fix: reuse HTTP connections in the default clients

Closed
MA
2mo ago
+276-27

What

Both default HTTP clients now hold a single underlying client, so connections are
reused across requests instead of a pool being built and discarded per call.

Why

RequestsClient.request() called requests.request(), which creates a new
Session per call. HTTPXClient.request() opened async with httpx.AsyncClient(...) per call. Every API call therefore paid a fresh TCP
connect and TLS handshake. It shows up most in loops (batch sends, contact syncs,
paginated reads) and in async code, where pooling is a main reason to be async.

Ten sequential Emails.send calls, against a local server counting accepted TCP
connections:

Clientbeforeafter
RequestsClient10 connections1
HTTPXClient10 connections1

Notes for review

HTTPXClient creates its AsyncClient lazily rather than in __init__.
resend/__init__.py instantiates the class at import time, when no event loop is
running, and an AsyncClient pool binds to the running loop. If the loop changes,
for example a second asyncio.run(...), a client is created for the new loop; the
previous one is dropped rather than closed, since its connections died with its
loop. close() and aclose() are available for explicit cleanup.

requests.Session is not thread safe in general, because concurrent mutation of
session attributes is not safe. This client never mutates session state after
construction: method, headers, body and timeout are all passed per call. A test
sends 40 requests across 8 threads through one shared client and asserts each
caller gets its own response.

A shared Session also persists cookies across requests where a per-call session
did not. The API does not rely on cookies, but the difference is real.

tests/request_test.py patched requests.request at module level, which a shared
session bypasses. Those seven decorators now patch requests.Session.request. No
assertion changed.

Testing

  • tox -e lint
  • tox -e mypy
  • tox -e py (568 passed)
  • New tests/http_client_connection_reuse_test.py covers connection reuse for both
    clients, close() / aclose(), concurrent use of the shared session, and the
    changed-loop path.
  • Red/green check: reverting the two client files to main while keeping the new
    tests makes six of the seven fail. The seventh covers the changed-loop path,
    which the previous per-request client handled by construction; it is included as
    a regression guard for the lazy client added here.

Summary by cubic

Reuse HTTP connections in the default HTTP clients to avoid per-request TCP/TLS handshakes. Previously each call created a new requests.Session/httpx.AsyncClient; now a single shared client reuses connections and cuts latency, especially in loops and async flows.

  • RequestsClient: holds one requests.Session; supports context manager; call close() when disposing a long‑lived client. Cookies now persist across requests.
  • HTTPXClient: holds one httpx.AsyncClient created on first use and bound to the current event loop; a new client is created if the loop changes (e.g., multiple asyncio.run calls). Call aclose() when disposing.
  • Concurrency: the shared session/client does not mutate per-request state; tests cover parallel calls and response isolation.
  • Tests: adds connection‑reuse coverage; existing tests now patch requests.Session.request instead of requests.request (assertions unchanged).
  • Result: 10 sequential sends open 1 connection instead of 10 for both sync and async clients.