Don't retry terminal HTTP errors, and surface them properly - #494
Conversation
`Task.retrying` threw `URLError(.badServerResponse)` for any non-2xx response, which its own `catch` then swallowed and retried. A permanent failure such as 401 or 404 therefore burned the full backoff schedule — roughly 65 seconds and 7 round-trips at the default `retryCount` of 6 — before `CustomURLSession.getRequestId` surfaced it to the caller. Return the response immediately for terminal client errors instead. This matches what already happens once retries are exhausted, since the loop's final attempt returns the response unchecked, so callers see the same error, just without the wasted requests and delay. Client errors that may succeed on a retry (408, 425, 429, 499) and all server errors keep their existing retry behaviour. Fixes #492 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`getRequestId` only threw for 401 and 404. Every other non-2xx response returned normally, so the error body was then decoded as if it were a success payload. That failed, and the caller received `NetworkError.decoding` — describing the wrong failure — while the SDK tracked a `network_decoding_fail` event for what was really an HTTP error. Add `NetworkError.http(statusCode:)` and classify every non-2xx response through `NetworkError.make(fromStatusCode:)`. 401 and 404 keep their existing dedicated cases and log messages, so nothing matching on those changes behaviour. The three near-identical logging blocks collapse into one that also records the status code. `NetworkError` gains an explicit `Equatable` conformance because adding an associated value drops the conformance simple enums get implicitly, which `PaywallLogic.handlePaywallError` relies on for `error == .notFound`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — a two-commit fix that stops retrying permanently-failing HTTP responses and classifies non-2xx responses as HTTP errors instead of decoding failures.
- Skip retries for terminal client errors —
Task+Retrying.swiftnow returns the response early whenTaskRetryLogic.isTerminal(statusCode:)is true, avoiding the full ~65s backoff on requests that can only fail the same way (e.g.401,404). Retryable client codes (408,425,429,499) and all5xxkeep retrying. - Surface HTTP errors properly —
getRequestIdinCustomURLSession.swiftclassifies every non-2xx response through newNetworkError.make(fromStatusCode:), addingNetworkError.http(statusCode:). Responses other than401/404no longer decode-fail intoNetworkError.decodingor emit a spuriousnetwork_decoding_failevent; the three near-identical logging blocks collapse into one that recordsstatus_code. Equatableconformance — added explicitly toNetworkErrorbecause the new associated value drops the implicit conformance thatPaywallLogic.handlePaywallError'serror == .notFoundcheck relies on.- Tests — new coverage asserts real
Task.retryinginvocation counts (1 for terminal errors, 4 for retryable/5xx),isTerminalclassification, and error/description/log-message mapping.CHANGELOG.mdupdated under unreleased4.16.2.
I verified the two classification paths (isTerminal for retry decisions and make for the surfaced error) stay consistent across all status ranges, that no exhaustive switch over NetworkError exists that the new .http case would break, and that the Equatable conformance is genuinely required. NetworkError is internal, so integrators are unaffected.
ℹ️ 3xx responses classify inconsistently between the two new code paths
NetworkError.make(fromStatusCode:) maps a 3xx (e.g. 302) to .http(statusCode:), and a test asserts that. But TaskRetryLogic.isTerminal returns false for 3xx (it only treats 400...499 as terminal), so a redirect reaching the retry-loop check would throw URLError(.badServerResponse) and burn the retry schedule before surfacing. In practice this is essentially unreachable — URLSession follows redirects by default, so a raw 3xx rarely reaches either path — so it's a cosmetic inconsistency, not a bug. Flagging only for awareness; no change needed.
Claude Opus | 𝕏
`NetworkError.make(fromStatusCode:)` treats any non-2xx response as an error, but `TaskRetryLogic.isTerminal` only considered 4xx terminal. A 3xx therefore threw `URLError(.badServerResponse)` and burned the full retry schedule before surfacing as `.http(statusCode:)`. The test suite asserted both opinions, so it documented the contradiction rather than catching it. A redirect only reaches the caller when `URLSession` couldn't follow it — a 3xx with no `Location` header, for instance — and it comes back the same way however many times it's sent, so there's nothing to gain from retrying. Reframe `isTerminal` as "not 2xx, and retrying can't help": 5xx and the retryable client errors are still sent again, everything else outside 2xx is terminal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the one new commit since the prior review resolves the 3xx classification inconsistency flagged last time by aligning the retry loop's terminal check with the surfaced-error mapping.
isTerminalnow treats 3xx as terminal —TaskRetryLogic.isTerminal(statusCode:)was rewritten from an early400...499-only guard to explicit2xx → false,5xx → false, else!retryableClientErrorCodes.contains. A redirect that reaches the loop (becauseURLSessioncouldn't follow it) now short-circuits instead of burning the backoff schedule, matchingNetworkError.make(fromStatusCode:)which already maps 3xx to.http.- Test coverage for redirects — added
redirectsAreNotRetried(301/302/307/308→ 1 invocation through realTask.retrying) andisTerminal_redirects(301/302/303/307/308→ terminal).isTerminal_successfulResponsesdropped301/304from its success set, correctly reflecting that 3xx is no longer non-terminal.
I confirmed the two classification paths (isTerminal for retry decisions, make for the surfaced error) now agree across every status range: both treat 5xx and 408/425/429/499 as retryable and everything else non-2xx as terminal. The prior review's sole note is addressed; no new concerns.
Claude Opus | 𝕏
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Changes in this pull request
Fixes #492, plus a related error-reporting problem found while working on it.
1. Terminal HTTP errors are no longer retried (
cd31438d3)Task.retryingthrewURLError(.badServerResponse)for any non-2xx response, which its owncatchimmediately swallowed and retried. A permanent failure such as401or404therefore burned the full backoff schedule before surfacing:That's ~65s and 7 round-trips at the default
retryCountof 6, for a request that could only ever fail the same way.Terminal statuses now return the response immediately instead of throwing. This matches what already happened once retries were exhausted — the loop's final attempt returns the response unchecked — so callers see the same error, just without the wasted requests and delay.
Per @anglinb's review note on the issue, client errors that may succeed on a retry are explicitly excluded and keep their existing behaviour:
408Request Timeout,425Too Early,429Too Many Requests,499Client Closed Request. All5xxare unaffected.2. HTTP errors are no longer reported as decoding failures (
d9fbbf7f2)getRequestIdonly threw for401and404. Every other non-2xx response returned normally, so the error body was then decoded as if it were a success payload. That failed, and the caller receivedNetworkError.decoding— describing the wrong failure — while the SDK tracked anetwork_decoding_failevent for what was really an HTTP error.Adds
NetworkError.http(statusCode:)and classifies every non-2xx response through a newNetworkError.make(fromStatusCode:).401and404keep their dedicated cases and log messages, so nothing matching on those changes behaviour. The three near-identical logging blocks collapse into one that also records the status code.Note:
NetworkErrorneeded an explicitEquatableconformance, because adding an associated value drops the conformance simple enums get implicitly — andPaywallLogic.handlePaywallErrorrelies on it forerror == .notFound.3. Redirects classify consistently across both paths (
671d6a2b7)The two changes above initially disagreed about 3xx.
NetworkError.make(fromStatusCode:)treats any non-2xx response as an error, butTaskRetryLogic.isTerminalonly considered 4xx terminal — so a redirect would have burned the full retry schedule before surfacing as.http(statusCode:). The tests asserted both opinions, so the suite documented the contradiction rather than catching it.A redirect only reaches the caller when
URLSessioncouldn't follow it — a 3xx with noLocationheader, for instance — and it comes back the same way however many times it's sent.isTerminalis now framed as "not 2xx, and retrying can't help", so both paths agree for every status code.The commits are kept separate: the first is a pure latency fix with no API change, the second alters the SDK's internal error surface, the third reconciles them.
NetworkErroris not public, so integrators are unaffected either way.Verification
888 tests in 93 suites pass. New coverage asserts actual invocation counts through the real
Task.retrying(1 call for a401, 4 for a429), not just the predicate:401/404keep their existing errors — regression guard for the compatibility claim above400,401,403,404,410,422301,302,303,307,308408,425,429,499500,502,503Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code
Greptile Summary
This PR improves HTTP failure handling.
Confidence Score: 5/5
The PR appears safe to merge, with terminal and retryable HTTP statuses handled consistently through the request pipeline.
The changed retry classification matches the added invocation-count tests, and existing callers either generically propagate or handle the new status-aware errors without relying on the former decoding behavior.
Important Files Changed
Sequence Diagram
sequenceDiagram participant Caller participant Retry as Task.retrying participant Backend participant Session as CustomURLSession Caller->>Retry: Start request Retry->>Backend: Send HTTP request Backend-->>Retry: HTTP response alt Terminal 4xx Retry-->>Session: Return response immediately else Retryable 4xx or 5xx Retry->>Backend: Retry with backoff Backend-->>Retry: Final response Retry-->>Session: Return response else Successful 2xx Retry-->>Session: Return response end alt Non-2xx Session-->>Caller: Throw classified NetworkError else 2xx Session-->>Caller: Decode response endReviews (1): Last reviewed commit: "Surface HTTP errors instead of reporting..." | Re-trigger Greptile
Context used: