feat(keycardai-oauth): pool httpx clients in the transports - #291
Merged
Larry-Osakwe merged 1 commit intoSep 8, 2026
Merged
Conversation
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>
Contributor
Author
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Larry-Osakwe
approved these changes
Sep 8, 2026
Larry-Osakwe
left a comment
Contributor
There was a problem hiding this comment.
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
deleted the
devin/eco-329-keycardai-oauth-transport-opens-a-fresh-httpx-client-on
branch
September 8, 2026 22:03
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes ECO-329.
HttpxTransportandHttpxAsyncTransportbuilt and closed an httpx client inside everyrequest_raw, so a long-lived keycardaiClient/AsyncClientpaid 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.timeoutas the client default) and the per-requesttimeoutstill flows throughrequest(timeout=...), falling back toconfig.timeoutwhen not given.Lifecycle:
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:
HttpxAsyncTransportrecordsasyncio.get_running_loop()at creation. A request on a different loop drops the old client (itsaclose()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.threading.Lockwith 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 inserver/credentials.pykeep per-request clients; noClientConfiginjection knob.Tests (
tests/keycardai/oauth/http/test_transports.py, patched at thehttpx.Client/httpx.AsyncClientseam, no network): identity reuse across two requests, config passthrough, timeout override vs fallback,close()/aclose()setis_closedand the next use creates a fresh client,NetworkErrorwrapping and operation string unchanged, 8-thread concurrent first use creates one client,gatheron one loop creates one client, twoasyncio.run()calls hold different clients, and foreign-loopaclose()drops without closing.test_client.pygainsTestClientClosefor the owned vs injected transport paths.Verification: oauth 521 passed; langchain, temporal, starlette, fastmcp green.
packages/mcphas 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