Skip to content

feat(keycardai-oauth): pool httpx clients in the transports - #291

Merged
Larry-Osakwe merged 1 commit into
mainfrom
devin/eco-329-keycardai-oauth-transport-opens-a-fresh-httpx-client-on
Sep 8, 2026
Merged

feat(keycardai-oauth): pool httpx clients in the transports#291
Larry-Osakwe merged 1 commit into
mainfrom
devin/eco-329-keycardai-oauth-transport-opens-a-fresh-httpx-client-on

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Summary

Closes ECO-329. HttpxTransport and HttpxAsyncTransport built and closed an httpx client inside every request_raw, so a long-lived keycardai Client/AsyncClient paid a fresh TCP and TLS handshake per call. Each transport now creates its client lazily on first use and reuses it; the client is configured exactly as before (verify_ssl, User-Agent, config.timeout as the client default) and the per-request timeout still flows through request(timeout=...), falling back to config.timeout when not given.

Lifecycle:

HttpxTransport.close()            # closes the pooled httpx.Client; next use creates a fresh one
HttpxAsyncTransport.aclose()      # same for httpx.AsyncClient
Client.close() / Client.__exit__            -> transport.close()  (only if the client created the transport)
AsyncClient.aclose() / AsyncClient.__aexit__ -> transport.aclose() (was a documented noop)

An injected transport is never closed by the client. An unclosed client is fine: httpx releases connections on garbage collection, stated in the docstrings rather than adding finalizers.

The two traps from the ticket:

  1. Event-loop lifetime: HttpxAsyncTransport records asyncio.get_running_loop() at creation. A request on a different loop drops the old client (its aclose() cannot be awaited from a foreign loop, so it is left to GC, commented at the seam) and creates a fresh one. aclose() from a foreign loop likewise drops without awaiting.
  2. Lazy-creation races: sync creation is behind a threading.Lock with a double check; async creation has no await between the check and the assignment, so one loop cannot double-create.

Observable behavior change (also in the commit body, which cz renders into the changelog): a pooled connection can die between requests, so a first attempt after idle can surface a transport error where a fresh-client design could not. Those already classify retryable=True.

Out of scope, deliberately: the module-level PKCE helper (pkce/_issuer.py) and the WIF token sources in server/credentials.py keep per-request clients; no ClientConfig injection knob.

Tests (tests/keycardai/oauth/http/test_transports.py, patched at the httpx.Client/httpx.AsyncClient seam, no network): identity reuse across two requests, config passthrough, timeout override vs fallback, close()/aclose() set is_closed and the next use creates a fresh client, NetworkError wrapping and operation string unchanged, 8-thread concurrent first use creates one client, gather on one loop creates one client, two asyncio.run() calls hold different clients, and foreign-loop aclose() drops without closing. test_client.py gains TestClientClose for the owned vs injected transport paths.

Verification: oauth 521 passed; langchain, temporal, starlette, fastmcp green. packages/mcp has two failures that are identical on main (auto_register_client assertions, unrelated).

Link to Devin session: https://app.devin.ai/sessions/bbc062cf32b548ef9c4b8b444d2ec67f
Open in Devin Desktop: https://app.devin.ai/desktop/session/bbc062cf32b548ef9c4b8b444d2ec67f?variant=devin
Requested by: @Larry-Osakwe

HttpxTransport and HttpxAsyncTransport create their httpx client lazily on first use and reuse it, configured from ClientConfig as before (verify_ssl, User-Agent, config.timeout as the client default) with the per-request timeout still passed through request(timeout=...). Transports gain close() and aclose(); Client and AsyncClient gain public close() and aclose() that close a transport they own, and the context-manager exits call them. The async transport remembers the event loop its client was created on and builds a fresh client when a request arrives on a different loop; sync creation is guarded by a lock and async creation has no await between check and assign.

One observable behavior change: a pooled connection can die between requests, so a first attempt after idle can surface a transport error where a fresh-client design could not. Those errors already classify retryable=True.

The module-level PKCE helper and the WIF token sources in server/credentials.py keep their per-request clients.

Co-Authored-By: Larry Osakwe <larry@keycard.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@Larry-Osakwe Larry-Osakwe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything the prompt pinned is pinned, plus the ownership rule it should have asked for: injected transports are never closed, only client-created ones, with tests both ways. The loop-change and foreign-loop-aclose semantics are exactly right (drop without awaiting, pinned by is_closed staying False), and the timeout handling actually improved: timeout=0 now honored instead of falling through the old or-fallback.

@Larry-Osakwe
Larry-Osakwe merged commit 5260494 into main Sep 8, 2026
14 checks passed
@Larry-Osakwe
Larry-Osakwe deleted the devin/eco-329-keycardai-oauth-transport-opens-a-fresh-httpx-client-on branch September 8, 2026 22:03
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