Skip to content

Commit a9e8b86

Browse files
quinnjclaude
andauthored
Add public isrecoverable(::Exception) for retry classification (#1245) (#1310)
Downstream packages (e.g. GitHub.jl) used HTTP.jl 1.x's private HTTP.RetryRequest.isrecoverable to decide whether a request exception is worth retrying. 2.0 dropped that module; the equivalent logic lived only in the private _retryable_request_error. Expose it as a documented public HTTP.isrecoverable(::Exception)::Bool that delegates to the same battle-tested classifier the built-in retry policy uses. Accepts either the underlying exception or the RequestRetryError wrapper passed to a retry_if callback, so downstream retry/backoff logic no longer has to reach into HTTP internals. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b4b0a5e commit a9e8b86

3 files changed

Lines changed: 43 additions & 0 deletions

File tree

docs/src/api/client.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ HTTP.Client
1717
HTTP.RetryBucket
1818
HTTP.RequestRetryError
1919
HTTP.retry_attempts
20+
HTTP.isrecoverable
2021
HTTP.roundtrip!
2122
HTTP.request
2223
HTTP.get

src/http_client_retry.jl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ end
7676

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

79+
"""
80+
isrecoverable(err::Exception) -> Bool
81+
82+
Return `true` when `err` represents a transient transport or protocol failure
83+
that is safe to retry — the same classification HTTP.jl's built-in retry policy
84+
applies to request-path exceptions. Recoverable cases include connection resets
85+
and EOFs (`EOFError`, `IOPoll.NetClosingError`), socket errors (`SystemError`),
86+
malformed responses (`ParseError`), and dial/handshake timeouts
87+
(`HostResolvers.DialTimeoutError`, `TLS.TLSHandshakeTimeoutError`), including the
88+
underlying causes of wrapped `HostResolvers.OpError`/`TLS.TLSError` exceptions.
89+
A request *deadline* being exceeded (`IOPoll.DeadlineExceededError`) is treated
90+
as non-recoverable, as is anything else.
91+
92+
Accepts either the underlying exception or the [`RequestRetryError`](@ref)
93+
wrapper passed to a `retry_if` callback. This is the public replacement for
94+
HTTP.jl 1.x's `HTTP.RetryRequest.isrecoverable`, intended for downstream
95+
packages that implement their own retry/backoff logic.
96+
"""
97+
isrecoverable(err::Exception)::Bool = _retryable_request_error(err)
98+
7999
# RFC 9113 §8.7: requests on streams reset with REFUSED_STREAM or rejected by
80100
# GOAWAY are guaranteed unprocessed, hence safe to retry regardless of method
81101
# idempotency. The replayable-body gate in `_should_retry_request_attempt`

test/http_retry_tests.jl

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,3 +574,25 @@ end
574574
invalid_headers = HT.Headers(["Retry-After" => "nonsense"])
575575
@test HT._retry_after_delay_ns(invalid_headers) === nothing
576576
end
577+
578+
@testset "isrecoverable classifies retryable request exceptions (#1245)" begin
579+
# recoverable: transient transport / protocol failures
580+
@test HT.isrecoverable(EOFError())
581+
@test HT.isrecoverable(HT.ParseError("bad"))
582+
@test HT.isrecoverable(SystemError("connect"))
583+
@test HT.isrecoverable(ND.DialTimeoutError("host:80"))
584+
585+
# non-recoverable: a deadline being hit, and unrelated exceptions
586+
@test !HT.isrecoverable(Reseau.IOPoll.DeadlineExceededError())
587+
@test !HT.isrecoverable(ArgumentError("nope"))
588+
@test !HT.isrecoverable(ErrorException("boom"))
589+
590+
# accepts the RequestRetryError wrapper handed to retry_if, unwrapping it
591+
@test HT.isrecoverable(HT.RequestRetryError(EOFError()))
592+
@test !HT.isrecoverable(HT.RequestRetryError(ArgumentError("nope")))
593+
594+
# matches the internal classifier the built-in policy uses
595+
for err in (EOFError(), HT.ParseError("x"), ArgumentError("y"), Reseau.IOPoll.DeadlineExceededError())
596+
@test HT.isrecoverable(err) == HT._retryable_request_error(err)
597+
end
598+
end

0 commit comments

Comments
 (0)