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..fbee52e --- /dev/null +++ b/examples/with_headers.py @@ -0,0 +1,70 @@ +""" +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!", +} + +print("=" * 60) +print("Example 1: Without type annotations") +print("=" * 60) + +response = resend.Emails.send(params) +print(f"Email sent! ID: {response['id']}") +print(f"Request ID: {response['headers'].get('x-request-id')}") +print(f"Rate limit: {response['headers'].get('x-ratelimit-limit')}") +print(f"Rate limit remaining: {response['headers'].get('x-ratelimit-remaining')}") +print(f"Rate limit reset: {response['headers'].get('x-ratelimit-reset')}") + +print("\n" + "=" * 60) +print("Example 2: With type annotations") +print("=" * 60) + +typed_response: resend.Emails.SendResponse = resend.Emails.send(params) +print(f"Email sent! ID: {typed_response['id']}") + +if "headers" in typed_response: + print(f"Request ID: {typed_response['headers'].get('x-request-id')}") + print(f"Rate limit: {typed_response['headers'].get('x-ratelimit-limit')}") + print( + f"Rate limit remaining: {typed_response['headers'].get('x-ratelimit-remaining')}" + ) + print(f"Rate limit reset: {typed_response['headers'].get('x-ratelimit-reset')}") + +print("\n" + "=" * 60) +print("Example 3: Rate limit tracking") +print("=" * 60) + + +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 676ebc6..285e59e 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 @@ -143,7 +144,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. @@ -156,7 +157,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. @@ -195,7 +196,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. @@ -218,7 +219,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/request.py b/resend/request.py index 2f1af85..a0334ae 100644 --- a/resend/request.py +++ b/resend/request.py @@ -27,6 +27,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}") @@ -90,6 +91,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,7 +106,12 @@ def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: ) 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, 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 cb76542..bf34089 100644 --- a/tests/exceptions_test.py +++ b/tests/exceptions_test.py @@ -49,7 +49,9 @@ def test_daily_quota_exceeded_error(self) -> None: def test_monthly_quota_exceeded_error(self) -> None: with pytest.raises(RateLimitError) as e: - raise_for_code_and_type(429, "monthly_quota_exceeded", "Monthly quota exceeded") + raise_for_code_and_type( + 429, "monthly_quota_exceeded", "Monthly quota exceeded" + ) assert e.type is RateLimitError assert e.value.code == 429 assert e.value.error_type == "monthly_quota_exceeded" 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