Skip to content

Retry network errors in Kotlin through the idempotency gate - #539

Merged
jeremy merged 3 commits into
mainfrom
kotlin-network-error-retry
Aug 1, 2026
Merged

Retry network errors in Kotlin through the idempotency gate#539
jeremy merged 3 commits into
mainfrom
kotlin-network-error-retry

Conversation

@jeremy

@jeremy jeremy commented Jul 31, 2026

Copy link
Copy Markdown
Member

Closes the last Kotlin retry-capability gap (wave 1, rock B3): transport-level network errors now retry through the same SPEC §7 idempotency gate as HTTP-status retries, modeled on the Swift directive loop (#517).

What changed

  • BasecampHttpClient.requestWithRetry classifies each attempt into an AttemptOutcome inside the catch, then acts on it outside any catch: onRetry (failed/upcoming attempt pair), backoff sleep, and the next attempt run at the tail, so a CancellationException from the sleep propagates raw and no phantom events fire for an attempt that already ended.
  • Eligibility (Gates 1+2) is hoisted ahead of the attempt and shared by both failure shapes: GET/PUT/DELETE/HEAD retry by method, POST only when metadata says idempotent: true, bounded by the existing min(caller cap, operation max) ceiling and enableRetry. Non-idempotent POSTs stay single-attempt.
  • Re-auth per retry comes for free: request() invokes authStrategy.authenticate when building every attempt.
  • Auth-phase failures surface raw and spend no retry budget (review finding, third commit): authenticateClassified tags a throwing AuthStrategy as an internal AuthPhaseFailure, and both retry entry points unwrap it — the strategy's own exception propagates raw on the first attempt, the wire is never reached, and the strategy is never re-driven by retries. Matches Swift (authenticates outside the attempt's catch), Go (returns the strategy's error before the attempt), and TypeScript (rethrows the original value so its identity survives). A BasecampException thrown by a strategy still propagates as-is.
  • Conformance: the "Network error on an idempotent POST is retried then succeeds" skip is removed (this PR's one §19 roster line); all seven DownloadURL/UploadsDownload skips stay for B5.
  • SPEC sweep: §7 network-error divergence clause (:439), the §7 Cross-SDK Kotlin sentence (:473), the two "(unlike Kotlin/TS)" Swift mentions (§7 + Appendix F), the Appendix F Kotlin row, the §19 roster line, the network-retry.json description, and a Kotlin README retry bullet.
  • Rebased onto Run the TypeScript retry loop beneath the middleware chain #538 (B2, TS retry loop): the §7 ~:439 divergence sentence and ~:473 Cross-SDK bullet now carry both halves — TS's loop-beneath-the-middleware-chain description and Kotlin's shared-gate description. With both landed, the Swift "(unlike Kotlin/TS)" parentheticals are dropped entirely rather than half-corrected.

Verify-first findings

Body replayability. The request body is a String parameter re-setBody() on every attempt, so replay after a thrown exception is structural — and now pinned: retriesIdempotentPostNetworkErrorWithFullBodyThroughGeneratedService drives the generated myAssignments.prioritizeAssignment (idempotent POST with a JSON body) through MockEngine throw-then-204 and asserts both attempts carry the identical full body {"id":123}.

Exception taxonomy (Ktor 3.5.1, JVM target). Verified against the shipped jar:

  • io.ktor.client.plugins.HttpRequestTimeoutException extends java.io.IOException — whole-request time budget (requestTimeoutMillis); deliberately NOT retried — the timeout re-arms per attempt, so a retry burns another full budget on a slowness shape it tends to repeat (comments made honest about the per-attempt mechanics in the second commit, after Codex review).
  • io.ktor.client.network.sockets.ConnectTimeoutException extends java.net.ConnectException — connect-phase, retried.
  • socket timeouts / connection resets / java.net.* IOExceptions — retried.
  • java.nio.channels.UnresolvedAddressException (CIO DNS failure) — extends IllegalArgumentException, not IOException. This is why the gate is a carve-out (cause !is HttpRequestTimeoutException), not an IOException allowlist: an allowlist would silently drop DNS failures. Matches Swift's broad classification (everything non-BasecampError retries).
  • CancellationException — rethrown raw, first catch clause (unchanged).
  • Auth-phase throws — classified at the source, not at the transport boundary: the broad transport catch cannot tell a broken credential provider from CIO's UnresolvedAddressException, but the auth strategy is the SDK's own extension point, invoked at a known point, so its failures are tagged there and surfaced raw.

Red proofs (pre-fix, verbatim)

Conformance, skip removed, before the fix:

  FAIL: Network error on an idempotent POST is retried then succeeds
        Expected 2 requests, got 1
Passed: 115, Failed: 1, Skipped: 8, Total: 124

Native RetryTest, new/flipped tests before the fix (23 tests completed, 4 failed):

networkErrorTriggersRetryForIdempotentOps -> BasecampException$Network: Network error: Connection refused
networkErrorRetriesExhaustAttempts -> AssertionFailedError: expected: <3> but was: <1>
retriesIdempotentPostNetworkErrorWithFullBodyThroughGeneratedService -> BasecampException$Network: Network error: Connection reset by peer
onRetryPairForNetworkError -> BasecampException$Network: Network error: Connection refused

The three pins that must NOT flip passed before and after the fix: nonIdempotentPostNetworkErrorIsSingleAttempt, requestTimeoutExceptionIsNotRetried, enableRetryFalseDisablesNetworkRetry.

Auth-phase red proofsauthStrategyFailureIsNotRetried against the un-fixed retry loop (network-retry commits without the third commit):

org.opentest4j.AssertionFailedError: Expected an exception of class java.lang.IllegalStateException to be thrown, but was com.basecamp.sdk.BasecampException$Network: Network error: credential provider broke

and, with the auth-call assertion ordered first to expose the retry consumption:

org.opentest4j.AssertionFailedError: auth strategy must not be re-driven by retries (thrown: com.basecamp.sdk.BasecampException$Network: Network error: credential provider broke) ==> expected: <1> but was: <3>

The throwing strategy was driven 3 times through the retry budget and misreported as a retryable network error; with the fix it runs once, the wire is never reached (requestCount == 0), and the caller sees their own IllegalStateException.

After the fix

  • Full :basecamp-sdk:jvmTest: BUILD SUCCESSFUL, 390 jvm testcases (all suites, authStrategyFailureIsNotRetried included).
  • make conformance-kotlin (post-rebase): Passed: 116, Failed: 0, Skipped: 8, Total: 124.
  • scripts/check-retry-metadata-parity.py (post-rebase, includes Run the TypeScript retry loop beneath the middleware chain #538's TS runtime-consumption update): OK.
  • make conformance-fixtures-check: ok.

Copilot AI review requested due to automatic review settings July 31, 2026 23:12
@jeremy jeremy added enhancement New feature or request kotlin labels Jul 31, 2026
@github-actions github-actions Bot added the conformance Conformance test suite label Jul 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fddd45d650

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copilot AI review requested due to automatic review settings July 31, 2026 23:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c1df600897

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

jeremy added 3 commits July 31, 2026 17:12
The Kotlin transport surfaced every transport-level exception immediately
as BasecampException.Network, so a single connection blip failed even a
GET that would happily retry a 503. Route network failures through the
same SPEC §7 eligibility gate as HTTP-status retries: naturally
idempotent methods (GET/PUT/DELETE/HEAD) and idempotent-flagged POSTs
retry with the shared backoff and attempt ceiling, while non-idempotent
POSTs stay single-attempt.

Modeled on the Swift directive loop (#517): the catch clause only
classifies the attempt outcome; on_retry (with the failed/upcoming
attempt pair), the backoff sleep, and the next attempt all run outside
any catch, so cancellation propagates raw and no phantom events fire.
Auth headers are rebuilt per attempt, so each retry re-authenticates.

The deliberate carve-out is Ktor's HttpRequestTimeoutException: the
caller's whole-request time budget is already spent when it surfaces, so
retrying would extend latency past what they configured. Everything else
the transport throws — connect/socket timeouts, connection resets, DNS
failures including CIO's UnresolvedAddressException (not an IOException,
which is why the gate is a carve-out rather than an allowlist) — retries.

Closes the "Network error on an idempotent POST is retried then
succeeds" conformance skip (red-first: Expected 2 requests, got 1) and
flips the native GET network test from pinning single-attempt to pinning
the retry, with new pins for body replay on a retried idempotent POST,
attempt-budget exhaustion, the timeout carve-out, enableRetry=false, and
the on_retry pair.
The HttpRequestTimeoutException carve-out's comments claimed the
caller's whole-request allowance was "already spent", implying a shared
deadline across attempts. HttpTimeout is installed on the client, so
each attempt gets a fresh requestTimeoutMillis window. The carve-out is
deliberate for a different reason: an attempt that consumed its entire
time budget is a slowness shape a retry tends to repeat, and each retry
burns another full budget, multiplying worst-case wall-clock time by the
attempt count. Transient shapes (connect/socket timeouts, resets, DNS)
still retry.
A throwing custom AuthStrategy was classified as a transport failure: the
broad Exception catch converted it to a retryable Network error, re-driving
the strategy through the full retry budget (3 auth calls for a default GET)
and misreporting a configuration or credential-provider fault as a network
error.

Classify at the source instead: authenticateClassified tags an auth-phase
throw as an internal AuthPhaseFailure, and both retry entry points unwrap it
and rethrow the strategy's own exception raw on the first attempt — the wire
is never reached and no retry budget is spent. Raw propagation matches the
sibling SDKs (Swift authenticates outside the attempt's catch, Go returns
the strategy's error before the attempt, TypeScript rethrows the original
value so its identity survives) and this SDK's own service layer, which
rethrows non-BasecampException exceptions raw. A BasecampException thrown
by a strategy still propagates as-is.
Copilot AI review requested due to automatic review settings August 1, 2026 00:13
@jeremy
jeremy force-pushed the kotlin-network-error-retry branch from c1df600 to 52ac1da Compare August 1, 2026 00:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy

jeremy commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Merging on green CI: 38 checks pass (4 expected skips) on 52ac1da. Review status: Codex reviewed both earlier commits (its P2 threads are addressed and resolved — the timeout carve-out comment fix in the second commit, and the auth-phase classification fix with red proofs in the third) and posted no fresh round for the final push after a 10-minute window; Copilot errored on every attempt including the post-push re-run. Zero unresolved threads.

@jeremy
jeremy merged commit 65cedde into main Aug 1, 2026
42 of 43 checks passed
@jeremy
jeremy deleted the kotlin-network-error-retry branch August 1, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conformance Conformance test suite enhancement New feature or request kotlin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants