feat: let HTTPXClient use a caller-owned httpx.AsyncClient

M4
1d ago
+159-18

HTTPXClient opens a new httpx.AsyncClient on every request, so async sends never reuse a connection. #252
tried to fix that by changing the default and was closed because of the edge cases around event loops and
closing. This takes a narrower route: the default stays exactly as it is, and callers can pass in a client they
own.

async with httpx.AsyncClient(timeout=60) as client:   # e.g. in a FastAPI lifespan
    resend.default_async_http_client = resend.HTTPXClient(client=client)
    ...
  • Without client, HTTPXClient still opens and closes a client per request, so nothing changes for anyone
    who doesn't opt in.
  • With client, the SDK never creates, binds or closes it. The caller makes it on their own loop and closes it
    themselves, which avoids the two problems #252 had to solve. If it's used after being closed, httpx raises a
    RuntimeError that AsyncRequest already turns into ResendError(error_type="HttpClientError").
  • It fits the selection order from #294. A client assigned to default_async_http_client is an explicit
    replacement.
  • Limits, proxies, HTTP/2 and httpx.MockTransport all come with the caller's client.

You can already get this by subclassing HTTPXClient, but then you're copying the files/json branching and the
error wrapping out of request(), and that copy drifts when the SDK changes. Here the request body moved into a
private _send helper that both paths share, and the httpx.RequestError → RuntimeError wrapping covers
both, same as before. I also added a short README note under "Custom async client".

For context, I run Ventute, which sends all its email through Resend. Its weekly digest sends one email per
eligible user, paging users 200 at a time and sending each page with asyncio.gather behind a semaphore of 8.
I came across this while looking at moving it from raw httpx calls to Emails.send_async.

Testing

Against a local HTTP/1.1 server that counts accepted TCP connections, 3 rounds of 8 parallel
Emails.send_async calls:

clientsendsTCP connections
HTTPXClient() (default)2424
HTTPXClient(client=client)248

Against api.resend.com each new connection is also a TLS handshake. I only counted connections locally and
didn't measure latency.

Repro (local server, fake key)
import asyncio, json, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import httpx
import resend

connections = 0


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def setup(self):  # once per TCP connection
        global connections
        connections += 1
        super().setup()

    def do_POST(self):
        self.rfile.read(int(self.headers["Content-Length"]))
        body = json.dumps({"id": "x"}).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


async def three_rounds_of_8():
    params = {"from": "a@example.com", "to": ["b@example.com"], "subject": "s", "html": "h"}
    for _ in range(3):
        await asyncio.gather(*(resend.Emails.send_async(params) for _ in range(8)))


async def main():
    global connections
    server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    resend.api_key, resend.api_url = "re_fake", f"http://127.0.0.1:{server.server_port}"

    await three_rounds_of_8()
    print("HTTPXClient():             ", connections, "connections for 24 sends")

    connections = 0
    async with httpx.AsyncClient() as client:
        resend.default_async_http_client = resend.HTTPXClient(client=client)
        await three_rounds_of_8()
    print("HTTPXClient(client=client):", connections, "connections for 24 sends")


asyncio.run(main())

tests/http_client_httpx_test.py uses httpx.MockTransport:

  • a provided client handles every request, isn't closed, and no new AsyncClient is created
  • multipart files and data go through a provided client
  • a transport error on a provided client still raises ResendError with HttpClientError
  • without client, a new AsyncClient with the configured timeout is still created per request

The first three fail on main, and the last one passes on both. The full suite passes on Python 3.14 and 3.8
(855 tests), and flake8 and tox -e mypy are clean.

Two things I left for you to decide. When client is passed, timeout doesn't apply (the caller's client has
its own), so I documented that rather than raising. And RequestsClient has the same per-call behaviour
(requests.request() opens a new Session each time), but I kept this PR async-only. Happy to change either.


Summary by cubic

Lets HTTPXClient use a caller-owned httpx.AsyncClient so async sends reuse connections instead of opening a new one per request.

Default behavior is unchanged: without client, HTTPXClient still opens and closes a client per request. With client, the SDK never creates, binds, or closes it, leaving the caller in control of the event loop and lifecycle. timeout is ignored when a client is provided, and the README documents that.

The request body moved into a shared private _send helper so both paths keep the same httpx.RequestError → RuntimeError wrapping.