Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 38 additions & 18 deletions resend/http_client_httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
108 changes: 108 additions & 0 deletions tests/http_client_httpx_test.py
Original file line number Diff line number Diff line change
@@ -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