Skip to content

Commit 4b6ddfc

Browse files
mathieu17gclaude
authored andcommitted
h2: retry streams reset with REFUSED_STREAM or rejected by GOAWAY (RFC 9113 8.7)
A stream reset with REFUSED_STREAM, or rejected by GOAWAY (stream_id > last_stream_id), is guaranteed unprocessed by the server and is safe to retry even for non-idempotent requests (RFC 9113 section 8.7). The client previously surfaced these as ProtocolError("HTTP/2 stream reset by peer") / H2GoAwayError after a single attempt, with the RST_STREAM error code discarded. - New H2StreamResetError carries the RFC 9113 section 7 error code; the RSTStreamFrame handler stores it instead of a generic ProtocolError, and showerror names the code (REFUSED_STREAM, CANCEL, ENHANCE_YOUR_CALM, ...). - _retryable_request_error classifies REFUSED_STREAM resets and H2GoAwayError as retryable; _h2_guaranteed_unprocessed lifts the idempotent-method gate for them (the replayable-body gate still applies). - The existing _drop_h2_conn! on error already makes the retry use a fresh connection. Greens the tests added in the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3fe6184 commit 4b6ddfc

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

src/http2_client.jl

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,37 @@ function Base.showerror(io::IO, err::H2GoAwayError)
3838
return nothing
3939
end
4040

41+
# RFC 9113 §7 error codes (0x0–0xd).
42+
const _H2_ERROR_CODE_NAMES = (
43+
"NO_ERROR", "PROTOCOL_ERROR", "INTERNAL_ERROR", "FLOW_CONTROL_ERROR",
44+
"SETTINGS_TIMEOUT", "STREAM_CLOSED", "FRAME_SIZE_ERROR", "REFUSED_STREAM",
45+
"CANCEL", "COMPRESSION_ERROR", "CONNECT_ERROR", "ENHANCE_YOUR_CALM",
46+
"INADEQUATE_SECURITY", "HTTP_1_1_REQUIRED",
47+
)
48+
const _H2_REFUSED_STREAM = UInt32(0x7)
49+
50+
function _h2_error_code_name(code::UInt32)::String
51+
code < UInt32(length(_H2_ERROR_CODE_NAMES)) && return _H2_ERROR_CODE_NAMES[Int(code) + 1]
52+
return string("0x", string(code; base = 16))
53+
end
54+
55+
"""
56+
H2StreamResetError
57+
58+
Raised when the peer resets an HTTP/2 stream with `RST_STREAM`. Carries the
59+
RFC 9113 §7 `error_code`. `REFUSED_STREAM` guarantees the stream was closed
60+
prior to any processing (RFC 9113 §8.7), so such requests are retried when
61+
retries are enabled.
62+
"""
63+
struct H2StreamResetError <: HTTPError
64+
error_code::UInt32
65+
end
66+
67+
function Base.showerror(io::IO, err::H2StreamResetError)
68+
print(io, "HTTP/2 stream reset by peer: ", _h2_error_code_name(err.error_code))
69+
return nothing
70+
end
71+
4172
"""
4273
H2StreamState
4374
@@ -762,7 +793,7 @@ function _process_incoming_frame!(conn::H2Connection, frame::AbstractFrame)
762793
rst = frame::RSTStreamFrame
763794
state = _stream_state(conn, rst.stream_id)
764795
state === nothing && return nothing
765-
_set_stream_error!(state::H2StreamState, ProtocolError("HTTP/2 stream reset by peer"))
796+
_set_stream_error!(state::H2StreamState, H2StreamResetError(rst.error_code))
766797
return nothing
767798
elseif frame isa WindowUpdateFrame
768799
update = frame::WindowUpdateFrame

src/http_client_retry.jl

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ end
4848
function _retryable_request_error(err::Exception)::Bool
4949
current = err
5050
while true
51+
# RFC 9113 §8.7: a stream reset with REFUSED_STREAM, or rejected by
52+
# GOAWAY (stream_id > last_stream_id), was not processed by the server.
53+
current isa H2StreamResetError && return (current::H2StreamResetError).error_code == _H2_REFUSED_STREAM
54+
current isa H2GoAwayError && return true
5155
current isa EOFError && return true
5256
current isa SystemError && return true
5357
current isa ParseError && return true
@@ -72,6 +76,22 @@ end
7276

7377
_retryable_request_error(err::RequestRetryError)::Bool = _retryable_request_error(err.err)
7478

79+
# RFC 9113 §8.7: requests on streams reset with REFUSED_STREAM or rejected by
80+
# GOAWAY are guaranteed unprocessed, hence safe to retry regardless of method
81+
# idempotency. The replayable-body gate in `_should_retry_request_attempt`
82+
# still applies.
83+
function _h2_guaranteed_unprocessed(err)::Bool
84+
current = err
85+
while true
86+
if current isa RequestRetryError
87+
current = (current::RequestRetryError).err
88+
continue
89+
end
90+
current isa H2StreamResetError && return (current::H2StreamResetError).error_code == _H2_REFUSED_STREAM
91+
return current isa H2GoAwayError
92+
end
93+
end
94+
7595
function _retry_hook_decision(controller::_RetryController, attempt::Int, err, req::Request, resp)
7696
hook = controller.retry_if
7797
hook === nothing && return nothing
@@ -86,7 +106,8 @@ function _should_retry_request_attempt(controller::_RetryController, attempt::In
86106
_retryable_request_body(req) || return false
87107
built_in = false
88108
if err !== nothing
89-
built_in = _retryable_policy_request(req, controller.retry_non_idempotent) && _retryable_request_error(err)
109+
policy_ok = _retryable_policy_request(req, controller.retry_non_idempotent) || _h2_guaranteed_unprocessed(err)
110+
built_in = policy_ok && _retryable_request_error(err)
90111
elseif resp !== nothing
91112
built_in = _retryable_policy_request(req, controller.retry_non_idempotent) && _retryable_status((resp::Response).status)
92113
end

0 commit comments

Comments
 (0)