diff --git a/examples/domains.py b/examples/domains.py index 1f530b0..520c5c7 100644 --- a/examples/domains.py +++ b/examples/domains.py @@ -37,8 +37,8 @@ print(f"Has more domains: {domains['has_more']}") if not domains["data"]: print("No domains found") -for domain in domains["data"]: - print(domain) +for listed_domain in domains["data"]: + print(listed_domain) print("\n--- Using pagination parameters ---") if domains["data"]: diff --git a/examples/with_headers.py b/examples/with_headers.py new file mode 100644 index 0000000..ad36ed7 --- /dev/null +++ b/examples/with_headers.py @@ -0,0 +1,52 @@ +""" +Example demonstrating how to access response headers. + +Response headers include useful information like rate limits, request IDs, etc. +""" + +import os + +import resend + +if not os.environ["RESEND_API_KEY"]: + raise EnvironmentError("RESEND_API_KEY is missing") + +params: resend.Emails.SendParams = { + "from": "onboarding@resend.dev", + "to": ["delivered@resend.dev"], + "subject": "Hello from Resend", + "html": "Hello, world!", +} + +resp: resend.Emails.SendResponse = resend.Emails.send(params) +print(f"Email sent! ID: {resp['id']}") + +if "headers" in resp: + print(f"Request ID: {resp['headers'].get('x-request-id')}") + print(f"Rate limit: {resp['headers'].get('x-ratelimit-limit')}") + print(f"Rate limit remaining: {resp['headers'].get('x-ratelimit-remaining')}") + print(f"Rate limit reset: {resp['headers'].get('x-ratelimit-reset')}") + +print("\n") +print("Example 3: Rate limit tracking") + + +def send_with_rate_limit_check(params: resend.Emails.SendParams) -> str: + """Example function showing how to track rate limits.""" + response = resend.Emails.send(params) + + # Access headers via dict key + headers = response.get("headers", {}) + remaining = headers.get("x-ratelimit-remaining") + limit = headers.get("x-ratelimit-limit") + + if remaining and limit: + print(f"Rate limit usage: {int(limit) - int(remaining)}/{limit}") + if int(remaining) < 10: + print("Warning: Approaching rate limit!") + + return response["id"] + + +email_id = send_with_rate_limit_check(params) +print(f"Sent email with ID: {email_id}") diff --git a/resend/_base_response.py b/resend/_base_response.py new file mode 100644 index 0000000..47ac531 --- /dev/null +++ b/resend/_base_response.py @@ -0,0 +1,16 @@ +"""Base response type for all Resend API responses.""" + +from typing import Dict + +from typing_extensions import NotRequired, TypedDict + + +class BaseResponse(TypedDict): + """Base response type that all API responses inherit from. + + Attributes: + headers: HTTP response headers including rate limit info, request IDs, etc. + Optional field that may not be present in all responses. + """ + + headers: NotRequired[Dict[str, str]] diff --git a/resend/api_keys/_api_keys.py b/resend/api_keys/_api_keys.py index 843cf8c..30a2583 100644 --- a/resend/api_keys/_api_keys.py +++ b/resend/api_keys/_api_keys.py @@ -3,13 +3,14 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.api_keys._api_key import ApiKey from resend.pagination_helper import PaginationHelper class ApiKeys: - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of API key objects with pagination metadata @@ -32,7 +33,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateApiKeyResponse(TypedDict): + class CreateApiKeyResponse(BaseResponse): """ CreateApiKeyResponse is the type that wraps the response of the API key that was created diff --git a/resend/audiences/_audiences.py b/resend/audiences/_audiences.py index 95a6116..cd4cb7e 100644 --- a/resend/audiences/_audiences.py +++ b/resend/audiences/_audiences.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict +from resend._base_response import BaseResponse from resend.segments._segments import Segments from ._audience import Audience @@ -10,7 +11,7 @@ class Audiences: - class RemoveAudienceResponse(TypedDict): + class RemoveAudienceResponse(BaseResponse): """ RemoveAudienceResponse is the type that wraps the response of the audience that was removed @@ -51,7 +52,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of audience objects with pagination metadata @@ -74,7 +75,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateAudienceResponse(TypedDict): + class CreateAudienceResponse(BaseResponse): """ CreateAudienceResponse is the type that wraps the response of the audience that was created diff --git a/resend/broadcasts/_broadcasts.py b/resend/broadcasts/_broadcasts.py index 5211b4e..748e4ff 100644 --- a/resend/broadcasts/_broadcasts.py +++ b/resend/broadcasts/_broadcasts.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._broadcast import Broadcast @@ -156,7 +157,7 @@ class SendParams(TypedDict): The date should be in natural language (e.g.: in 1 min) or ISO 8601 format (e.g: 2024-08-05T11:52:01.858Z). """ - class CreateResponse(TypedDict): + class CreateResponse(BaseResponse): """ CreateResponse is the class that wraps the response of the create method. @@ -169,7 +170,7 @@ class CreateResponse(TypedDict): id of the created broadcast """ - class UpdateResponse(TypedDict): + class UpdateResponse(BaseResponse): """ UpdateResponse is the class that wraps the response of the update method. @@ -208,7 +209,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse is the class that wraps the response of the list method with pagination metadata. @@ -231,7 +232,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class RemoveResponse(TypedDict): + class RemoveResponse(BaseResponse): """ RemoveResponse is the class that wraps the response of the remove method. diff --git a/resend/contact_properties/_contact_properties.py b/resend/contact_properties/_contact_properties.py index 2e6bccf..b649e98 100644 --- a/resend/contact_properties/_contact_properties.py +++ b/resend/contact_properties/_contact_properties.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._contact_property import ContactProperty @@ -10,7 +11,7 @@ class ContactProperties: - class CreateResponse(TypedDict): + class CreateResponse(BaseResponse): """ CreateResponse is the type that wraps the response of the contact property that was created. @@ -28,7 +29,7 @@ class CreateResponse(TypedDict): The object type, always "contact_property". """ - class UpdateResponse(TypedDict): + class UpdateResponse(BaseResponse): """ UpdateResponse is the type that wraps the response of the contact property that was updated. @@ -46,7 +47,7 @@ class UpdateResponse(TypedDict): The object type, always "contact_property". """ - class RemoveResponse(TypedDict): + class RemoveResponse(BaseResponse): """ RemoveResponse is the type that wraps the response of the contact property that was removed. @@ -96,7 +97,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of contact property objects with pagination metadata. diff --git a/resend/contacts/_contacts.py b/resend/contacts/_contacts.py index 2468af4..e166321 100644 --- a/resend/contacts/_contacts.py +++ b/resend/contacts/_contacts.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._contact import Contact @@ -15,7 +16,7 @@ class Contacts: Segments = ContactSegments Topics = Topics - class RemoveContactResponse(TypedDict): + class RemoveContactResponse(BaseResponse): """ RemoveContactResponse is the type that wraps the response of the contact that was removed @@ -56,7 +57,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of contact objects with pagination metadata @@ -79,7 +80,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateContactResponse(TypedDict): + class CreateContactResponse(BaseResponse): """ CreateContactResponse is the type that wraps the response of the contact that was created @@ -97,7 +98,7 @@ class CreateContactResponse(TypedDict): The ID of the scheduled email that was canceled. """ - class UpdateContactResponse(TypedDict): + class UpdateContactResponse(BaseResponse): """ UpdateContactResponse is the type that wraps the response of the contact that was updated diff --git a/resend/contacts/_topics.py b/resend/contacts/_topics.py index 0e414bd..d178e04 100644 --- a/resend/contacts/_topics.py +++ b/resend/contacts/_topics.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._contact_topic import ContactTopic, TopicSubscriptionUpdate @@ -23,7 +24,7 @@ class _ListParams(TypedDict): """ -class _ListResponse(TypedDict): +class _ListResponse(BaseResponse): object: str """ The object type: "list" @@ -53,7 +54,7 @@ class _UpdateParams(TypedDict): """ -class _UpdateResponse(TypedDict): +class _UpdateResponse(BaseResponse): id: str """ The contact ID. diff --git a/resend/contacts/segments/_contact_segments.py b/resend/contacts/segments/_contact_segments.py index 854b6f5..f0dae00 100644 --- a/resend/contacts/segments/_contact_segments.py +++ b/resend/contacts/segments/_contact_segments.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._contact_segment import ContactSegment @@ -14,7 +15,7 @@ class ContactSegments: This is separate from the main Contacts API which uses audience_id. """ - class AddContactSegmentResponse(TypedDict): + class AddContactSegmentResponse(BaseResponse): """ AddContactSegmentResponse is the type that wraps the response when adding a contact to a segment. @@ -27,7 +28,7 @@ class AddContactSegmentResponse(TypedDict): The ID of the contact segment association. """ - class RemoveContactSegmentResponse(TypedDict): + class RemoveContactSegmentResponse(BaseResponse): """ RemoveContactSegmentResponse is the type that wraps the response when removing a contact from a segment. @@ -72,7 +73,7 @@ class ListContactSegmentsParams(TypedDict): Cannot be used with the after parameter. """ - class ListContactSegmentsResponse(TypedDict): + class ListContactSegmentsResponse(BaseResponse): """ ListContactSegmentsResponse type that wraps a list of segment objects with pagination metadata. diff --git a/resend/domains/_domains.py b/resend/domains/_domains.py index 39cb85f..c1bf090 100644 --- a/resend/domains/_domains.py +++ b/resend/domains/_domains.py @@ -3,6 +3,7 @@ from typing_extensions import Literal, NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.domains._domain import Domain from resend.domains._record import Record from resend.pagination_helper import PaginationHelper @@ -30,7 +31,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of domain objects with pagination metadata @@ -53,7 +54,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateDomainResponse(TypedDict): + class CreateDomainResponse(BaseResponse): """ CreateDomainResponse is the type that wraps the response of the domain that was created diff --git a/resend/emails/_attachments.py b/resend/emails/_attachments.py index c7172e9..a8568dd 100644 --- a/resend/emails/_attachments.py +++ b/resend/emails/_attachments.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.emails._received_email import (EmailAttachment, EmailAttachmentDetails) from resend.pagination_helper import PaginationHelper @@ -23,7 +24,7 @@ class _ListParams(TypedDict): """ -class _ListResponse(TypedDict): +class _ListResponse(BaseResponse): object: str """ The object type: "list" diff --git a/resend/emails/_batch.py b/resend/emails/_batch.py index 1fdd55c..f8d5f69 100644 --- a/resend/emails/_batch.py +++ b/resend/emails/_batch.py @@ -3,11 +3,12 @@ from typing_extensions import Literal, NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from ._emails import Emails -class SendEmailResponse(TypedDict): +class SendEmailResponse(BaseResponse): id: str """ The sent Email ID. @@ -60,7 +61,7 @@ class SendOptions(TypedDict): Defaults to "strict" when not provided. """ - class SendResponse(TypedDict): + class SendResponse(BaseResponse): data: List[SendEmailResponse] """ A list of email objects diff --git a/resend/emails/_emails.py b/resend/emails/_emails.py index eedba0f..9f7416a 100644 --- a/resend/emails/_emails.py +++ b/resend/emails/_emails.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.emails._attachment import Attachment, RemoteAttachment from resend.emails._attachments import Attachments from resend.emails._email import Email @@ -42,7 +43,7 @@ class _UpdateParams(TypedDict): """ -class _UpdateEmailResponse(TypedDict): +class _UpdateEmailResponse(BaseResponse): object: str """ The object type: email @@ -53,7 +54,7 @@ class _UpdateEmailResponse(TypedDict): """ -class _CancelScheduledEmailResponse(TypedDict): +class _CancelScheduledEmailResponse(BaseResponse): object: str """ The object type: email @@ -194,12 +195,13 @@ class SendOptions(TypedDict): If provided, will be sent as the `Idempotency-Key` header. """ - class SendResponse(TypedDict): + class SendResponse(BaseResponse): """ SendResponse is the type that wraps the response of the email that was sent. Attributes: id (str): The ID of the sent email + headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse) """ id: str @@ -230,7 +232,7 @@ class ListParams(TypedDict): Return emails before this cursor for pagination. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse is the type that wraps the response for listing emails. @@ -238,6 +240,7 @@ class ListResponse(TypedDict): object (str): The object type: "list" data (List[Email]): The list of email objects. has_more (bool): Whether there are more emails available for pagination. + headers (NotRequired[Dict[str, str]]): HTTP response headers (inherited from BaseResponse) """ object: str @@ -269,7 +272,7 @@ def send( id: The ID of the sent email """ path = "/emails" - resp = request.Request[Email]( + resp = request.Request[Emails.SendResponse]( path=path, params=cast(Dict[Any, Any], params), verb="post", diff --git a/resend/emails/_receiving.py b/resend/emails/_receiving.py index 7eed6ec..97c98d3 100644 --- a/resend/emails/_receiving.py +++ b/resend/emails/_receiving.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.emails._received_email import (EmailAttachment, EmailAttachmentDetails, ListReceivedEmail, ReceivedEmail) @@ -24,7 +25,7 @@ class _ListParams(TypedDict): """ -class _ListResponse(TypedDict): +class _ListResponse(BaseResponse): object: str """ The object type: "list" @@ -54,7 +55,7 @@ class _AttachmentListParams(TypedDict): """ -class _AttachmentListResponse(TypedDict): +class _AttachmentListResponse(BaseResponse): object: str """ The object type: "list" diff --git a/resend/exceptions.py b/resend/exceptions.py index e16d093..41af790 100644 --- a/resend/exceptions.py +++ b/resend/exceptions.py @@ -4,7 +4,7 @@ codes as outlined in https://resend.com/docs/api-reference/errors. """ -from typing import Any, Dict, NoReturn, Union +from typing import Any, Dict, NoReturn, Optional, Union class ResendError(Exception): @@ -29,12 +29,14 @@ def __init__( error_type: str, message: str, suggested_action: str, + headers: Optional[Dict[str, str]] = None, ): Exception.__init__(self, message) self.code = code self.message = message self.suggested_action = suggested_action self.error_type = error_type + self.headers = headers or {} class MissingApiKeyError(ResendError): @@ -45,6 +47,7 @@ def __init__( message: str, error_type: str, code: Union[str, int], + headers: Optional[Dict[str, str]] = None, ): suggested_action = """Include the following header Authorization: Bearer YOUR_API_KEY in the request.""" @@ -57,6 +60,7 @@ def __init__( suggested_action=suggested_action, code=code, error_type=error_type, + headers=headers, ) @@ -68,6 +72,7 @@ def __init__( message: str, error_type: str, code: Union[str, int], + headers: Optional[Dict[str, str]] = None, ): suggested_action = """Generate a new API key in the dashboard.""" @@ -77,6 +82,7 @@ def __init__( suggested_action=suggested_action, code=code, error_type=error_type, + headers=headers, ) @@ -88,6 +94,7 @@ def __init__( message: str, error_type: str, code: Union[str, int], + headers: Optional[Dict[str, str]] = None, ): default_message = """ The request body is missing one or more required fields.""" @@ -104,6 +111,7 @@ def __init__( message=message, suggested_action=suggested_action, error_type=error_type, + headers=headers, ) @@ -115,6 +123,7 @@ def __init__( message: str, error_type: str, code: Union[str, int], + headers: Optional[Dict[str, str]] = None, ): default_message = """ The request body is missing one or more required fields.""" @@ -131,6 +140,7 @@ def __init__( message=message, suggested_action=suggested_action, error_type=error_type, + headers=headers, ) @@ -142,6 +152,7 @@ def __init__( message: str, error_type: str, code: Union[str, int], + headers: Optional[Dict[str, str]] = None, ): default_message = """ Something went wrong.""" @@ -157,6 +168,7 @@ def __init__( message=message, suggested_action=suggested_action, error_type=error_type, + headers=headers, ) @@ -168,6 +180,7 @@ def __init__( message: str, error_type: str, code: Union[str, int], + headers: Optional[Dict[str, str]] = None, ): suggested_action = """Reduce your request rate or wait before retrying. """ suggested_action += """Check the response headers for rate limit information.""" @@ -178,6 +191,7 @@ def __init__( message=message, suggested_action=suggested_action, error_type=error_type, + headers=headers, ) @@ -200,7 +214,10 @@ def __init__( def raise_for_code_and_type( - code: Union[str, int], error_type: str, message: str + code: Union[str, int], + error_type: str, + message: str, + headers: Optional[Dict[str, str]] = None, ) -> NoReturn: """Raise the appropriate error based on the code and type. @@ -208,6 +225,7 @@ def raise_for_code_and_type( code (str): The error code error_type (str): The error type message (str): The error message + headers (Optional[Dict[str, str]]): The HTTP response headers Raises: ResendError: If it is a Resend err @@ -231,7 +249,11 @@ def raise_for_code_and_type( # Handle the case where the error might be unknown if error is None: raise ResendError( - code=code, message=message, error_type=error_type, suggested_action="" + code=code, + message=message, + error_type=error_type, + suggested_action="", + headers=headers, ) # Raise error from errors list @@ -242,10 +264,15 @@ def raise_for_code_and_type( code=code, message=message, error_type=error_type, + headers=headers, ) # defaults to ResendError if finally can't find error type raise ResendError( - code=code, message=message, error_type=error_type, suggested_action="" + code=code, + message=message, + error_type=error_type, + suggested_action="", + headers=headers, ) diff --git a/resend/request.py b/resend/request.py index 3518f69..5d8ddf3 100644 --- a/resend/request.py +++ b/resend/request.py @@ -28,6 +28,7 @@ def __init__( self.params = params self.verb = verb self.options = options + self._response_headers: Dict[str, str] = {} def perform(self) -> Union[T, None]: data = self.make_request(url=f"{resend.api_url}{self.path}") @@ -37,6 +38,7 @@ def perform(self) -> Union[T, None]: code=data.get("statusCode") or 500, message=data.get("message", "Unknown error"), error_type=data.get("name", "InternalServerError"), + headers=self._response_headers, ) if isinstance(data, dict): @@ -93,6 +95,9 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: suggested_action="Request failed, please try again.", ) + # Store response headers for later access + self._response_headers = dict(resp_headers) + content_type = {k.lower(): v for k, v in resp_headers.items()}.get( "content-type", "" ) @@ -102,13 +107,20 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: code=500, message=f"Expected JSON response but got: {content_type}", error_type="InternalServerError", + headers=self._response_headers, ) try: - return cast(Union[Dict[str, Any], List[Any]], json.loads(content)) + parsed_data = cast(Union[Dict[str, Any], List[Any]], json.loads(content)) + # Inject headers into dict responses + if isinstance(parsed_data, dict): + parsed_data["headers"] = dict(self._response_headers) + # For list responses, return as-is (lists can't have headers key) + return parsed_data except json.JSONDecodeError: raise_for_code_and_type( code=500, message="Failed to decode JSON response", error_type="InternalServerError", + headers=self._response_headers, ) diff --git a/resend/segments/_segments.py b/resend/segments/_segments.py index 4dadd0f..3f2dbfa 100644 --- a/resend/segments/_segments.py +++ b/resend/segments/_segments.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._segment import Segment @@ -10,7 +11,7 @@ class Segments: - class RemoveSegmentResponse(TypedDict): + class RemoveSegmentResponse(BaseResponse): """ RemoveSegmentResponse is the type that wraps the response of the segment that was removed @@ -51,7 +52,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of segment objects with pagination metadata @@ -74,7 +75,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateSegmentResponse(TypedDict): + class CreateSegmentResponse(BaseResponse): """ CreateSegmentResponse is the type that wraps the response of the segment that was created diff --git a/resend/templates/_templates.py b/resend/templates/_templates.py index 64b7bb4..3532d44 100644 --- a/resend/templates/_templates.py +++ b/resend/templates/_templates.py @@ -5,6 +5,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._template import Template, TemplateListItem, Variable @@ -68,7 +69,7 @@ class CreateParams(_CreateParamsFrom): variables: NotRequired[List[Variable]] """The array of variables used in the template.""" - class CreateResponse(TypedDict): + class CreateResponse(BaseResponse): """Response from creating a template. Attributes: @@ -121,7 +122,7 @@ class UpdateParams(_CreateParamsFrom): variables: NotRequired[List[Variable]] """The array of variables used in the template.""" - class UpdateResponse(TypedDict): + class UpdateResponse(BaseResponse): """Response from updating a template. Attributes: @@ -153,7 +154,7 @@ class ListParams(TypedDict): before: NotRequired[str] """Return templates before this cursor.""" - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """Response from listing templates. Attributes: @@ -171,7 +172,7 @@ class ListResponse(TypedDict): has_more: bool """Whether there are more results available.""" - class PublishResponse(TypedDict): + class PublishResponse(BaseResponse): """Response from publishing a template. Attributes: @@ -185,7 +186,7 @@ class PublishResponse(TypedDict): object: str """The object type (always "template").""" - class DuplicateResponse(TypedDict): + class DuplicateResponse(BaseResponse): """Response from duplicating a template. Attributes: @@ -199,7 +200,7 @@ class DuplicateResponse(TypedDict): object: str """The object type (always "template").""" - class RemoveResponse(TypedDict): + class RemoveResponse(BaseResponse): """Response from removing a template. Attributes: diff --git a/resend/topics/_topics.py b/resend/topics/_topics.py index 19e535b..e724edc 100644 --- a/resend/topics/_topics.py +++ b/resend/topics/_topics.py @@ -3,6 +3,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from ._topic import Topic @@ -10,7 +11,7 @@ class Topics: - class CreateTopicResponse(TypedDict): + class CreateTopicResponse(BaseResponse): """ CreateTopicResponse is the type that wraps the response of the topic that was created @@ -38,7 +39,7 @@ class CreateParams(TypedDict): The topic description. Max length is 200 characters. """ - class UpdateTopicResponse(TypedDict): + class UpdateTopicResponse(BaseResponse): """ UpdateTopicResponse is the type that wraps the response of the topic that was updated @@ -61,7 +62,7 @@ class UpdateParams(TypedDict, total=False): The topic description. Max length is 200 characters. """ - class RemoveTopicResponse(TypedDict): + class RemoveTopicResponse(BaseResponse): """ RemoveTopicResponse is the type that wraps the response of the topic that was removed @@ -102,7 +103,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of topic objects with pagination metadata diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index caa5d74..1abf0ea 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -7,6 +7,7 @@ from typing_extensions import NotRequired, TypedDict from resend import request +from resend._base_response import BaseResponse from resend.pagination_helper import PaginationHelper from resend.webhooks._webhook import (VerifyWebhookOptions, Webhook, WebhookEvent, WebhookStatus) @@ -35,7 +36,7 @@ class ListParams(TypedDict): Cannot be used with the after parameter. """ - class ListResponse(TypedDict): + class ListResponse(BaseResponse): """ ListResponse type that wraps a list of webhook objects with pagination metadata @@ -58,7 +59,7 @@ class ListResponse(TypedDict): Whether there are more results available for pagination """ - class CreateWebhookResponse(TypedDict): + class CreateWebhookResponse(BaseResponse): """ CreateWebhookResponse is the type that wraps the response of the webhook that was created @@ -110,7 +111,7 @@ class UpdateParams(TypedDict): The webhook status. Can be either "enabled" or "disabled". """ - class UpdateWebhookResponse(TypedDict): + class UpdateWebhookResponse(BaseResponse): """ UpdateWebhookResponse is the type that wraps the response of the webhook that was updated @@ -128,7 +129,7 @@ class UpdateWebhookResponse(TypedDict): The ID of the updated webhook """ - class DeleteWebhookResponse(TypedDict): + class DeleteWebhookResponse(BaseResponse): """ DeleteWebhookResponse is the type that wraps the response of the webhook that was deleted diff --git a/tests/exceptions_test.py b/tests/exceptions_test.py index bf34089..39256ee 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -55,3 +55,60 @@ def test_monthly_quota_exceeded_error(self) -> None: assert e.type is RateLimitError assert e.value.code == 429 assert e.value.error_type == "monthly_quota_exceeded" + + def test_headers_default_to_empty_dict(self) -> None: + with pytest.raises(ResendError) as e: + raise_for_code_and_type(999, "error_type", "msg") + assert e.value.headers == {} + + def test_headers_passed_to_known_error(self) -> None: + headers = { + "retry-after": "5", + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": "1699564800", + } + with pytest.raises(RateLimitError) as e: + raise_for_code_and_type( + 429, + "rate_limit_exceeded", + "Rate limit exceeded", + headers=headers, + ) + assert e.value.headers == headers + assert e.value.headers["retry-after"] == "5" + assert e.value.headers["x-ratelimit-remaining"] == "0" + + def test_headers_passed_to_unknown_error(self) -> None: + headers = {"x-request-id": "req_123"} + with pytest.raises(ResendError) as e: + raise_for_code_and_type(999, "unknown", "msg", headers=headers) + assert e.value.headers == headers + + def test_headers_passed_to_unknown_error_type(self) -> None: + headers = {"x-request-id": "req_456"} + with pytest.raises(ResendError) as e: + raise_for_code_and_type(500, "unknown_type", "msg", headers=headers) + assert e.value.headers == headers + + def test_headers_on_validation_error(self) -> None: + headers = {"x-request-id": "req_789"} + with pytest.raises(ValidationError) as e: + raise_for_code_and_type( + 400, + "validation_error", + "err", + headers=headers, + ) + assert e.value.headers == headers + + def test_headers_on_application_error(self) -> None: + headers = {"x-request-id": "req_abc"} + with pytest.raises(ApplicationError) as e: + raise_for_code_and_type( + 500, + "application_error", + "err", + headers=headers, + ) + assert e.value.headers == headers diff --git a/tests/response_headers_integration_test.py b/tests/response_headers_integration_test.py new file mode 100644 index 0000000..2816025 --- /dev/null +++ b/tests/response_headers_integration_test.py @@ -0,0 +1,89 @@ +import unittest +from unittest.mock import Mock + +import resend + + +class TestResponseHeadersIntegration(unittest.TestCase): + def setUp(self) -> None: + """Set up test environment.""" + resend.api_key = "re_test_key" + + def test_email_send_response_includes_headers(self) -> None: + """Test that email send response includes headers.""" + # Mock the HTTP client to return headers + mock_client = Mock() + mock_client.request.return_value = ( + b'{"id": "email_123", "from": "test@example.com"}', + 200, + { + "content-type": "application/json", + "x-request-id": "req_abc123", + "x-ratelimit-limit": "100", + "x-ratelimit-remaining": "95", + "x-ratelimit-reset": "1699564800", + }, + ) + + # Replace default HTTP client with mock + original_client = resend.default_http_client + resend.default_http_client = mock_client + + try: + # Send email + response = resend.Emails.send( + { + "from": "test@example.com", + "to": "user@example.com", + "subject": "Test", + "html": "

