Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/api/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ HTTP.Client
HTTP.RetryBucket
HTTP.RequestRetryError
HTTP.retry_attempts
HTTP.isrecoverable
HTTP.roundtrip!
HTTP.request
HTTP.get
Expand Down
20 changes: 20 additions & 0 deletions src/http_client_retry.jl
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,26 @@ end

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

"""
isrecoverable(err::Exception) -> Bool

Return `true` when `err` represents a transient transport or protocol failure
that is safe to retry — the same classification HTTP.jl's built-in retry policy
applies to request-path exceptions. Recoverable cases include connection resets
and EOFs (`EOFError`, `IOPoll.NetClosingError`), socket errors (`SystemError`),
malformed responses (`ParseError`), and dial/handshake timeouts
(`HostResolvers.DialTimeoutError`, `TLS.TLSHandshakeTimeoutError`), including the
underlying causes of wrapped `HostResolvers.OpError`/`TLS.TLSError` exceptions.
A request *deadline* being exceeded (`IOPoll.DeadlineExceededError`) is treated
as non-recoverable, as is anything else.

Accepts either the underlying exception or the [`RequestRetryError`](@ref)
wrapper passed to a `retry_if` callback. This is the public replacement for
HTTP.jl 1.x's `HTTP.RetryRequest.isrecoverable`, intended for downstream
packages that implement their own retry/backoff logic.
"""
isrecoverable(err::Exception)::Bool = _retryable_request_error(err)

# RFC 9113 §8.7: requests on streams reset with REFUSED_STREAM or rejected by
# GOAWAY are guaranteed unprocessed, hence safe to retry regardless of method
# idempotency. The replayable-body gate in `_should_retry_request_attempt`
Expand Down
22 changes: 22 additions & 0 deletions test/http_retry_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -574,3 +574,25 @@ end
invalid_headers = HT.Headers(["Retry-After" => "nonsense"])
@test HT._retry_after_delay_ns(invalid_headers) === nothing
end

@testset "isrecoverable classifies retryable request exceptions (#1245)" begin
# recoverable: transient transport / protocol failures
@test HT.isrecoverable(EOFError())
@test HT.isrecoverable(HT.ParseError("bad"))
@test HT.isrecoverable(SystemError("connect"))
@test HT.isrecoverable(ND.DialTimeoutError("host:80"))

# non-recoverable: a deadline being hit, and unrelated exceptions
@test !HT.isrecoverable(Reseau.IOPoll.DeadlineExceededError())
@test !HT.isrecoverable(ArgumentError("nope"))
@test !HT.isrecoverable(ErrorException("boom"))

# accepts the RequestRetryError wrapper handed to retry_if, unwrapping it
@test HT.isrecoverable(HT.RequestRetryError(EOFError()))
@test !HT.isrecoverable(HT.RequestRetryError(ArgumentError("nope")))

# matches the internal classifier the built-in policy uses
for err in (EOFError(), HT.ParseError("x"), ArgumentError("y"), Reseau.IOPoll.DeadlineExceededError())
@test HT.isrecoverable(err) == HT._retryable_request_error(err)
end
end
Loading