diff --git a/README.md b/README.md index c0c407b..223c671 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,19 @@ resend.api_key = "re_yourkey" resend.default_async_http_client = resend.HTTPXClient(timeout=60) ``` +By default, `HTTPXClient` opens a new connection for every request. To reuse +connections, pass your own `httpx.AsyncClient`. You own its lifecycle, so close +it when you're done: + +```py +import httpx +import resend + +async with httpx.AsyncClient(timeout=60) as client: + resend.default_async_http_client = resend.HTTPXClient(client=client) + ... +``` + Legacy configurations that assign an `AsyncHTTPClient` to `resend.default_http_client` are also supported. Async requests select a client in this order: diff --git a/resend/http_client_httpx.py b/resend/http_client_httpx.py index 32ea193..b0e2f15 100644 --- a/resend/http_client_httpx.py +++ b/resend/http_client_httpx.py @@ -8,10 +8,14 @@ class HTTPXClient(AsyncHTTPClient): """ Async HTTP client implementation using the httpx library. + + Pass an ``httpx.AsyncClient`` as ``client`` to reuse its connections across + requests. The caller owns that client and is responsible for closing it. """ - def __init__(self, timeout: int = 30): + def __init__(self, timeout: int = 30, client: Optional[httpx.AsyncClient] = None): self._timeout = timeout + self._client = client async def request( self, @@ -23,25 +27,41 @@ async def request( data: Optional[Dict[str, str]] = None, ) -> Tuple[bytes, int, Mapping[str, str]]: try: + if self._client is not None: + return await self._send( + self._client, method, url, headers, json, files, data + ) async with httpx.AsyncClient(timeout=self._timeout) as client: - if files is not None: - resp = await client.request( - method=method, - url=url, - headers=headers, - files=files, - data=data, - ) - else: - resp = await client.request( - method=method, - url=url, - headers=headers, - json=json if data is None else None, - data=data, - ) - return resp.content, resp.status_code, resp.headers + return await self._send(client, method, url, headers, json, files, data) except httpx.RequestError as e: # This gets caught by the async request.perform() method # and raises a ResendError with the error type "HttpClientError" raise RuntimeError(f"Request failed: {e}") from e + + async def _send( + self, + client: httpx.AsyncClient, + method: str, + url: str, + headers: Mapping[str, str], + json: Optional[Union[Dict[str, object], List[object]]], + files: Optional[Dict[str, Any]], + data: Optional[Dict[str, str]], + ) -> Tuple[bytes, int, Mapping[str, str]]: + if files is not None: + resp = await client.request( + method=method, + url=url, + headers=headers, + files=files, + data=data, + ) + else: + resp = await client.request( + method=method, + url=url, + headers=headers, + json=json if data is None else None, + data=data, + ) + return resp.content, resp.status_code, resp.headers diff --git a/tests/http_client_httpx_test.py b/tests/http_client_httpx_test.py new file mode 100644 index 0000000..8c5b7fc --- /dev/null +++ b/tests/http_client_httpx_test.py @@ -0,0 +1,108 @@ +from typing import Any, List + +import httpx +import pytest + +import resend +from resend.exceptions import ResendError + +pytestmark = pytest.mark.asyncio + +PARAMS: resend.Emails.SendParams = { + "from": "team@example.com", + "to": ["user@example.com"], + "subject": "Test", + "text": "Hello", +} + + +@pytest.fixture(autouse=True) # type: ignore +def configure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(resend, "api_key", "re_test") + monkeypatch.setattr(resend, "api_url", "https://test.invalid") + monkeypatch.setattr( + resend, "default_async_http_client", resend.default_async_http_client + ) + + +def recording_transport(requests: List[httpx.Request]) -> httpx.MockTransport: + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json={"id": "email_1"}) + + return httpx.MockTransport(respond) + + +async def test_provided_client_is_reused_and_left_open( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: List[httpx.Request] = [] + client = httpx.AsyncClient(transport=recording_transport(requests)) + + def no_new_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient: + raise AssertionError("HTTPXClient created its own httpx.AsyncClient") + + monkeypatch.setattr(httpx, "AsyncClient", no_new_client) + resend.default_async_http_client = resend.HTTPXClient(client=client) + + for _ in range(3): + email = await resend.Emails.send_async(PARAMS) + assert email["id"] == "email_1" + + assert len(requests) == 3 + assert all(r.url == "https://test.invalid/emails" for r in requests) + assert not client.is_closed + await client.aclose() + + +async def test_provided_client_sends_files() -> None: + requests: List[httpx.Request] = [] + async with httpx.AsyncClient(transport=recording_transport(requests)) as client: + content, status_code, _ = await resend.HTTPXClient(client=client).request( + method="post", + url="https://test.invalid/upload", + headers={}, + files={"file": ("a.csv", b"email\n", "text/csv")}, + data={"name": "a"}, + ) + + assert status_code == 200 + assert b"email_1" in content + assert requests[0].headers["content-type"].startswith("multipart/form-data") + assert b'name="name"' in requests[0].content + + +async def test_provided_client_request_error_raises_resend_error() -> None: + def fail(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + async with httpx.AsyncClient(transport=httpx.MockTransport(fail)) as client: + resend.default_async_http_client = resend.HTTPXClient(client=client) + + with pytest.raises(ResendError) as error: + await resend.Emails.send_async(PARAMS) + + assert error.value.error_type == "HttpClientError" + assert "connection refused" in error.value.message + + +async def test_without_client_a_new_client_is_created_per_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: List[httpx.Request] = [] + created: List[Any] = [] + real_async_client = httpx.AsyncClient + + def counting_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient: + created.append(kwargs.get("timeout")) + kwargs["transport"] = recording_transport(requests) + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", counting_client) + resend.default_async_http_client = resend.HTTPXClient(timeout=60) + + await resend.Emails.send_async(PARAMS) + await resend.Emails.send_async(PARAMS) + + assert created == [60, 60] + assert len(requests) == 2