Conversation
…rsistent adapter (1.4.0) Every KB call opened a fresh TCP + TLS connection; the connect phase is where the 1s Net::OpenTimeout failures happen. KB::PersistentAdapter subclasses faraday-net_http_persistent 1.2 over net-http-persistent 4.0.8, with one Net::HTTP::Persistent per process shared by all model clients. Fixes over the stock adapter, each pinned by a real-socket spec: - timeouts go on the checked-out connection, not the shared object, so a read_timeout: override can't leak into another thread's call; - SSL is left to Net::HTTP::Persistent's defaults: the stock per-instance cert store would drop every TLS connection whenever another model client called; - error classes match faraday-net_http (connect timeout and refused connection are Faraday::ConnectionFailed wrapping the Net/Errno cause). Each attempt reports "new"/"reused" into the request.kb_client event (connections), tagged kb.connections on the Datadog span. idle_timeout defaults to 30s, under the Heroku router's measured ~55s idle close. keep_alive = false restores one connection per call. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fatbeard2
left a comment
There was a problem hiding this comment.
🤖 Agent review
The adapter holds up against the upstream sources. The overridden private methods are the hooks that faraday-net_http 1.0.1 and faraday-net_http_persistent 1.2.0 actually call. Error normalization produces the same classes as net_http. The requests.zero? check runs before net-http-persistent increments the counter, and the fiber-local handoff is sound. SSL defaults match the claim.
Two caveats the PR leaves out:
- connection_pool's automatic after-fork reload needs Ruby >= 3.1, because it hooks
Process._fork. - A preloading parent that called KB before forking shares TLS sockets with its children. The child's reload sends close_notify on them, and the parent recovers via EOF detection.
The main problems are in the specs. The leak spec doesn't discriminate, the stale-connection spec is masked by the retry, and the TLS fix has no spec. There are also two unstated behaviour changes: connect_timeout is frozen at first use, and env proxies are ignored. Nothing blocking.
| expect(accepted: server.accepted, write: events.last[:connections]).to eq(accepted: 1, write: ['reused']) | ||
| end | ||
|
|
||
| it "does not leak one call's read_timeout override into the next call on the same connection" do |
There was a problem hiding this comment.
🤖 [should-fix] Run one call after another, the stock adapter would also pass this: the leak is a race between threads. Interleave two threads, with thread A holding a read_timeout: 30 call open while thread B's stall fails at about 1s.
| it 'recovers when the server closed an idle connection without telling the client' do | ||
| client.request('close') | ||
|
|
||
| expect(result: client.request('a'), accepted: server.accepted).to eq(result: { 'ok' => true }, accepted: 2) |
There was a problem hiding this comment.
🤖 [should-fix] A retry masks this one. If the EOF check loses the race, the GET gets retried and still passes. Assert no retry, or use a POST, which is not retried after a maybe-sent failure.
| .to eq(accepted: 1, connections: [['new'], ['reused'], ['reused']]) | ||
| end | ||
|
|
||
| it 'shares the pool between clients of different entities on the same host' do |
There was a problem hiding this comment.
🤖 [should-fix] The TLS/cert-store fix has no spec, so restoring super in configure_ssl would pass the suite. At least pin that ssl_generation stays 0 across calls from two clients.
| def build_http | ||
| HTTP.new(name: 'kb-ruby').tap do |http| | ||
| http.idle_timeout = KB.config.request.idle_timeout | ||
| http.open_timeout = KB.config.request.connect_timeout |
There was a problem hiding this comment.
🤖 [should-fix] open_timeout and idle_timeout are read once, when the process-wide object is first built, so later config changes have no effect until reset!. Nothing covers timeouts on the default adapter any more. Apply open_timeout per attempt or document it, and add a spec.
| http = persistent_connection.http | ||
| http.read_timeout = options.read_timeout if options.read_timeout | ||
| http.write_timeout = options.write_timeout if options.write_timeout | ||
| self.connection = persistent_connection.requests.zero? ? 'new' : 'reused' |
There was a problem hiding this comment.
🤖 [should-fix] When Net::HTTP silently reconnects inside request, after the router's FIN or once keep_alive_timeout elapses, the attempt is still labelled reused. A connect failure there shows as reused plus OpenTimeout. Document what reused means, or detect the reconnect.
|
|
||
| private | ||
|
|
||
| def net_http_connection(_env) |
There was a problem hiding this comment.
🤖 [nit] Overriding net_http_connection drops HTTP(S)_PROXY/NO_PROXY handling. This is a silent difference from the net_http contract, so document it.
|
|
||
| class << self | ||
| def http | ||
| MUTEX.synchronize { @http ||= build_http } |
There was a problem hiding this comment.
🤖 [nit] Every call takes the MUTEX just to read a memoized ivar. Use @http || MUTEX.synchronize { @http ||= build_http }.
…apter - Specs now discriminate: the shared Net::HTTP::Persistent never carries a call's read_timeout; two interleaved threads keep their own budgets; a server-closed connection is detected before a POST is sent (no retry to mask it); ssl_generation stays 0 across model clients; the pool connects with connect_timeout. Restoring the stock configure_request/configure_ssl makes three of them fail (including pool sharing, even over plain http). - A failure that never reached KB is always reported as a "new" connection; document what "reused" means (Net::HTTP may still reconnect silently). - open_timeout also applied per attempt, for Net::HTTP's own reconnects. - Document: connect/idle timeouts are read once per process (reset! after runtime changes), HTTP(S)_PROXY is not honoured, fork behaviour. - Don't take the mutex on every call once the pool exists. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
🤖 Agent review addressed in 91fa83e (plus a merge of #108's review fixes). The specs now discriminate. The shared object never carries a call's read_timeout, two interleaved threads keep their own budgets, and a server-closed connection is detected before a POST is sent. |
…duction; document the same-instant race On CI (Ruby 3.4) the client posted microseconds after the server closed, before the FIN arrived, so Net::HTTP's EOF check could not see it. A router idle close is long delivered by the next call; a close racing the write is not detectable and is now documented (GET retried, POST fails as ConnectionFailed/EOFError). 20/20 runs green on ruby:3.4 locally. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Why?
Every KB call opens a fresh TCP + TLS connection to the Heroku router. The connect phase is where our KB failures now show up: Funnel's 1s
Net::OpenTimeouts since the 1.0.0 rollout. Reusing connections removes that phase from most calls. Noma already calls KB over pooled keep-alive connections (OkHttp defaults), and its baseline KB failure rate was about 13 times lower than Funnel's or connected_health's (0.010% vs 0.13%). The comparison is suggestive, not proof: Noma also differs in timeouts and traffic.Changes
KB::PersistentAdaptersubclasses faraday-net_http_persistent 1.2's adapter, over net-http-persistent 4.0.8. It is on by default.KB.config.request.keep_alive = falserestores one connection per call, so there's a rollback without a gem pin.One pool per process. Each model class memoizes its own
KB::Client, about eight of them, all pointing at the same host. They now share a singleNet::HTTP::Persistent, whose pool is keyed by host and port, so every model reuses the same sockets.The stock adapter isn't safe to drop in. Each of these fixes has a real-socket spec:
Net::HTTP::Persistent. Aread_timeout: 30override, as the birthdays job uses, would then apply to whatever another thread sends next. Timeouts now go on the checked-out connection.VERIFY_PEERwith the system CA store. KB clients pass no SSL options.Faraday::TimeoutErrorfor a connect timeout and a rawNet::HTTP::Persistent::Errorfor a refused connection or a down host. Both now becomeFaraday::ConnectionFailedwrapping theNet::OpenTimeout/Errnocause, exactly as with faraday-net_http.connected_health's breaker,KB::Errorwrapping, the retry policy and thekb.client.errorswidget see the same classes as today.Idle timeout.
KB.config.request.idle_timeoutdefaults to 30s. I measured the Heroku router against KB staging, on bothkb-staging.barkibu.comand the herokuapp.com host: it closes an idle client connection after about 55s. If the router closes a connection first, Net::HTTP detects the EOF before reuse and reconnects; a spec covers this with a POST, which would not be retried. A close that races the next write in the same instant is not detectable. A GET then gets retried, and a POST fails asConnectionFailed/EOFError. CI caught that race in the spec before the spec was given a realistic gap; see the README.Deliberate differences from faraday-net_http:
connect_timeoutandidle_timeoutare read once per process, when the shared pool is built, so callKB::PersistentAdapter.reset!after changing them at runtime.HTTP(S)_PROXYis not honoured.Instrumentation. Each attempt reports
"new"or"reused"into therequest.kb_clientevent asconnections. "reused" means the pool handed out an already-open connection, and a failure that never reached KB is always "new". The field is e.g.["reused", "new"]for a reused connection that failed followed by a retry on a fresh one. The Datadog span tag iskb.connections. It gives a reuse ratio in production, and it shows whether failures come from fresh connections or from stale reused ones.Other notes:
ConnectionPool. On Ruby ≥ 3.1, connection_pool ≥ 2.4 drops its connections after fork, and consumers are on 2.4.1 (Funnel), 3.0.2 (Global Admin) and 2.5.5 (connected_health). net-http-persistent 4.0.8 acceptsconnection_poolfrom 2.2.4 up to < 4, so all three resolve.http.requestspans, because net-http-persistent callsNet::HTTP#request. On a reused connection, the connect phase simply doesn't happen.connectionstag will show it if it happens: an error withreused.Released as 1.4.0. New runtime dependencies:
faraday-net_http_persistent ~> 1.2, which every consumer already bundles through Faraday 1.10, andnet-http-persistent ~> 4.0.Checklist
lib/kb/version.rbupdatedHow to test
Automated tests cover it. From the repo root:
Result: 225 examples, 0 failures; rubocop clean on
libandspec. CI is green, and the keep-alive spec passed 20 of 20 runs locally on ruby:3.4.spec/persistent_adapter_spec.rbruns against a small keep-alive HTTP server on a real socket (spec/support/keep_alive_server.rb), with WebMock disabled because its Net::HTTP patch opens a new socket per real request. It checks:new, reused, reused;read_timeout: 30call never writes its timeout onto the shared object, and while it is in flight, another thread's stalled call gives up at the 1s global budget;ssl_generationstays 0 across model clients;connect_timeout;idle_timeout;Faraday::ConnectionFailedwrappingErrno::ECONNREFUSED, labellednew, new.Mutation check: restoring the stock
configure_request/configure_sslmakes three of these fail, including pool sharing, even over plain http.Before merge, it's worth a staging soak of one consumer. Check that
kb.connections:reuseddominates onoperation_name:kb.client.request, and that Puma and Sidekiq boot cleanly with the shared pool.Upgrade Deployment Instructions
barkibu-kb ~> 1.4with a conservative lock update, which addsnet-http-persistent4.0.8.KB.config.request.keep_alive = falsein the app's KB initializer.🤖 Generated with Claude Code