Skip to content

feat(keycardai-oauth): reuse Net::HTTP sessions in the default transport - #35

Merged
Larry-Osakwe merged 1 commit into
mainfrom
devin/eco-383-ruby-sdk-nethttpclient-opens-a-fresh-connection-on-every
Sep 8, 2026
Merged

feat(keycardai-oauth): reuse Net::HTTP sessions in the default transport#35
Larry-Osakwe merged 1 commit into
mainfrom
devin/eco-383-ruby-sdk-nethttpclient-opens-a-fresh-connection-on-every

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Summary

Closes ECO-383 (Ruby twin of python-sdk #291). NetHTTPClient#perform built Net::HTTP.new(host, port) per request and never kept the session, so every verify and token exchange through the mcp gem's long-lived NetHTTPClient paid a fresh TCP and TLS handshake. Sessions are now pooled per instance, per thread, per (host, port, scheme):

@registry = { Thread => { [host, port, scheme] => Net::HTTP } }   # behind @registry_mutex
perform(uri, request, timeout):
  http = session_for(uri)            # own thread's map, no lock once registered; recreate unless started?
  http.open_timeout = timeout || DEFAULT_OPEN_TIMEOUT
  http.read_timeout = timeout || DEFAULT_READ_TIMEOUT
  http.start unless http.started?
  http.request(request)              # rescue list unchanged, EOFError included

The mutex is taken only when a thread registers for the first time or on close, and each time it is taken the registry is swept of dead threads (their sessions are finished). No transparent retry: a keepalive the server dropped while idle raises NetworkError exactly as any other failure, matching the Python decision; the commit body carries the changelog note about first-attempt-after-idle failures and points at ECO-360 for consumer-side retryability.

NetHTTPClient#close finishes every registered session and clears the registry; the client stays usable and the next request opens fresh sessions. Documented as "call when no requests are in flight". The 16 module-level function defaults that build a throwaway NetHTTPClient keep that shape; their sessions now live until GC, stated in the class docs.

One deliberate deviation from the prompt, flagged: a request with no timeout: resets the cached session to Net::HTTP's construction defaults (60s open/read, captured once at load as DEFAULT_OPEN_TIMEOUT / DEFAULT_READ_TIMEOUT) rather than to literal nil. nil on Net::HTTP means no timeout at all, which would have silently turned a timeout-less request into an unbounded one; today's fresh-session behavior is the 60s default, and that is what is preserved. The spec pins the reset value.

No new dependencies; stdlib only. No mcp or a2a changes; no version or changelog hand-edits.

Specs (oauth/spec/keycardai/oauth/http_spec.rb, Net::HTTP.new stubbed with a fake session, no network): same-thread same-host reuse, distinct sessions per host/port/scheme, distinct sessions per thread, per-request timeout applied then reset, close finishes and the next request opens a fresh session, dead-thread sweep, and the unchanged NetworkError message on EOFError. rake spec (oauth 161, mcp 30, a2a 16) and rake rubocop clean on Ruby 3.4.

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

NetHTTPClient keeps Net::HTTP sessions open and reuses them per instance, per thread, per (host, port, scheme). The request path takes no lock; a small mutex guards only the registry of per-thread session maps, which is swept of dead threads whenever a new thread registers or close runs. Timeouts are now assigned per request on the cached session and fall back to Net::HTTP's defaults when a request carries none. NetHTTPClient#close finishes every session and clears the registry; the client stays usable afterwards. TLS selection and the NetworkError rescue list are unchanged.

Behavior note: a keepalive connection the server dropped while idle now surfaces as NetworkError on the first attempt after idle, where a fresh-connection design could not fail that way. There is no transparent retry; the Ruby retryability classification (ECO-360) is the consumer-side answer when it lands. Throwaway clients built by module-level function defaults keep their sessions until garbage collection.

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
Collaborator

Choose a reason for hiding this comment

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

The design landed as decided: per-instance, per-thread, per-(host, port, scheme) sessions with the mutex confined to registry registration and close, dead-thread sweep pinned by a spec, and no transparent retry. The timeout handling corrects my prompt: restoring nil would have disabled timeouts, so capturing and restoring Net::HTTP's own defaults (60s) is what actually reproduces the old fresh-session behavior. Session self-healing after a dead keepalive rides stdlib begin_transport reconnect, verified against net/http's error path.

@Larry-Osakwe
Larry-Osakwe merged commit f0c7dd4 into main Sep 8, 2026
12 checks passed
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