Repository navigation
[CHA-5724] fix: retry requests on dropped connections - #223
Closed
nijeesh-stream wants to merge 3 commits into
Closed
nijeesh-stream wants to merge 3 commits into
nijeesh-stream wants to merge 3 commits into
Conversation
The load balancer front end in front of the API occasionally drops connections. A pooled keep-alive connection closed without a TLS close_notify makes the next request fail with "SSL_read: unexpected eof while reading", and faraday-net_http_persistent disables Net::HTTP's own retry (max_retries = 0). Add StreamChat::RetryMiddleware to the client's Faraday connection. It retries up to 2 times with jittered backoff (~100ms, ~200ms): - any method when the failure happened in Net::HTTP's connect or begin_transport phase, so the request was never sent; - GET, HEAD and OPTIONS also when the connection dropped mid-flight. HTTP error responses and timeouts are never retried, and IO bodies are retried only when they can be rewound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nijeesh-stream
requested review from
JimmyPettersson85,
shaljam and
vishalnarkhede
as code owners
October 20, 2026 12:44
On Linux CI a dropped connection surfaces as Faraday::ConnectionFailed (EOFError) rather than Faraday::SSLError, and Net::HTTP may reconnect a stale pooled connection by itself. Accept either transport error, assert only the outcome for stale connections, and check the unrewindable-body rule with a refused connection, which fails the same way everywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
Closing without merging: CHA-5724 has been closed (no further customer reports). Keeping this PR and branch as a reference implementation in case the issue comes back. Context for whoever picks it up:
Findings are documented on https://linear.app/stream/issue/CHA-5724 |
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.
TLDR
OpenSSL::SSL::SSLSocket#sysread_nonblock: SSL_read: unexpected eof while reading(Shape-Construction on 3.26.2).faraday-net_http_persistentturns it off (max_retries = 0).StreamChat::RetryMiddleware) on the client connection retries up to 2 times (~100ms, then ~200ms, with jitter). It retries a call only when that is safe: any method if the failure provably happened before the request was sent, and GET/HEAD/OPTIONS also if the connection dropped mid-flight. It has no per-endpoint logic and adds no new gem.Ticket
CLA
Description of the pull request
Adds
lib/stream-chat/retry_middleware.rband registers it inClient#initializeafterfaraday.request :multipartand before the adapter. Because it is middleware, it covers every request on@conn:make_http_requestand the upload path (send_file, which calls@conn.postdirectly).Design rules
Faraday::SSLError/Faraday::ConnectionFailedNet::HTTP#begin_transportframe (stale pooled connection) in the cause chainFaraday::SSLError/Faraday::ConnectionFailedNet::HTTP#connectframe (refused, DNS, TCP reset or TLS handshake failure) in the cause chainFaraday::SSLError/Faraday::ConnectionFailedFaraday::TimeoutError(read and open timeouts)rewindCompositeReadIO) is rewound. If it can't be rewound, the original error is raised.Evidence
Facts this design relies on (local reproductions with the SDK's exact stack: Faraday 2.12+, faraday-net_http_persistent 2.3, net-http-persistent 4.0,
idle_timeout = 59):faraday-net_http_persistentsetshttp.max_retries = 0 if http.respond_to?(:max_retries=)inconfigure_request(faraday-net_http_persistent 2.3.1,lib/faraday/adapter/net_http_persistent.rb). Net::HTTP's idempotent retry is therefore off.OpenSSL::SSL::SSLError/EOFError/Errno::ECONNRESETnever reaches the caller. The adapter wraps it asFaraday::SSLError(SSL errors) orFaraday::ConnectionFailed(everything else), with the original in#cause.Faraday::SSLError(causeSSL_read: unexpected eof while reading). It is raised fromNet::HTTP#begin_transport(the@socket.eof?liveness check), and the server receives 0 requests.transport_request, nobegin_transport), and the server did receive the request.Net::HTTP#connectframe in its cause chain:Faraday::ConnectionFailed←Net::HTTP::Persistent::Error←Errno::ECONNREFUSEDFaraday::ConnectionFailed←Socket::ResolutionErrorFaraday::SSLError←OpenSSL::SSL::SSLError (SSL_connect ...)Faraday::ConnectionFailed←Errno::ECONNRESETFaraday::TimeoutError←Net::OpenTimeout. This is aTimeoutError, so it is not retried.`connect'(Ruby ≤ 3.3) and'Net::HTTP#connect'(Ruby ≥ 3.4). The new specs pass on Ruby 3.2.0 (old style) and 3.4.2 (new style).How a drop surfaces depends on the Ruby/OpenSSL build
The first CI run showed that the same drop surfaces differently on different builds:
Faraday::SSLError(SSL_read: unexpected eof while reading)Faraday::ConnectionFailedwrappingEOFError: end of file reached(fromread_status_line)Faraday::SSLErrorfrombegin_transport; the middleware retries iteof?check sees a clean EOF and reconnects by itselfThe middleware handles both. It catches both
Faraday::SSLErrorandFaraday::ConnectionFailed, decides by the backtrace frame rather than the error class or message, and re-raises whichever error the adapter produced. The specs were updated to match (commitc507368):RetryMiddlewareshows 1 attempt (and 0 reads) for a body that can't be rewound, and 3 attempts for a rewindableStringIO.Real-socket specs (
spec/retry_middleware_spec.rb)The specs use a local HTTPS server (
spec/support/tls_test_server.rb) with a self-signed certificate. TLS verification is turned off only in the test client. They go through the realStreamChat::Clientrequest path and need no network access or API keys. Output on the branch (Ruby 3.4.2, macOS):Client#initializemade the "retries…", stale-connection and attempt-cap examples fail. That was measured on the first commit, so the specs exercise the middleware there.Other checks:
bundle exec rake rubocopreports 29 files inspected, no offenses.bundle exec srb tcreports no errors.errors_spec,webhook_compression_spec,retry_middleware_spec) all pass.Risks
connect,begin_transport). If a future Net::HTTP renames or reshapes these, the effect is fail-safe: non-GET requests stop being retried. Nothing gets retried that shouldn't. CI's Ruby 3.0/3.1/4.0 matrix runs the new specs. Locally only 3.2.0 and 3.4.2 were run.Faraday::TimeoutError, and the rule is to never retry timeouts. A request that never connected within the timeout therefore still fails on the first attempt.🤖 Generated with Claude Code