Give the backoff formula a ceiling in all six SDKs, and retry a Swift Transport's own network error - #592
Conversation
… Transport's own network error #577 was filed as a Kotlin bug. It is six SDKs, in three distinct shapes, and the Swift one is strictly the worst and was tracked nowhere. Signed 64-bit wrap Kotlin (1L shl), Go (1<<) The product goes negative and the sleep primitive treats a non-positive delay as "no delay". Kotlin attempt 55 measured -432,345,564,227,567,518ms; Go attempts 62-64 measured 18-58ms, which is the jitter alone. Trap Swift (UInt64 multiply) The process CRASHES. `<<` on an unsigned integer is a smart shift, so an over-shift quietly yields 0, but at `1 << 63` the multiply overflows UInt64 and traps. The red-proof run died with signal 5. Infinity TypeScript (Math.pow) `Math.pow(2, 1024)` is Infinity, and setTimeout clamps an out-of-range delay to 1ms. Unbounded integer Python (2 **), Ruby (2**) No wrap: the multiplier promotes to a bignum. Python raised OverflowError converting it to a float; Ruby produced Float::INFINITY, and sleep(Float::INFINITY) never returns. Three of the four shapes are the same failure: a client that has stopped backing off and is hammering a server already answering 429/503, which is the exact traffic pattern backoff exists to prevent. The fourth is a crash. All of it is reachable. Kotlin's builder validates maxRetries >= 0 with no upper bound; Go's WithMaxRetries only rejects n < 1. It needs a long genuine failure streak, so this is a robustness gap rather than a live incident — but "crash" and "tight-loop a struggling server" are bad enough consequences to close. SPEC §7 declared no delay ceiling at all, so six implementations were making six independent judgment calls about a number the spec never named. It now names one. MAX_BACKOFF_DELAY = 30,000ms, adopted rather than invented: Go's generated client has capped at RetryConfig.MaxDelay = 30s since that retry loop was first templated, so the constant describes shipping behavior. The new "Backoff Ceiling" subsection states the four requirements — saturate rather than wrap or diverge; the ceiling bounds the backoff term with jitter added on top; it applies to linear and constant backoff and therefore to base_delay_ms itself; Retry-After is server-directed and exempt — and tabulates the four overflow shapes so the next implementation knows what it is defending against. No shipped path changes. behavior-model.json tops out at base_delay_ms 2000 over at most three attempts, so the largest delay any operation computes is 4000ms and the ceiling is unreachable by default. Each SDK pins that explicitly: the exponential values below the ceiling are asserted exact. Every implementation compares the multiplier against MAX_BACKOFF_DELAY / base before multiplying, so no intermediate ever leaves the host's numeric range — the clamp is not a Math.min applied after the damage. TypeScript needed two sites. `services/base.ts` runs its own retry loop for the raw multipart transport and computed `Math.pow(2, attempt)` inline; both loops now share `saturatingBackoff`, so the ceiling cannot be honored on one path and not the other. Python needed two too — sync and async each carry a copy of _calculate_delay — and the test drives both. #567: Swift's Transport is public and documented as a seam, but both retry loops classified EVERY BasecampError out of the transport as terminal. A consumer normalizing connectivity failures into BasecampError.network — the most natural thing to reach for, and exactly the classification §6 defines for the condition with retryable: true — got one attempt with retries enabled, while a transport throwing an arbitrary foreign error retried normally. The more carefully an implementer read the error taxonomy, the more certainly they disabled their own retries. The catch could not simply be deleted: it also swallowed the response-type guard, which raised BasecampError.network("Invalid response type") for a non-HTTP URLResponse. That one is a deterministic programming error — retrying it three times just repeats it. So the guard now raises a private InvalidResponseTypeError and is converted back at every boundary, leaving the public error contract byte-for-byte unchanged, and only .network moves to the retry branch. Both loops changed together; splitting them would put two classifications of the same error inside one SDK, which is worse than the gap. On exhaustion the transport's own error surfaces instead of being re-wrapped in a second, vaguer .network that would discard the only diagnostic the caller has. The retry gate is unchanged: network-error retry still runs behind SPEC §7's three gates, so a non-idempotent POST is still attempted exactly once. Red proofs, all captured before the fix: Kotlin RetryTest#backoffDelaySaturatesInsteadOfOverflowing expected every delay in 30000..30100 ms, got attempt 53 -> 4503599627370496020ms, attempt 55 -> -432345564227567518ms, attempt 62 -> 48ms, attempt 2147483647 -> 64ms Go TestBackoffDelaySaturates backoffDelay(32) = 596523h14m8s; backoffDelay(63) = 58.219404ms; backoffDelay(1073741824) = 37.702901ms Swift BackoffCeilingTests — xctest exited with signal code 5 (SIGTRAP) partway through testExponentialBackoffSaturates, after reporting attempt 40 produced 549755813888000ms TS backoff ceiling > saturates the exponential term attempt 40 -> 1099511627776087.8ms, attempt 1024 -> Infinityms Python test_calculate_delay_saturates attempt 64 produced 9.223372036854776e+18s; attempt 2147483648 raised OverflowError: int too large to convert to float Ruby BackoffCeilingTest#test_calculate_delay_saturates_at_the_ceiling attempt 1024 -> 8.98846567431158e+307s, attempt 10000 -> Infinitys Swift TransportNetworkErrorRetryTests — 3 failures, both retry loops #567 giving exactly 1 attempt where 2 and 3 were expected The two-sided bound in each is deliberate. A one-sided "never too long" check passes on exactly the attempts that tight-loop: 1000 * (1L shl 63) wraps to precisely 0, which looks perfectly in range while being the bug itself. Verified green: go-check, kt-check, ts-check, py-check, rb-check, swift-check (319 tests, ran rather than printing its macOS SKIP line), all six conformance runners, check-retry-metadata-parity, check-idempotency-parity. Closes #577 Closes #567
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b67350b73d
ℹ️ 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".
There was a problem hiding this comment.
Pull request overview
This PR closes two issues by hardening the retry/backoff machinery shared across all six SDKs (Go, TypeScript, Ruby, Swift, Kotlin, Python):
- #577 — Backoff ceiling. The exponential backoff term
base × 2^nwas unbounded, and each language failed differently on a long failure streak: signed-64-bit wrap to a non-positive delay (Kotlin/Go tight-loop), aUInt64multiply trap that crashes the process (Swift),InfinitythatsetTimeoutclamps to 1ms (TypeScript), and bignum promotion toOverflowError/Float::INFINITY(Python/Ruby). The PR introduces aMAX_BACKOFF_DELAY = 30,000msceiling — adopted from Go's generatedRetryConfig.MaxDelay— implemented with a clamp before the multiply so no intermediate ever leaves the host's numeric range. SPEC §7 gains a new "Backoff Ceiling" subsection defining the constant and four requirements. - #567 — Swift Transport network errors. Both Swift retry loops classified every
BasecampErrorout of theTransportas terminal, so a consumer normalizing connectivity failures intoBasecampError.networkgot only one attempt. The catch is split so.networkroutes to the retry branch, a new privateInvalidResponseTypeErrorkeeps the non-HTTP-response guard terminal, and the transport's own error surfaces unwrapped on exhaustion.
Changes:
- Add a shared saturating-backoff helper to each SDK (clamp-before-multiply) and route both TypeScript retry loops through it.
- Swift: reclassify transport
.networkas retryable under the existing idempotency gate; introduce internalInvalidResponseTypeError. - Document
MAX_BACKOFF_DELAYand the four overflow shapes in SPEC §7; add red-proof tests in all six SDKs. No generated output changes; default paths never reach the ceiling.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
SPEC.md |
Adds "Backoff Ceiling" §7 subsection, MAX_BACKOFF_DELAY_MS to the formula/constants table, and Swift network-error classification wording. |
go/pkg/basecamp/http.go |
Adds MaxBackoffDelay = 30s constant. |
go/pkg/basecamp/client.go |
Extracts saturatingBackoff with clamp-before-multiply; backoffDelay delegates to it. |
go/pkg/basecamp/backoff_ceiling_test.go |
Pins saturation, exact sub-ceiling values, and base-above-ceiling / zero-base edges. |
kotlin/.../BasecampHttpClient.kt |
Adds MAX_BACKOFF_DELAY_MS and clamps calculateBackoffDelay (coerced shift, division guard). |
kotlin/.../RetryTest.kt |
Adds two-sided saturation test across overflow attempts. |
python/src/basecamp/config.py |
Adds MAX_BACKOFF_DELAY and module-level saturating_backoff. |
python/src/basecamp/_http.py, _async_http.py |
Both transports call saturating_backoff; Retry-After stays exempt. |
python/tests/test_backoff_ceiling.py |
Exercises both sync/async _calculate_delay plus helper edges. |
ruby/lib/basecamp/config.rb |
Adds MAX_BACKOFF_DELAY/MAX_BACKOFF_EXPONENT and self.saturating_backoff. |
ruby/lib/basecamp/http.rb |
calculate_delay uses Config.saturating_backoff. |
ruby/test/basecamp/backoff_ceiling_test.rb |
Saturation, sub-ceiling, Retry-After-exempt, and helper-edge tests. |
swift/.../HTTPClient.swift |
Adds maxBackoffDelayMs, backoffDelayMs (clamped), InvalidResponseTypeError, isTransportNetworkFailure; both retry loops reclassify .network. |
swift/Tests/.../BackoffCeilingTests.swift |
Saturation + zero/negative-attempt trap guards. |
swift/Tests/.../TransportNetworkErrorRetryTests.swift |
Retry-on-.network, exhaustion, idempotency gate, guard-stays-terminal, foreign-error control. |
typescript/src/retry.ts |
Adds MAX_BACKOFF_DELAY_MS and saturatingBackoff; calculateBackoffDelay reuses it. |
typescript/src/services/base.ts |
Multipart retry loop uses saturatingBackoff instead of inline Math.pow. |
typescript/tests/backoff-ceiling.test.ts |
Saturation, sub-ceiling exactness, linear/constant clamp, shared-term edges. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…g reachable from any base Two review findings on the #577/#567 work, one of them a regression this branch introduced. Cancellation through the wrapper (regression, Swift) Routing a transport-thrown BasecampError.network to the retry branch moved it past `catch let error as BasecampError`, where it had been terminal, and into the generic catch. isCancellation looked only at the outer error, so a Transport that normalized a cancelled request into .network(cause: CancellationError()) — the exact shape #567 exists to support — was no longer terminal: the loop fired onRetry, slept, re-authenticated and re-sent a request the caller had already cancelled. Raw URLError(.cancelled) stayed terminal, so one cancellation got two answers, which contradicts the premise of #567 that a network error is classified by meaning and not by type. isCancellation now walks the .network cause chain. The walk is bounded at 8 links rather than recursive: `cause` is caller-supplied and a cycle in it must not hang the retry loop. Red proof, against this branch's own un-fixed HTTPClient.swift: TransportNetworkErrorRetryTests.swift:348: error: -[BasecampTests.TransportNetworkErrorRetryTests testWrappedCancellationIsTerminalOnPerformRequest] : XCTAssertEqual failed: ("3") is not equal to ("1") - CancellationError: a cancelled request must not be re-sent TransportNetworkErrorRetryTests.swift:349: error: -[BasecampTests.TransportNetworkErrorRetryTests testWrappedCancellationIsTerminalOnPerformRequest] : XCTAssertEqual failed: ("2") is not equal to ("0") - CancellationError: onRetry must not fire for a cancellation TransportNetworkErrorRetryTests.swift:372: error: -[BasecampTests.TransportNetworkErrorRetryTests testWrappedCancellationIsTerminalOnTheDownloadHop] : XCTAssertEqual failed: ("3") is not equal to ("1") - URLError(.cancelled): a cancelled download must not be re-sent The download hop took 6.4s in that run: it really slept its backoff and really re-sent. Both loops are pinned, in both cause shapes, plus a control proving a non-cancellation cause still retries so the guard does not quietly reopen #567. The uniformity claim was false Go, Kotlin and Swift hard-saturate: their base delay is an integer duration, so `multiplier > MAX / base` always fires before their shift cap does. Python, Ruby and TypeScript instead capped the exponent at a fixed constant (64/64/53) and took min(base * 2**capped, CEILING). That bounds the intermediate but not the outcome — for a small enough base the capped product never reaches the ceiling and the term plateaus below it forever. Captured from this branch's own un-fixed config.py: MAX_BACKOFF_DELAY = 30.0 saturating_backoff(1e-30, 64) = 9.223372036854777e-12 saturating_backoff(1e-30, 65) = 1.8446744073709553e-11 saturating_backoff(1e-30, 66) = 1.8446744073709553e-11 saturating_backoff(1e-30, 105) = 1.8446744073709553e-11 saturating_backoff(1e-30, 1100) = 1.8446744073709553e-11 saturating_backoff(1e-30, 2147483648) = 1.8446744073709553e-11 ~18 picoseconds, forever: SPEC §7 requirement 1's tight loop, introduced by the very change meant to prevent it. Un-fixed, the term kept growing and reached a sane delay around attempt 105, so this plateau is new. It is reachable — Python validates only base_delay >= 0, and Ruby's validate! has no base_delay check at all. All three now derive the bound from the configured base: the smallest exponent e with base * 2**e >= CEILING, compared before the power is evaluated. The fixed cap survives only as a numeric-range backstop, raised to 1023 (the largest exponent whose power is a finite double) so it can never be the operative bound for a base delay expressible as a duration. SPEC §7 requirement 1 said "clamp the exponent, or compare the multiplier ... either keeps every intermediate inside the host's numeric range". That either/or is the false equivalence. It now says to compare before multiplying, and spells out why a fixed exponent cap is not a substitute. Disclosed: a hooks-lifecycle change Moving a transport-thrown .network into the generic catch also means the attempt is finalized: onRequestEnd fires with statusCode 0, where the old BasecampError catch emitted no request-end event and said so in a comment. That is intended — the error now means "the attempt failed at the transport", the same as a raw URLError, so it is reported the same way and start/end stay paired for every attempt the loop begins. It was previously undisclosed and unpinned. Against the pre-PR client the new test reports: XCTAssertEqual failed: ("[]") is not equal to ("[0, 0, 0]") - a transport failure is reported as status 0, like a raw URLError Pinned in both directions: .network gains the event, every other BasecampError still emits none. Verified, each REAL_EXIT written into its log and grepped back: make py-check 0 (878 passed) | make rb-check 0 (1087 runs, 0 failures; 147 files, no offenses) | make ts-check 0 (78 files, 1134 tests) | make swift-check 0 (325 tests executed) | make go-check 0 | make kt-check 0 | make conformance 0 (all six runners) | make check-retry-metadata-parity 0 | make check-idempotency-parity 0 Kotlin and Go were restored byte-identical to the previous commit and re-run with the caches forced off (gradle --rerun, go test -count=1) rather than quoted from a cache hit.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b5726c6555
ℹ️ 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".
Round-2 replaced the fixed exponent cap with a bound derived from the configured base, computed as MAX_BACKOFF_DELAY / base_delay. That ratio is itself infinite once base_delay drops below MAX_BACKOFF_DELAY / MAX_FLOAT (~1.67e-307s, ~1.67e-304ms), and the fixed-1023 fallback that backstopped it then failed in the mirror direction of the plateau it replaced: the term saturates EARLY. At base_delay=1e-307 the specified term is ~8.99s at attempt 1024 and ~17.98s at 1025; both returned the 30s ceiling — a sleep more than three times longer than the formula asks for, decided by a numeric backstop rather than by the ceiling. TypeScript was worse: base 1e-305 returns 30000ms where the term is ~899ms, 33x. The round-2 tests could not catch it because monotonicity and eventual saturation both hold for a formula that saturates early. The bound now comes from log2(ceiling) - log2(base), which has no cliff, and the term is scaled directly — math.ldexp in Python and Ruby, bounded repeated multiplication in TypeScript, which has no ldexp and whose Math.pow(2, e) is Infinity past 1023 even when the product is an ordinary number. The numeric backstop is therefore deleted outright rather than merely made rarer: _MAX_BACKOFF_EXPONENT, MAX_BACKOFF_EXPONENT and the TS const are gone. Red proof, new tests against this branch's own b5726c6 sources: Python E assert 30.0 == 8.988465674311579 E + where 30.0 = saturating_backoff(1e-307, 1024) Ruby Expected: 8.988465674311579 Actual: 30.0 TS AssertionError: expected 30000 to be 898.846567431158 // Object.is equality The TS saturation probe moved from attempt 1023 to 1089 — the exact saturating exponent of the smallest denormal. Probing at 1023 asserted the ceiling at an attempt where 5e-324 * 2**1023 is genuinely ~4.4e-16ms, a bound the old fallback met only by saturating early. SPEC §7 requirement 1 gains the rule this violated: the bound must be derived without overflowing its own arithmetic, and saturating early is as much a deviation as plateauing. Verified, each REAL_EXIT written into its log and grepped back: make py-check 0 (879 passed) | make rb-check 0 (1088 runs, 0 failures; 147 files, no offenses) | make ts-check 0 (78 files, 1135 tests) | make conformance 0 (all six runners) | make check-retry-metadata-parity 0 | make check-idempotency-parity 0 | make sync-spec-version-check 0 | make sync-api-version-check 0 Go, Kotlin and Swift are untouched: their base delay is an integer duration, so the multiplier comparison always fires before any exponent bound does.
|
@codex review |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
Round 3 pushed as Codex's follow-up P2 on The bound now comes from Also corrected in the description, all evidence-fidelity rather than code:
CI: all real checks pass on |
Closes #577. Closes #567.
#577 is not the Kotlin bug it was filed as
It is six SDKs, in three distinct shapes, and the Swift one is strictly the worst and was tracked nowhere.
1L shl), Go (1<<)UInt64multiply)<<on an unsigned integer is a smart shift so an over-shift quietly yields0, but at1 << 63the multiply overflowsUInt64and trapsMath.pow)Math.pow(2, 1024)isInfinity, andsetTimeoutclamps an out-of-range delay to 1ms2 **), Ruby (2**)OverflowErrorconverting it to a float; Ruby coerces toFloat::INFINITY, andsleep(Float::INFINITY)never returnsThree of the four shapes are the same failure: a client that has stopped backing off and is hammering a server already answering 429/503 — the exact traffic pattern backoff exists to prevent. The fourth is a crash.
All reachable. Kotlin's builder validates
maxRetries >= 0with no upper bound; Go'sWithMaxRetriesonly rejectsn < 1. It needs a long genuine failure streak, so this is a robustness gap rather than a live incident — but "crash" and "tight-loop a struggling server" are consequences bad enough to close.The Swift trap is not in any issue. #577 mentions "Swift already had a related
UInt64trap fixed on #563" — that was theRetry-After→ nanoseconds conversion. The multiply incalculateDelayis a separate, live trap, and it is the one that crashes.SPEC §7 declared no ceiling at all
Six implementations were each making an independent judgment call about a number the spec never named. It names one now.
MAX_BACKOFF_DELAY = 30,000ms, adopted rather than invented: Go's generated client has capped atRetryConfig.MaxDelay = 30ssince that retry loop was first templated, so the constant describes shipping behavior rather than a fresh opinion.The "Backoff Ceiling" subsection states four requirements — saturate rather than wrap or diverge; the ceiling bounds the backoff term with jitter added on top; it applies to
linearandconstanttoo, and therefore tobase_delay_msitself;Retry-Afteris server-directed and exempt — and tabulates the four overflow shapes so the next implementation knows what it is defending against.No shipped path changes.
behavior-model.jsontops out atbase_delay_ms: 2000over at most three attempts, so the largest delay any operation computes is 4000ms and the ceiling is unreachable by default. Each SDK pins that explicitly: the exponential values below the ceiling are asserted exact.How the clamp is done, and why the first round got it wrong
Every implementation compares against
MAX_BACKOFF_DELAY / base_delaybefore multiplying, so no intermediate ever leaves the host's numeric range — the clamp is not amin()applied after the damage is done.Round 1 claimed that of all six. It was true of three. Go, Kotlin and Swift take an integer base delay, so the
multiplier > MAX / basecomparison always fires long before their shift cap does; they genuinely saturate. Python, Ruby and TypeScript take a float base and instead capped the exponent at a fixed constant — 64, 64 and 53 — then tookmin(base × 2^capped, CEILING). That bounds the intermediate but not the outcome: for a base small enough thatbase × 2^cap < CEILING, the term plateaus below the ceiling forever instead of saturating at it.Captured from this branch's own round-1
config.py:~18 picoseconds, forever — SPEC §7 requirement 1's tight loop, introduced by the change meant to prevent it. Un-fixed, the term kept growing and reached a sane delay around attempt 105, so the plateau was new. And it is reachable: Python validates only
base_delay >= 0, and Ruby'svalidate!has nobase_delaycheck at all.All three now derive the bound from the configured base — the smallest exponent
ewithbase × 2^e >= CEILING, compared before the power is evaluated. The fixed cap survives only as a numeric-range backstop, raised to 1023 (the largest exponent whose power is a finite double) so it can never be the operative bound for a base delay expressible as a duration.SPEC §7 requirement 1 previously read "Clamp the exponent, or compare the multiplier … either keeps every intermediate inside the host's numeric range". That either/or is the false equivalence that licensed the plateau. It now says to compare before multiplying, and spells out why a fixed exponent cap is not a substitute.
Two SDKs needed two sites each. TypeScript's
services/base.tsruns its own retry loop for the raw multipart transport and computedMath.pow(2, attempt)inline; both loops now sharesaturatingBackoff. Python's sync and async transports each carry a copy of_calculate_delay; the test drives both.#567: a Swift Transport's own network error
Transportispublicand documented as a testability seam, but both retry loops classified everyBasecampErrorout of it as terminal. A consumer normalizing connectivity failures intoBasecampError.network— the most natural thing to reach for, and exactly the classification §6 defines for the condition withretryable: true— got one attempt with retries enabled, while a transport throwing an arbitrary foreign error retried normally. The more carefully an implementer read the error taxonomy, the more certainly they disabled their own retries.The catch could not simply be deleted, as #567 notes: it also swallowed the response-type guard, which raised
BasecampError.network("Invalid response type")for a non-HTTPURLResponse. That is a deterministic programming error — retrying it three times just repeats it. So the guard now raises a privateInvalidResponseTypeErrorand is converted back at every boundary, leaving the public error contract byte-for-byte unchanged, and only.networkmoves to the retry branch.Both loops changed together. Splitting them would put two classifications of the same error inside one SDK, which is worse than the gap. On exhaustion the transport's own error surfaces instead of being re-wrapped in a second, vaguer
.networkthat would discard the only diagnostic the caller has.The retry gate is unchanged: network-error retry still runs behind SPEC §7's three gates, so a non-idempotent POST is still attempted exactly once. That is pinned.
Cancellation has to come through the wrapper too
Moving
.networkpastcatch let error as BasecampError— where it had been terminal — and into the generic catch had a consequence round 1 missed.isCancellationinspected only the outer error, so aTransportthat normalized a cancelled request into.network(cause: CancellationError())was no longer terminal: the loop firedonRetry, slept, re-authenticated and re-sent a request the caller had already cancelled. RawURLError(.cancelled)stayed terminal, so one cancellation got two answers — precisely the classify-by-type reasoning #567 exists to remove.isCancellationnow walks the.networkcause chain, bounded at 8 links rather than recursive:causeis caller-supplied and a cycle in it must not hang the retry loop.Disclosed: a hooks-lifecycle change
Routing a transport-thrown
.networkinto the generic catch also means the attempt is finalized —onRequestEnd(info, result: RequestResult(statusCode: 0, …))— where the oldBasecampErrorcatch emitted no request-end event and said so in a comment.That is intended, not incidental: the error now means "the attempt failed at the transport", the same as a raw
URLError, so it is reported the same way andonRequestStart/onRequestEndstay paired for every attempt the loop begins. Round 1 neither disclosed it nor pinned it. It is now pinned in both directions —.networkgains the event, every otherBasecampErrorstill emits none.Red proofs
Every one below is literal runner output, captured against un-fixed code and pasted from the runner's own log file rather than retyped. Where a line is elided it is marked
[...]and the elision is explained.Jitter and wall-clock timings are random per run, so those figures differ between rounds. The Kotlin, Go, Swift #567 and plateau blocks carry round-3 numbers (re-run this round); the Swift #577 SIGTRAP block and the Python, Ruby and TypeScript #577 blocks carry round-2 numbers and were not re-run. Each block says which sources it ran against.
Kotlin —
RetryTest#backoffDelaySaturatesInsteadOfOverflowing, fromkotlin/sdk/build/test-results/jvmTest/TEST-com.basecamp.sdk.RetryTest.xml(single line in the XML, wrapped here). Reproduced by reverting the body ofcalculateBackoffDelaytobaseDelayMs * (1L shl (attempt - 1))while KEEPINGMAX_BACKOFF_DELAY_MS, which the new test references — a wholesale revert to5efc52f0does not compile:Attempt 55 is negative —
delay()returns immediately. Attempts 62–64 and 128 are jitter alone. Attempt 65 has collapsed back to the base value, because1L shl 64wraps the shift itself to 1.Go —
TestBackoffDelaySaturates, fromcd go && go test -count=1 -run TestBackoffDelaySaturates ./pkg/basecamp/against the pre-PRclient.go(git show 5efc52f0:go/pkg/basecamp/client.go),http.gokept so the new test'sMaxBackoffDelaystill resolves:(Round 2 printed this block with the
--- FAIL:header moved below the indented lines and one trailingFAILdropped.go testemits the header first; the block above is the file, unedited.)Swift #577 —
BackoffCeilingTests. The process died:and, from the same run (
[...]elides the absolute path to thexctestbinary and its-XCTesttest list):Signal 5 is SIGTRAP. The suite never reached the remaining assertions because attempt 62 killed the process.
TypeScript —
backoff ceiling > saturates the exponential term instead of running away:setTimeoutclamps each of thoseInfinityvalues to 1ms.Python —
test_calculate_delay_saturates. Both transports carry their own copy of_calculate_delay, so both are driven; the assertion lines below are every one the runemitted, in order:
Attempts 10,000 and 2,147,483,648 are the two
OverflowErrors — the delay is not merely wrong,the retry aborts with an exception the caller never sees documented. (
15 passedrather thanround 1's
3 passedbecause this run includes the round-2 plateau tests, which pass against theun-fixed formula: it had no plateau.)
Ruby —
BackoffCeilingTest#test_calculate_delay_saturates_at_the_ceilingsleep(Float::INFINITY)never returns.The plateau (round 2), against this branch's own round-1 code
Ruby — the failure names every base and attempt at once:
TypeScript — same shape, 16 entries (
[...]elides the 12 for bases1e-100,1e-300and5e-324, which differ only in magnitude):Python —
8 failed, 14 passed, e.g.:Swift #567 and the cancellation regression
Against the pre-PR client — the branch base
5efc52f0, i.e.HEAD~2now, which round 2 mislabelledHEAD~1— the wholeTransportNetworkErrorRetryTestsfile reports (reproducing it also requires removingBackoffCeilingTests.swift, which referencesHTTPClient.backoffDelayMsand so cannot compile against the pre-PR source):failing, by name:
The two
testWrappedCancellation…tests pass there — which is the point: pre-PR,.networkwas terminal, so wrapped cancellation was already correct. The regression is this PR's. Against round 1's client:(
[...]elides the repeatedBasecampTests.TransportNetworkErrorRetryTestsclass prefix inside each bracket.) Three attempts where one is required, andonRetryfired twice. The download hop took 6.355 seconds — it really slept its backoff and really re-sent.The lifecycle change, from the pre-PR client:
Why every bound is two-sided
A one-sided "never too long" check passes on exactly the attempts that tight-loop.
1000 * (1L shl 63)wraps to precisely0, which looks perfectly in range while being the bug itself. So once the exponential term is past the ceiling, the assertion is that the delay sits at the ceiling — 0ms and a collapsed 1000ms both fail. The plateau tests are the same discipline one level up: they assert the term reaches the ceiling, which amin(…, CEILING)alone does not guarantee.Verification
Every
REAL_EXIT=$?was written into its log and grepped back — not read off a mid-run "all passed" line, and not taken fromecho $?after a pipe.make go-checkmake kt-checkmake ts-checkmake py-checkmake rb-checkmake swift-checkmake conformancemake check-retry-metadata-paritymake check-idempotency-parityKotlin and Go are byte-identical to
b67350b7on this branch (git diff --stat HEAD -- kotlin/ go/is empty), and both were re-run with their caches forced off —./gradlew :basecamp-sdk:jvmTest --rerun(7m21s,> Task :basecamp-sdk:jvmTest, notFROM-CACHE) andgo test -count=1 ./...— rather than quoted from a cache hit.What changed in round 2
isCancellationnever inspects the.networkcause — a cancelled request is announced, slept on, re-authenticated and re-sent1e-3to5e-324)onRequestEndwithstatusCode: 0)TEST-com.basecamp.sdk.RetryTest.xmland the equivalent runner logs. The Swift, Python and Ruby proofs were re-captured too rather than carried over. Every elision is now marked[...]and explainedArgued rather than changed
validate!still has nobase_delaycheck, and Python's still accepts anybase_delay >= 0. Those were cited as reachability evidence for the plateau, and adding validation would not have closed it —1e-30is>= 0, so Python accepted it too. The fix had to be in the formula, and is. Tightening the two validators to match each other is a real parity gap but an orthogonal one; it is not in this PR.linearbackoff still cannot reach the ceiling for a base below ~3.3e-12ms. Linear growth isbase × nwithnan attempt count, so reaching 30,000ms needsn >= 30000 / base; below that base no finite attempt count qualifies. That is arithmetic, not a clamp defect — Swift is only immune because its base is an integer millisecond count. The test asserts saturation exactly where a linear term can reach it, and asserts the ceiling is never exceeded for every base.What changed in round 3
MAX_BACKOFF_DELAY / base, and that ratio itself overflows below ~1.67e-307. The fixed-1023 fallback then saturates early —1e-307returns the 30s ceiling at attempt 1024 where the specified term is ~8.99s; TypeScript returns 30000ms where the term is ~899ms, 33xlog2(ceiling) - log2(base); term scaled withldexp(Python/Ruby) or bounded repeated multiplication (TypeScript, which has noldexp). The numeric backstop is deleted, not made rarer. One new test per SDK pinning the exact intermediate products--- FAIL:moved below the indented lines and one trailingFAILdroppedHEAD~1, which is round 1, not pre-PR5efc52f0, and the required removal ofBackoffCeilingTests.swiftdisclosedRe-run from scratch this round and pasted from their log files: Kotlin, Go, the three
plateau proofs (Python/Ruby/TypeScript), and all three Swift #567 blocks — cancellation,
pre-PR, and lifecycle. Their jitter and wall-clock figures therefore differ from round 2's,
because they are different runs; the plateau numbers are deterministic and reproduced
byte-for-byte.
Carried over unchanged from round 2, and not re-run this round: the Swift #577 SIGTRAP
block and the Python, Ruby and TypeScript #577 blocks. They are round-2 captures and
are labelled as such rather than presented as fresh.
Argued rather than changed (round 3)
Configstill acceptsbase_delay=1e-307, and Ruby'svalidate!still has nobase_delaycheck. Same disposition as round 2: these are reachability evidence, not the defect. The formula now tracks the specified term for every positive base, so no validator change is needed to close this finding. Tightening the two validators to match each other remains a real but orthogonal parity gap.Not touched
spec/basecamp.smithy,openapi.json, and SPEC §19 — no generated output changed, somake generatewas not run and the three timestamp-churn files are untouched.