Skip to content

Commit 3fe6184

Browse files
mathieu17gclaude
authored andcommitted
Add failing tests: h2 streams reset with REFUSED_STREAM or rejected by GOAWAY are never retried
Scripted-server tests (patterned on http2_client_tests.jl): the server refuses the first request - RST_STREAM(REFUSED_STREAM) or GOAWAY(last_stream_id=0) - then answers 200 to any later attempt, on the same or a new connection. With retry=true the client currently surfaces ProtocolError / H2GoAwayError after a single attempt; per RFC 9113 section 8.7 both cases are guaranteed unprocessed and safe to retry. Red until the retry fix lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 426b15b commit 3fe6184

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
using Test
2+
using HTTP
3+
using Reseau
4+
5+
const HT = HTTP
6+
const ND = Reseau.HostResolvers
7+
const NC = Reseau.TCP
8+
9+
# RFC 9113 §7 error codes
10+
const _RSR_REFUSED_STREAM = UInt32(0x7)
11+
const _RSR_NO_ERROR = UInt32(0x0)
12+
13+
function _rsr_write_frame!(conn::NC.Conn, frame::HT.AbstractFrame)
14+
io = IOBuffer()
15+
HT.write_frame!(io, frame)
16+
bytes = take!(io)
17+
total = 0
18+
while total < length(bytes)
19+
n = write(conn, bytes[(total + 1):end])
20+
n > 0 || error("expected write progress")
21+
total += n
22+
end
23+
return nothing
24+
end
25+
26+
function _rsr_read_preface!(conn::NC.Conn)
27+
n = length(HT._H2_PREFACE)
28+
offset = 0
29+
while offset < n
30+
chunk = Vector{UInt8}(undef, n - offset)
31+
nr = readbytes!(conn, chunk)
32+
nr > 0 || error("unexpected EOF reading client preface")
33+
offset += nr
34+
end
35+
return nothing
36+
end
37+
38+
function _rsr_next_headers!(reader)::HT.HeadersFrame
39+
while true
40+
frame = HT.read_frame!(reader)
41+
frame isa HT.HeadersFrame && return frame::HT.HeadersFrame
42+
frame isa HT.WindowUpdateFrame && continue
43+
frame isa HT.SettingsFrame && continue
44+
frame isa HT.PingFrame && continue
45+
error("expected headers frame, got $(typeof(frame))")
46+
end
47+
end
48+
49+
# Scripted h2 server: refuses the first `refuse_first` requests (RST_STREAM
50+
# REFUSED_STREAM, or GOAWAY rejecting the in-flight stream), then answers 200.
51+
# An accept loop plus a per-connection headers loop keeps it agnostic to
52+
# whether the client retries on the same connection or on a new one.
53+
function _rsr_serve!(listener; scenario::Symbol, refuse_first::Int = 1)
54+
attempts = Threads.Atomic{Int}(0)
55+
conns = Threads.Atomic{Int}(0)
56+
accept_task = errormonitor(Threads.@spawn begin
57+
while true
58+
conn = try
59+
NC.accept(listener)
60+
catch
61+
break # listener closed: end of test
62+
end
63+
Threads.atomic_add!(conns, 1)
64+
errormonitor(Threads.@spawn begin
65+
try
66+
_rsr_read_preface!(conn)
67+
_rsr_write_frame!(conn, HT.SettingsFrame(false, Pair{UInt16, UInt32}[]))
68+
reader = HT._ConnReader(conn)
69+
encoder = HT.Encoder()
70+
while true
71+
hf = _rsr_next_headers!(reader)
72+
attempt = Threads.atomic_add!(attempts, 1) + 1
73+
if attempt <= refuse_first
74+
if scenario === :rst
75+
_rsr_write_frame!(conn, HT.RSTStreamFrame(hf.stream_id, _RSR_REFUSED_STREAM))
76+
elseif scenario === :goaway
77+
_rsr_write_frame!(conn, HT.GoAwayFrame(UInt32(0), _RSR_NO_ERROR, UInt8[]))
78+
break
79+
else
80+
error("unknown scenario $scenario")
81+
end
82+
else
83+
encoded = HT.encode_header_block(encoder, HT.HeaderField[HT.HeaderField(":status", "200", false)])
84+
_rsr_write_frame!(conn, HT.HeadersFrame(hf.stream_id, true, true, encoded))
85+
end
86+
end
87+
catch
88+
# client tore the connection down (expected on error paths)
89+
finally
90+
HTTP.@try_ignore NC.close(conn)
91+
end
92+
end)
93+
end
94+
end)
95+
return attempts, conns, accept_task
96+
end
97+
98+
function _rsr_request(url)
99+
return try
100+
HT.get(url; protocol = :h2, retry = true, connect_timeout = 5, request_timeout = 15)
101+
catch err
102+
err
103+
end
104+
end
105+
106+
@testset "HTTP/2 retry of guaranteed-unprocessed streams (RFC 9113 §8.7)" begin
107+
108+
# A stream reset with REFUSED_STREAM is guaranteed unprocessed (RFC 9113 §8.7)
109+
# and must be retried transparently.
110+
@testset "HTTP/2 client retries a stream reset with REFUSED_STREAM" begin
111+
listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8)
112+
laddr = NC.addr(listener)::NC.SocketAddrV4
113+
url = "http://" * ND.join_host_port("127.0.0.1", Int(laddr.port)) * "/"
114+
attempts, conns, accept_task = _rsr_serve!(listener; scenario = :rst)
115+
try
116+
result = _rsr_request(url)
117+
@info "REFUSED_STREAM scenario" result attempts[] conns[]
118+
@test attempts[] == 2 # the refused attempt was retried
119+
@test result isa HT.Response
120+
result isa HT.Response && @test (result::HT.Response).status == 200
121+
finally
122+
HTTP.@try_ignore NC.close(listener)
123+
HTTP.@try_ignore wait(accept_task)
124+
end
125+
end
126+
127+
# A stream rejected by GOAWAY (stream_id > last_stream_id) is likewise
128+
# unprocessed and must be retried, necessarily on a new connection.
129+
@testset "HTTP/2 client retries a stream rejected by GOAWAY" begin
130+
listener = ND.listen("tcp", "127.0.0.1:0"; backlog = 8)
131+
laddr = NC.addr(listener)::NC.SocketAddrV4
132+
url = "http://" * ND.join_host_port("127.0.0.1", Int(laddr.port)) * "/"
133+
attempts, conns, accept_task = _rsr_serve!(listener; scenario = :goaway)
134+
try
135+
result = _rsr_request(url)
136+
@info "GOAWAY scenario" result attempts[] conns[]
137+
@test attempts[] == 2
138+
@test conns[] == 2 # GOAWAY drains the first connection
139+
@test result isa HT.Response
140+
result isa HT.Response && @test (result::HT.Response).status == 200
141+
finally
142+
HTTP.@try_ignore NC.close(listener)
143+
HTTP.@try_ignore wait(accept_task)
144+
end
145+
end
146+
147+
end # parent testset

0 commit comments

Comments
 (0)