Test

", + } + ) + + # Verify response is a dict + assert isinstance(response, dict) + + # Verify backward compatibility - dict access still works + assert response["id"] == "email_123" + assert response.get("from") == "test@example.com" + + # Verify new feature - headers are accessible via dict key + assert "headers" in response + assert response["headers"]["x-request-id"] == "req_abc123" + assert response["headers"]["x-ratelimit-limit"] == "100" + assert response["headers"]["x-ratelimit-remaining"] == "95" + assert response["headers"]["x-ratelimit-reset"] == "1699564800" + + finally: + # Restore original HTTP client + resend.default_http_client = original_client + + def test_list_response_headers(self) -> None: + """Test that list responses include headers.""" + # Mock the HTTP client to return a list + mock_client = Mock() + mock_client.request.return_value = ( + b'{"data": [{"id": "1"}, {"id": "2"}]}', + 200, + { + "content-type": "application/json", + "x-request-id": "req_xyz", + }, + ) + + original_client = resend.default_http_client + resend.default_http_client = mock_client + + try: + # Get API keys list + response = resend.ApiKeys.list() + + # List responses are dicts with data field + assert isinstance(response, dict) + assert "data" in response + # Headers are injected into the dict + assert "headers" in response + assert response["headers"]["x-request-id"] == "req_xyz" + + finally: + resend.default_http_client = original_client diff --git a/tests/response_test.py b/tests/response_test.py index 6dc9a3e..9da85de 100644 --- a/tests/response_test.py +++ b/tests/response_test.py @@ -23,9 +23,7 @@ def test_list_response_supports_dict_access(self) -> None: } ) - attachments = resend.Emails.Receiving.Attachments.list( - email_id="test-email-id" - ) + attachments = resend.Emails.Receiving.Attachments.list(email_id="test-email-id") assert attachments["object"] == "list" assert attachments["has_more"] is False assert len(attachments["data"]) == 1 @@ -48,9 +46,7 @@ def test_list_response_supports_attribute_access(self) -> None: } ) - attachments = resend.Emails.Receiving.Attachments.list( - email_id="test-email-id" - ) + attachments = resend.Emails.Receiving.Attachments.list(email_id="test-email-id") assert attachments.object == "list" # type: ignore[attr-defined] assert attachments.has_more is False # type: ignore[attr-defined] assert len(attachments.data) == 1 # type: ignore[attr-defined] @@ -65,9 +61,7 @@ def test_attribute_access_raises_for_missing_key(self) -> None: } ) - attachments = resend.Emails.Receiving.Attachments.list( - email_id="test-email-id" - ) + attachments = resend.Emails.Receiving.Attachments.list(email_id="test-email-id") with self.assertRaises(AttributeError): _ = attachments.nonexistent # type: ignore[attr-defined]