Skip to content

Commit 5b518b5

Browse files
authored
Better handling of GOAWAY. (#28)
1 parent 17f7b3b commit 5b518b5

4 files changed

Lines changed: 87 additions & 1 deletion

File tree

lib/protocol/http2/connection.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,15 @@ def receive_goaway(frame)
237237

238238
self.close!
239239

240+
# Streams above the last stream ID were not processed by the remote peer and are safe to retry (RFC 9113 §6.8).
241+
error = ::Protocol::HTTP::RequestRefusedError.new("GOAWAY: request not processed.")
242+
243+
@streams.each_value do |stream|
244+
if stream.id > @remote_stream_id
245+
stream.close(error)
246+
end
247+
end
248+
240249
if error_code != 0
241250
# Shut down immediately.
242251
raise GoawayError.new(message, error_code)

protocol-http2.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@ Gem::Specification.new do |spec|
2525
spec.required_ruby_version = ">= 3.3"
2626

2727
spec.add_dependency "protocol-hpack", "~> 1.4"
28-
spec.add_dependency "protocol-http", "~> 0.47"
28+
spec.add_dependency "protocol-http", "~> 0.61"
2929
end

releases.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- On GOAWAY, proactively close unprocessed streams (ID above `last_stream_id`) with `Protocol::HTTP::RequestRefusedError`, enabling safe retry of non-idempotent requests.
6+
37
## v0.24.0
48

59
- When closing a connection with active streams, if an error is not provided, it will default to `EOFError` so that streams propagate the closure correctly.

test/protocol/http2/connection.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,79 @@ def before
391391
expect(stream.state).to be == :closed
392392
end
393393

394+
let(:stream_class) do
395+
Class.new(Protocol::HTTP2::Stream) do
396+
attr_reader :error
397+
398+
def closed(error)
399+
@error = error
400+
super
401+
end
402+
end
403+
end
404+
405+
it "closes unprocessed streams with RequestRefusedError on graceful GOAWAY" do
406+
stream.send_headers(request_headers, Protocol::HTTP2::END_STREAM)
407+
408+
# Establish request stream on server:
409+
server.read_frame
410+
411+
another_stream = client.create_stream do |connection, id|
412+
stream_class.create(connection, id)
413+
end
414+
another_stream.send_headers(request_headers, Protocol::HTTP2::END_STREAM)
415+
416+
# Server sends GOAWAY with last_stream_id=1, meaning stream 3 was not processed:
417+
server.send_goaway(0)
418+
419+
client.read_frame
420+
421+
# The unprocessed stream (id=3) should be closed with RequestRefusedError:
422+
expect(another_stream.state).to be == :closed
423+
expect(another_stream.error).to be_a(Protocol::HTTP::RequestRefusedError)
424+
425+
# The processed stream (id=1) should still be open:
426+
expect(stream.state).not.to be == :closed
427+
end
428+
429+
it "closes all streams with RequestRefusedError on GOAWAY with last_stream_id=0" do
430+
another_stream = client.create_stream do |connection, id|
431+
stream_class.create(connection, id)
432+
end
433+
another_stream.send_headers(request_headers, Protocol::HTTP2::END_STREAM)
434+
435+
# Server sends GOAWAY with last_stream_id=0, meaning no streams were processed:
436+
server.send_goaway(0)
437+
438+
client.read_frame
439+
440+
expect(another_stream.state).to be == :closed
441+
expect(another_stream.error).to be_a(Protocol::HTTP::RequestRefusedError)
442+
end
443+
444+
it "closes unprocessed streams with RequestRefusedError on non-graceful GOAWAY" do
445+
stream.send_headers(request_headers, Protocol::HTTP2::END_STREAM)
446+
447+
# Establish request stream on server:
448+
server.read_frame
449+
450+
another_stream = client.create_stream do |connection, id|
451+
stream_class.create(connection, id)
452+
end
453+
another_stream.send_headers(request_headers, Protocol::HTTP2::END_STREAM)
454+
455+
# Server sends non-graceful GOAWAY with last_stream_id=1:
456+
server.send_goaway(1, "Shutting down")
457+
458+
expect do
459+
client.read_frame
460+
end.to raise_exception(Protocol::HTTP2::GoawayError)
461+
462+
# The unprocessed stream should still have been closed with RequestRefusedError:
463+
expect(another_stream.state).to be == :closed
464+
expect(another_stream.error).to be_a(Protocol::HTTP::RequestRefusedError)
465+
end
466+
394467
it "client can handle non-graceful shutdown" do
395468
stream.send_headers(request_headers, Protocol::HTTP2::END_STREAM)
396469

0 commit comments

Comments
 (0)