From 94484f419ab9be9a9edd23b19159022739e9f8f2 Mon Sep 17 00:00:00 2001 From: mayankbohradev Date: Tue, 6 Oct 2026 17:15:07 +0530 Subject: [PATCH] fix: honor legacy custom async HTTP clients Keep a reference to the automatic async client so a legacy custom client is selected before that default. Preserve priority for a replacement dedicated client and document the selection order. Add regression and control tests for dispatch, timeouts and errors. --- README.md | 12 + resend/__init__.py | 4 +- resend/async_request.py | 10 +- tests/async_http_client_selection_test.py | 253 ++++++++++++++++++++++ 4 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 tests/async_http_client_selection_test.py diff --git a/README.md b/README.md index ea8f916..c0c407b 100644 --- a/README.md +++ b/README.md @@ -97,3 +97,15 @@ import resend resend.api_key = "re_yourkey" resend.default_async_http_client = resend.HTTPXClient(timeout=60) ``` + +Legacy configurations that assign an `AsyncHTTPClient` to +`resend.default_http_client` are also supported. Async requests select a client +in this order: + +1. A replacement client assigned to `resend.default_async_http_client`. +2. An `AsyncHTTPClient` assigned to `resend.default_http_client`. +3. The automatic `HTTPXClient`, if httpx is installed. + +Setting `resend.default_async_http_client = None` still permits the legacy +fallback. Prefer the dedicated async setting for new code, so the sync client +remains available for synchronous calls. diff --git a/resend/__init__.py b/resend/__init__.py index c2b983c..0510536 100644 --- a/resend/__init__.py +++ b/resend/__init__.py @@ -109,6 +109,7 @@ # Async HTTP client — auto-detected if httpx is installed, can be overridden # with any AsyncHTTPClient subclass. Set to None if no async library is available. default_async_http_client: Optional[AsyncHTTPClient] = None +_auto_async_http_client: Optional[AsyncHTTPClient] = None # Config vars api_key = os.environ.get("RESEND_API_KEY") @@ -262,7 +263,8 @@ from .async_request import AsyncRequest # noqa: F401 from .http_client_httpx import HTTPXClient # noqa: F401 - default_async_http_client = HTTPXClient() + _auto_async_http_client = HTTPXClient() + default_async_http_client = _auto_async_http_client __all__.extend(["AsyncHTTPClient", "HTTPXClient", "AsyncRequest"]) except ImportError: pass diff --git a/resend/async_request.py b/resend/async_request.py index c647ac9..be17658 100644 --- a/resend/async_request.py +++ b/resend/async_request.py @@ -101,13 +101,13 @@ async def make_request(self, url: str) -> Union[Dict[str, Any], List[Any]]: json_params = None try: - # Priority 1: dedicated async client (auto-detected or explicitly set) + # An explicitly replaced dedicated client takes priority. async_client = resend.default_async_http_client - # Priority 2: user set an AsyncHTTPClient on default_http_client (legacy, still supported) - if async_client is None and isinstance( - resend.default_http_client, AsyncHTTPClient - ): + # Legacy custom clients take priority over the automatic default. + if ( + async_client is None or async_client is resend._auto_async_http_client + ) and isinstance(resend.default_http_client, AsyncHTTPClient): async_client = resend.default_http_client if async_client is None: diff --git a/tests/async_http_client_selection_test.py b/tests/async_http_client_selection_test.py new file mode 100644 index 0000000..d7c6cbb --- /dev/null +++ b/tests/async_http_client_selection_test.py @@ -0,0 +1,253 @@ +import json +from typing import Any, List, Mapping, Tuple + +import httpx +import pytest + +import resend +from resend.exceptions import ResendError +from resend.http_client_async import AsyncHTTPClient + +pytestmark = pytest.mark.asyncio + +AUTOMATIC_CLIENT = resend.default_async_http_client + + +class NamedHTTPXClient(resend.HTTPXClient): + def __init__(self, name: str, timeout: int = 60) -> None: + super().__init__(timeout=timeout) + self.name = name + + async def request( + self, *args: Any, **kwargs: Any + ) -> Tuple[bytes, int, Mapping[str, str]]: + kwargs["headers"] = {**kwargs["headers"], "x-test-client": self.name} + return await super().request(*args, **kwargs) + + +class FailingAsyncClient(AsyncHTTPClient): + async def request( + self, *args: Any, **kwargs: Any + ) -> Tuple[bytes, int, Mapping[str, str]]: + raise RuntimeError("custom transport failed") + + +@pytest.fixture # type: ignore +def network(monkeypatch: pytest.MonkeyPatch) -> List[httpx.Request]: + requests: List[httpx.Request] = [] + real_async_client = httpx.AsyncClient + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + body: Any = {"id": request.headers.get("x-test-client", "automatic")} + if request.url.path == "/usage": + body = { + "object": "usage", + "emails": { + "daily": { + "used": 0, + "limit": 100, + "sent": 0, + "received": 0, + "resets_at": "2026-01-02T00:00:00Z", + }, + "monthly": { + "used": 0, + "limit": 1000, + "sent": 0, + "received": 0, + "resets_at": "2026-02-01T00:00:00Z", + }, + }, + "contacts": {"used": 0, "limit": 1000}, + "segments": {"used": 0, "limit": 3}, + "broadcasts": {"used": 0, "limit": None}, + "ai_credits": { + "used": 0, + "limit": 100, + "next_increase_at": "2026-01-02T00:00:00Z", + }, + "automation_runs": { + "used": 0, + "limit": 100, + "resets_at": "2026-02-01T00:00:00Z", + }, + "domains": {"used": 1, "limit": 10}, + "rate_limit": {"limit": 10, "duration": "1000ms"}, + } + elif request.method == "GET": + body.update( + { + "object": "email", + "message_id": "", + "from": "team@example.com", + "to": ["user@example.com"], + "created_at": "2026-01-01T00:00:00Z", + "subject": "Test", + "html": "", + "text": "Hello", + "bcc": [], + "cc": [], + "reply_to": [], + "last_event": "delivered", + } + ) + return httpx.Response( + 200, + json=body, + headers={"x-request-id": "req_test"}, + ) + + def isolated_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient: + kwargs["transport"] = httpx.MockTransport(respond) + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", isolated_client) + monkeypatch.setattr(resend, "api_key", "re_test") + monkeypatch.setattr(resend, "api_url", "https://test.invalid") + monkeypatch.setattr(resend, "default_http_client", resend.RequestsClient()) + monkeypatch.setattr(resend, "default_async_http_client", AUTOMATIC_CLIENT) + return requests + + +@pytest.mark.parametrize("resource", ["send", "get", "usage"]) # type: ignore +async def test_legacy_async_client_overrides_automatic_client( + monkeypatch: pytest.MonkeyPatch, network: List[httpx.Request], resource: str +) -> None: + monkeypatch.setattr(resend, "default_http_client", NamedHTTPXClient("legacy")) + + response: Any + if resource == "send": + response = await resend.Emails.send_async( + { + "from": "team@example.com", + "to": ["user@example.com"], + "subject": "Test", + "text": "Hello", + }, + {"idempotency_key": "test-send"}, + ) + assert network[0].method == "POST" + assert json.loads(network[0].content)["subject"] == "Test" + assert network[0].headers["idempotency-key"] == "test-send" + elif resource == "get": + response = await resend.Emails.get_async("email_1") + assert network[0].url.path == "/emails/email_1" + else: + response = await resend.Usage.get_async() + assert network[0].url.path == "/usage" + assert response["object"] == "usage" + + assert network[0].headers.get("x-test-client") == "legacy" + assert response["http_headers"]["x-request-id"] == "req_test" + assert len(network) == 1 + assert network[0].headers["authorization"] == "Bearer re_test" + assert network[0].extensions["timeout"]["read"] == 60 + + +async def test_legacy_httpx_client_uses_configured_timeout( + monkeypatch: pytest.MonkeyPatch, network: List[httpx.Request] +) -> None: + monkeypatch.setattr(resend, "default_http_client", resend.HTTPXClient(timeout=45)) + + await resend.Emails.get_async("email_1") + + assert network[0].extensions["timeout"]["read"] == 45 + + +async def test_explicit_dedicated_client_has_priority_over_legacy_client( + monkeypatch: pytest.MonkeyPatch, network: List[httpx.Request] +) -> None: + monkeypatch.setattr(resend, "default_http_client", NamedHTTPXClient("legacy")) + monkeypatch.setattr( + resend, "default_async_http_client", NamedHTTPXClient("dedicated", timeout=90) + ) + + response = await resend.Emails.get_async("email_1") + + assert response["id"] == "dedicated" + assert network[0].extensions["timeout"]["read"] == 90 + + +async def test_explicit_httpx_client_has_priority_over_legacy_client( + monkeypatch: pytest.MonkeyPatch, network: List[httpx.Request] +) -> None: + monkeypatch.setattr(resend, "default_http_client", NamedHTTPXClient("legacy")) + monkeypatch.setattr( + resend, "default_async_http_client", resend.HTTPXClient(timeout=75) + ) + + response = await resend.Emails.get_async("email_1") + + assert response["id"] == "automatic" + assert network[0].extensions["timeout"]["read"] == 75 + + +async def test_automatic_client_is_used_with_sync_default( + network: List[httpx.Request], +) -> None: + response = await resend.Emails.get_async("email_1") + + assert response["id"] == "automatic" + assert network[0].extensions["timeout"]["read"] == 30 + assert isinstance(resend.default_http_client, resend.RequestsClient) + + +async def test_legacy_client_is_used_when_dedicated_client_is_none( + monkeypatch: pytest.MonkeyPatch, network: List[httpx.Request] +) -> None: + monkeypatch.setattr(resend, "default_http_client", NamedHTTPXClient("legacy")) + monkeypatch.setattr(resend, "default_async_http_client", None) + + response = await resend.Emails.get_async("email_1") + + assert response["id"] == "legacy" + assert network[0].extensions["timeout"]["read"] == 60 + + +async def test_restoring_automatic_client_restores_legacy_priority( + monkeypatch: pytest.MonkeyPatch, network: List[httpx.Request] +) -> None: + monkeypatch.setattr(resend, "default_http_client", NamedHTTPXClient("legacy")) + monkeypatch.setattr( + resend, "default_async_http_client", NamedHTTPXClient("dedicated") + ) + assert (await resend.Emails.get_async("email_1"))["id"] == "dedicated" + + monkeypatch.setattr(resend, "default_async_http_client", AUTOMATIC_CLIENT) + + assert (await resend.Emails.get_async("email_1"))["id"] == "legacy" + assert len(network) == 2 + + +async def test_missing_async_client_retains_configuration_error( + monkeypatch: pytest.MonkeyPatch, network: List[httpx.Request] +) -> None: + monkeypatch.setattr(resend, "default_async_http_client", None) + + with pytest.raises(ResendError) as error: + await resend.Emails.get_async("email_1") + + assert error.value.error_type == "AsyncClientNotConfigured" + assert error.value.code == 500 + assert network == [] + + +@pytest.mark.parametrize("dedicated", [False, True]) # type: ignore +async def test_selected_custom_client_error_is_not_bypassed( + monkeypatch: pytest.MonkeyPatch, + network: List[httpx.Request], + dedicated: bool, +) -> None: + if dedicated: + monkeypatch.setattr(resend, "default_http_client", NamedHTTPXClient("legacy")) + monkeypatch.setattr(resend, "default_async_http_client", FailingAsyncClient()) + else: + monkeypatch.setattr(resend, "default_http_client", FailingAsyncClient()) + + with pytest.raises(ResendError) as error: + await resend.Emails.get_async("email_1") + + assert error.value.error_type == "HttpClientError" + assert "custom transport failed" in str(error.value) + assert network == []