Skip to content

fix: reuse HTTP connections in the default clients - #252

Open
mayankbohradev wants to merge 1 commit into
resend:mainfrom
mayankbohradev:fix/http-client-connection-reuse
Open

fix: reuse HTTP connections in the default clients#252
mayankbohradev wants to merge 1 commit into
resend:mainfrom
mayankbohradev:fix/http-client-connection-reuse

Conversation

@mayankbohradev

@mayankbohradev mayankbohradev commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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:

Client before after
RequestsClient 10 connections 1
HTTPXClient 10 connections 1

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.

Written for commit 2962256. Summary will update on new commits.

Review in cubic

RequestsClient called requests.request() and HTTPXClient opened an
httpx.AsyncClient inside a context manager on every call. Both build and
discard a connection pool per request, so each API call paid a new TCP
and TLS handshake.

RequestsClient now holds a requests.Session. HTTPXClient holds an
httpx.AsyncClient created lazily on first use, because the class is
instantiated at import time when no event loop is running and the pool
binds to the running loop. A new client is created if the loop changes,
so repeated asyncio.run() calls keep working.

Ten sequential sends against a local server open one connection instead
of ten, for both the sync and async clients.

Tests in request_test.py patched requests.request at module level. They
now patch requests.Session.request; the assertions are unchanged.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.85714% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 96.65%. Comparing base (7f1acd0) to head (2962256).
⚠️ Report is 202 commits behind head on main.

Files with missing lines Patch % Lines
resend/http_client_httpx.py 94.73% 1 Missing ⚠️
resend/http_client_requests.py 88.88% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main     #252       +/-   ##
===========================================
+ Coverage   82.66%   96.65%   +13.98%     
===========================================
  Files           4       70       +66     
  Lines          75     3736     +3661     
===========================================
+ Hits           62     3611     +3549     
- Misses         13      125      +112     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants