Conversation
A persistent client keeps its socket between requests, and the server or a proxy may close it while it sits idle. The kernel still accepts the next request into the send buffer of the half-closed socket, so the failure only surfaces when the response is read: "couldn't read response headers" on a plain socket, ECONNRESET after a reset, and OpenSSL::SSL::SSLError over TLS on OpenSSL 3. In that case the server never saw the request (httprb#420, httprb#459). RFC 9110 Section 9.2.2 lets a client repeat an idempotent request automatically, and Net::HTTP, Go's net/http and urllib3 do so by default. When a reused connection fails before any response byte arrives and the request is replayable, close the connection and send the request once more on a new one. The resend starts from a fresh connection, so it happens at most once. Request#replayable? is true when the method is idempotent or an Idempotency-Key or X-Idempotency-Key header is present, and the body is nil or a String; IO and Enumerable bodies may already be consumed. Clients with a retriable policy keep its semantics. Features see one on_request and one wrap_response per call, and on_error only for the final failure. The client stays dirty until the resent request completes, so a resend interrupted by Thread#kill or Timeout is not reused. To stay within the Metrics limits, transmit and resend? live in Client::ConnectionReuse, check_premature_eof moves into Connection::Internals, the idempotency rules move into Request::Idempotency, and notify_features is inlined.
This was referenced Oct 3, 2026
This branch has not been deployed
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.
A persistent client can lose a request to a close it couldn't have seen: the server or a proxy closes the connection just as the next request goes out. The write succeeds into the kernel buffer, and the read fails with
couldn't read response headers, ECONNRESET, orSSLErrorover TLS on OpenSSL 3. A liveness check before reuse (#859) can't catch it, because the socket was fine when the check ran.The client can't tell whether the server processed the request. zanker made that point in #420: "It's possible to have a request that succeeds on the server, but fails on the client when trying to read the headers." So this only resends requests that are safe to repeat. RFC 9110 §9.2.2 allows that for idempotent methods, and Net::HTTP, urllib3 and Go all do it by default.
A request is resent once, on a new connection, when all of these hold:
Idempotency-KeyorX-Idempotency-Keyheader.retriablepolicy. Those keep their own semantics.The resend isn't resent again, per RFC 9110: "A client SHOULD NOT automatically retry a failed automatic retry." Features see one
on_requestand onewrap_responseper call, andon_erroronly for the final failure. The client stays dirty until the resent request completes, so a resend interrupted byThread#killorTimeoutisn't reused.Counted by the server, 30 runs per case, the same outcome on MRI 3.4.8 and JRuby 10.1.2.0:
ResponseHeaderError30/30Idempotency-Key, close crosses itResponseHeaderError30/30ResponseHeaderError30/30ResponseHeaderError30/30ResponseHeaderError30/30ResponseHeaderError30/30ResponseHeaderError30/30.retriabledoesn't cover this today. It's opt-in, and it retries by exception class without looking at the method, so it resends a POST the server already processed, up to 5 times by default. I can open an issue for that separately with repros.I ran into this through a commercial HTTPS proxy that cuts a CONNECT tunnel 10.0 s after the last byte it sent the client. Of 54 requests reused at 9.6 to 10.1 s idle, 38 failed with 0 response bytes, though the tunnel was still open when each request went out.
Code:
Client::ConnectionReuse#transmitand#resend?hold the decision,Connection#response_started?tracks the first response byte, andRequest::Idempotency#replayable?the method and body rules. They're separate modules to stay within the Metrics limits;check_premature_eofmoved intoConnection::Internalsfor the same reason.test/support/scripted_server.rbis a small TCP/TLS server that records each request's bytes and connection, so the tests count deliveries instead of inferring them..retriable), at most once, and the feature callbacks.test_connection_reuse_enabled_socket_issue_raises_for_non_idempotent_requestpins down the unkeyed POST.Validation:
HTTP::Client::ConnectionReuse95/95 killed andHTTP::Request::Idempotency63/63..mutant.ymlignoresHTTP::Client*, so I passed them as subjects on the command line. Running it with more than one job needs the test CA fix in Keep the test CA in memory #857.Open questions:
replayable?andresponse_started?are public. Happy to mark them@api private.Idempotency-Keyis still an IETF draft (-07). Go honors it; I can drop it and keep methods only.max_retries = 0. Should there be an option here too?Written with AI models and checked by a human: Claude Opus 5.5 did the tracing, the patch and the specs; Claude Sonnet 5 ran sub-agent research and test runs; GPT-6 Astra reviewed as advisor; GPT-6.1 Sol published the drafts; Claude Fable 5.1 reviewed upstream history and forks. @ilyazub directed each step and pushed.