Do not close HTTP/2 connections while a graceful GOAWAY is being drained - #245
Merged
samuel-williams-shopify merged 3 commits intoAug 31, 2026
Merged
Conversation
1 task
Contributor
Author
|
if you need a proof that it works i can temporary add gem "protocol-http2", git: "https://github.com/senid231/protocol-http2.git", branch: "graceful-goaway-drain" |
When a server sends a graceful GOAWAY it will not accept new streams, but it is
still processing the streams at or below last_stream_id and will send their
responses (RFC 9113 6.8). The client only honoured the first half: the
connection was closed at once, the background reader stopped reading, and every
stream still waiting was failed with "Connection closed with N active stream(s)!"
-- exactly the streams the server accepted and was about to answer. For a POST
that is unrecoverable, because a non-idempotent request is not retried.
nginx sends a graceful GOAWAY on the keepalive_requests-th request of every
HTTP/2 connection, on keepalive_time, and on every reload, so a client with
requests in flight loses a burst of them each time. Reproduced against nginx
1.22.1: 20 of 100 requests failed while the backend completed all 100.
With the companion change in protocol-http2, such a connection is now retired
from the pool immediately but stays open until the accepted streams complete:
- viable?/reusable? are false once a GOAWAY has been received, so the pool
retires the connection instead of handing it to another request.
- Client#call refuses a request which reaches a connection that is going away,
so it is retried on a new connection, non-idempotent ones included. This is
checked before closed?, because a GOAWAY which finds no streams to drain
closes the connection in the same step.
- close(nil) defers while the connection is draining, so the pool retiring it
does not fail the streams we are waiting for. An explicit error, or the
absence of a background reader, still closes immediately.
- close_if_drained! stops the background reader when the drain finishes in
another task: the last stream can complete on the sending side, leaving the
reader parked in a blocking read, leaking the task and the socket.
- close no longer stops the reader task when it is the reader task, which
raised Async::Cancel in the middle of close and left the socket open.
Assisted-By: devx/618580b0-d55f-4c2e-95b6-87e648f60543
Assisted-By: devx/618580b0-d55f-4c2e-95b6-87e648f60543
samuel-williams-shopify
force-pushed
the
graceful-goaway-drain
branch
from
August 31, 2026 03:22
0785bc3 to
7519a41
Compare
samuel-williams-shopify
merged commit Aug 31, 2026
1dfa85b
into
socketry:main
17 of 20 checks passed
Contributor
Author
|
@samuel-williams-shopify thx for quick response i tested it with nginx - fix works fine. when you plan to release it? |
Member
|
Probably in the next 24 hours I'm trying to fix one more issue but if it looks problematic, I'll give up and just release this fix. |
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
When a server sends a graceful
GOAWAY(error codeNO_ERROR), it is telling us two things:it will not accept new streams, and it is still processing the streams at or below
last_stream_idand will send their responses (RFC 9113 §6.8).Today the client only honours the first half.
GOAWAYcloses the connection immediately, thebackground reader stops reading, and every stream still waiting for a response is failed with:
Those streams are exactly the ones the server accepted and is about to answer. For a
POSTthis is unrecoverable:
Protocol::HTTP::Request#retry!isfalsefor non-idempotent methods,so the request fails locally even though the server processed it and its side effects already
happened.
This is not an edge case. nginx sends a graceful
GOAWAYon thekeepalive_requests-threquest of every HTTP/2 connection (default
1000), onkeepalive_time(default1h), and onevery
nginx -s reload. Any client that keeps a persistent HTTP/2 connection with requests inflight loses a burst of them each time.
Together with socketry/protocol-http2#31 this makes the client drain such a connection: it is
retired from the pool immediately, but stays open until the streams the server accepted have
completed.
Reproduction
Reproduced against real nginx 1.22.1 (and 1.20.2) with
async-http 0.101.0+protocol-http2 0.26.2.keepalive_requestsis lowered to40so the event happens every 40requests instead of every 1000; nothing else is unusual about the setup.
nginx.confbackend.rb— stands in for the application server: sleeps 300ms, answers 201, logs what it received and what it finishedclient.rb— one persistent HTTP/2 connection, 4 bursts of 25 concurrent POSTsBefore
The failed sequence numbers are exactly the streams accepted before the 40th request on each
connection, and
backend finished: 100shows every one of them was fully processed. nginx logsthem as
499(client closed the connection) and, atinfolevel,client prematurely closed connection while processing HTTP/2 connection.After (this PR + socketry/protocol-http2#31)
What actually happens
keepalive_requestswhile opening streamN. It setsh2c->goaway, queuesGOAWAY(last_stream_id = N, NO_ERROR), and still creates and processes streamN.Later
HEADERSframes on that connection are skipped withoutRST_STREAM, per RFC 9113§6.8. It only starts its lingering close once its in-flight stream count drops to zero.
That is a textbook graceful shutdown.
Protocol::HTTP2::Connection#receive_goawaycallsclose!, soclosed?becomes true rightaway.
while !self.closed?, so it stops reading, and itsensurecalls
Connection#close(nil). With streams still registered and no error given, that raisesEOFError.new("Connection closed with #{@streams.size} active stream(s)!")into all of themand closes the socket.
connection,
reusable?is false because the connection is closed, so the pool retires it —and
retirecallsclose, killing whatever is still in flight.EOFErroris not retried forPOST/PATCH, so the request surfaces as a failure.The refusal half already works correctly: streams above
last_stream_idare closed withProtocol::HTTP::RefusedErrorand resent on a new connection. It is the accepted streams thatare lost.
The fix
Server side (
protocol-http2, socketry/protocol-http2#31): a gracefulGOAWAYno longercloses the connection while it still has accepted streams; the connection closes when the last
one completes. That PR adds
Connection#goaway_received?; the remaining lifecycle is expressedby the concrete
closed?state and registered streams.This PR is the client half:
Connection#reusable?/#viable?are false once aGOAWAYhas been received, so thepool retires the connection and never hands it to another request while it drains.
Client#callraisesProtocol::HTTP::RefusedErrorif a request does reach a connectionwhich is going away (it can be acquired from the pool just before the
GOAWAYarrives).Nothing has been written at that point, so the request is safely retried on a new connection,
including non-idempotent ones. This is checked before
closed?, because aGOAWAYwhichfinds no streams to drain closes the connection in the same step, and the pre-existing
Protocol::HTTP2::Errorraised for a closed connection is not retried.but retains it while existing users remain. The final release retires and synchronously closes
the connection.
Connection#closetherefore keeps its normal synchronous contract; gracefulretention belongs to the pool rather than to a stateful close operation.
Connection#close_if_drained!stops the background reader when the drain finishes inanother task. The last drained stream can complete on the sending side — the response
arrived first and the request body was still being written — and the reader is then parked in
a blocking read where it would never notice the connection is closed, leaking the task and
the socket.
Connection#closeno longer stops the reader task when it is the reader task. Thatpath is now common — the last drained stream completes inside the reader, releases the
connection, and the pool retires it — and
Async::Task#stopon the current task raisesAsync::Cancelin the middle ofclose, sosupernever ran and the socket was left open.The reader unwinds on its own instead, since
closed?is true by then.Tests
test/async/http/protocol/http2/graceful_goaway.rb:GOAWAY(last_stream_id = first), and only then answers the accepted one: all three requestssucceed, the accepted one on the original connection and the other two retried on a new one;
GOAWAYis not reusable or viable and refuses new requests withRefusedError; a pool-level regression verifies that it is removed from availability afterone release, retained while another user remains, and closed by the final release;
GOAWAYleft nothing to drain still refuses new requests withRefusedErrorrather than failing them;Notes
keepalive_requests/keepalive_time/ reload, and Envoy or gRPC servers, which send anadvisory
GOAWAY(2^31-1)first and the reallast_stream_idlater.Types of Changes
Bug fix
Contribution