Skip to content

Give the backoff formula a ceiling in all six SDKs, and retry a Swift Transport's own network error - #592

Merged
jeremy merged 3 commits into
mainfrom
fix/backoff-ceiling-and-swift-network-retry
Aug 3, 2026
Merged

jeremy merged 3 commits into
mainfrom
fix/backoff-ceiling-and-swift-network-retry

Conversation

@jeremy

@jeremy jeremy commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Closes #577. Closes #567.

Review round 3 (383a6fe1). The round-2 exponent bound had its own overflow and
saturated early; fixed, with the numeric backstop now deleted outright. The round-2 Go
proof was re-captured after being found reordered, and two undisclosed reproduction steps
are now stated. See What changed in round 3.

Review round 2 (b5726c65). Two open P2 threads closed, one of them a regression this
PR introduced, plus an undisclosed behavior change now disclosed and pinned. All red proofs
below were re-captured from the runners' own output — the first round's Kotlin, Go, TypeScript
Swift, Python and Ruby quotes were trimmed or re-formatted while presented as verbatim. See
What changed in round 2.

#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.

Shape SDKs What actually happens
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"
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
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 raises OverflowError converting it to a float; Ruby coerces to 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 — the exact traffic pattern backoff exists to prevent. The fourth is a crash.

All 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 consequences bad enough to close.

The Swift trap is not in any issue. #577 mentions "Swift already had a related UInt64 trap fixed on #563" — that was the Retry-After → nanoseconds conversion. The multiply in calculateDelay is 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 at RetryConfig.MaxDelay = 30s since 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 linear and constant too, 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.

How the clamp is done, and why the first round got it wrong

Every implementation compares against MAX_BACKOFF_DELAY / base_delay before multiplying, so no intermediate ever leaves the host's numeric range — the clamp is not a min() 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 / base comparison 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 took min(base × 2^capped, CEILING). That bounds the intermediate but not the outcome: for a base small enough that base × 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:

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 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'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 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.ts runs its own retry loop for the raw multipart transport and computed Math.pow(2, attempt) inline; both loops now share saturatingBackoff. 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

Transport is public and documented as a testability seam, but both retry loops classified every BasecampError out of it 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, as #567 notes: it also swallowed the response-type guard, which raised BasecampError.network("Invalid response type") for a non-HTTP URLResponse. That 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. That is pinned.

Cancellation has to come through the wrapper too

Moving .network past catch let error as BasecampError — where it had been terminal — and into the generic catch had a consequence round 1 missed. isCancellation inspected only the outer error, so a Transport that normalized a cancelled request into .network(cause: CancellationError()) 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 — precisely the classify-by-type reasoning #567 exists to remove.

isCancellation now walks the .network cause chain, bounded at 8 links rather than recursive: cause is caller-supplied and a cycle in it must not hang the retry loop.

Disclosed: a hooks-lifecycle change

Routing a transport-thrown .network into the generic catch also means the attempt is finalized — onRequestEnd(info, result: RequestResult(statusCode: 0, …)) — where the old BasecampError catch 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 and onRequestStart/onRequestEnd stay paired for every attempt the loop begins. Round 1 neither disclosed it nor pinned it. It is now pinned in both directions — .network gains the event, every other BasecampError still 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, from kotlin/sdk/build/test-results/jvmTest/TEST-com.basecamp.sdk.RetryTest.xml (single line in the XML, wrapped here). Reproduced by reverting the body of calculateBackoffDelay to baseDelayMs * (1L shl (attempt - 1)) while KEEPING MAX_BACKOFF_DELAY_MS, which the new test references — a wholesale revert to 5efc52f0 does not compile:

java.lang.AssertionError: expected every delay in 30000..30100 ms, got attempt 6 -> 32055ms, attempt 10 -> 512036ms, 
attempt 53 -> 4503599627370496016ms, attempt 54 -> 9007199254740992046ms, attempt 55 -> -432345564227567573ms, 
attempt 62 -> 16ms, attempt 63 -> 60ms, attempt 64 -> 31ms, 
attempt 65 -> 1034ms, attempt 128 -> 52ms, attempt 2147483647 -> 55ms
	at com.basecamp.sdk.RetryTest.backoffDelaySaturatesInsteadOfOverflowing(RetryTest.kt:73)

Attempt 55 is negative — delay() returns immediately. Attempts 62–64 and 128 are jitter alone. Attempt 65 has collapsed back to the base value, because 1L shl 64 wraps the shift itself to 1.

Go — TestBackoffDelaySaturates, from cd go && go test -count=1 -run TestBackoffDelaySaturates ./pkg/basecamp/ against the pre-PR client.go (git show 5efc52f0:go/pkg/basecamp/client.go), http.go kept so the new test's MaxBackoffDelay still resolves:

--- FAIL: TestBackoffDelaySaturates (0.00s)
    backoff_ceiling_test.go:31: backoffDelay(6) = 32.048197034s, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(10) = 8m32.000807907s, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(32) = 596523h14m8.070580213s, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(33) = 1193046h28m16.044978153s, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(62) = 96.303837ms, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(63) = 36.090233ms, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(64) = 27.516297ms, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(65) = 43.494194ms, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(128) = 45.44507ms, want within [30s, 30.1s]
    backoff_ceiling_test.go:31: backoffDelay(1073741824) = 90.638611ms, want within [30s, 30.1s]
FAIL
FAIL	github.com/basecamp/basecamp-sdk/go/pkg/basecamp	0.241s
FAIL

(Round 2 printed this block with the --- FAIL: header moved below the indented lines and one trailing FAIL dropped. go test emits the header first; the block above is the file, unedited.)

Swift #577 — BackoffCeilingTests. The process died:

Test Case '-[BasecampTests.BackoffCeilingTests testDelaysBelowTheCeilingAreExact]' started.
Test Case '-[BasecampTests.BackoffCeilingTests testDelaysBelowTheCeilingAreExact]' passed (0.001 seconds).
Test Case '-[BasecampTests.BackoffCeilingTests testExponentialBackoffSaturates]' started.
BackoffCeilingTests.swift:26: error: -[BasecampTests.BackoffCeilingTests testExponentialBackoffSaturates] : XCTAssertEqual failed: ("32000") is not equal to ("30000") - attempt 6 produced 32000ms
BackoffCeilingTests.swift:26: error: -[BasecampTests.BackoffCeilingTests testExponentialBackoffSaturates] : XCTAssertEqual failed: ("512000") is not equal to ("30000") - attempt 10 produced 512000ms
BackoffCeilingTests.swift:26: error: -[BasecampTests.BackoffCeilingTests testExponentialBackoffSaturates] : XCTAssertEqual failed: ("549755813888000") is not equal to ("30000") - attempt 40 produced 549755813888000ms

and, from the same run ([...] elides the absolute path to the xctest binary and its -XCTest test list):

error: Process '/Applications/Xcode-beta.app/Contents/Developer/usr/bin/xctest -XCTest [...] BasecampTests.xctest' exited with unexpected signal code 5

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:

+   "attempt 5 -> 32049.44683992746ms",
+   "attempt 10 -> 1024027.9309143878ms",
+   "attempt 40 -> 1099511627776016.8ms",
+   "attempt 100 -> 1.2676506002282294e+33ms",
+   "attempt 1023 -> Infinityms",
+   "attempt 1024 -> Infinityms",
+   "attempt 1025 -> Infinityms",
+   "attempt 9007199254740991 -> Infinityms",

setTimeout clamps each of those Infinity values 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 run
emitted, in order:

E           AssertionError: HttpClient: attempt 6 produced 32.093675600908675s
E           AssertionError: HttpClient: attempt 10 produced 512.0987787004146s
E           AssertionError: HttpClient: attempt 64 produced 9.223372036854776e+18s
E           AssertionError: HttpClient: attempt 128 produced 1.7014118346046923e+38s
E           AssertionError: HttpClient: attempt 1024 produced 8.98846567431158e+307s
E       OverflowError: int too large to convert to float
src/basecamp/_http.py:376: OverflowError
E       OverflowError: int too large to convert to float
src/basecamp/_http.py:376: OverflowError
7 failed, 15 passed in 83.21s (0:01:23)

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 passed rather than
round 1's 3 passed because this run includes the round-2 plateau tests, which pass against the
un-fixed formula: it had no plateau.)

Ruby — BackoffCeilingTest#test_calculate_delay_saturates_at_the_ceiling

  1) Failure:
BackoffCeilingTest#test_calculate_delay_saturates_at_the_ceiling [test/basecamp/backoff_ceiling_test.rb:35]:
every backoff must land within 30.0..30.1s.
Expected ["attempt 6 -> 32.075309102877455s", "attempt 10 -> 512.0862119375181s", "attempt 64 -> 9.223372036854776e+18s", "attempt 128 -> 1.7014118346046923e+38s", "attempt 1024 -> 8.98846567431158e+307s", "attempt 10000 -> Infinitys", "attempt 2147483648 -> Infinitys"] to be empty.
9 runs, 26426 assertions, 1 failures, 0 errors, 0 skips

sleep(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:

  1) Failure:
BackoffCeilingTest#test_saturating_backoff_reaches_the_ceiling_for_any_positive_base [test/basecamp/backoff_ceiling_test.rb:86]:
the term must saturate at the ceiling, not plateau below it.
Expected ["base_delay=1.0e-30 attempt=1100 -> 1.8446744073709553e-11s", "base_delay=1.0e-30 attempt=5000 -> 1.8446744073709553e-11s", "base_delay=1.0e-30 attempt=2147483648 -> 1.8446744073709553e-11s", "base_delay=1.0e-100 attempt=1100 -> 1.8446744073709552e-81s", "base_delay=1.0e-100 attempt=5000 -> 1.8446744073709552e-81s", "base_delay=1.0e-100 attempt=2147483648 -> 1.8446744073709552e-81s", "base_delay=1.0e-300 attempt=1100 -> 1.8446744073709552e-281s", "base_delay=1.0e-300 attempt=5000 -> 1.8446744073709552e-281s", "base_delay=1.0e-300 attempt=2147483648 -> 1.8446744073709552e-281s", "base_delay=5.0e-324 attempt=1100 -> 9.113902524445497e-305s", "base_delay=5.0e-324 attempt=5000 -> 9.113902524445497e-305s", "base_delay=5.0e-324 attempt=2147483648 -> 9.113902524445497e-305s"] to be empty.

  2) Failure:
BackoffCeilingTest#test_saturating_backoff_is_monotonic_up_to_the_ceiling [test/basecamp/backoff_ceiling_test.rb:106]:
base_delay=1.0e-30 never reached the ceiling.
Expected: 30.0
  Actual: 1.8446744073709553e-11

TypeScript — same shape, 16 entries ([...] elides the 12 for bases 1e-100, 1e-300 and 5e-324, which differ only in magnitude):

+   "base=1e-30 attempt=1023 -> 9.007199254740993e-15ms",
+   "base=1e-30 attempt=1100 -> 9.007199254740993e-15ms",
+   "base=1e-30 attempt=5000 -> 9.007199254740993e-15ms",
+   "base=1e-30 attempt=9007199254740991 -> 9.007199254740993e-15ms",
[...]
AssertionError: base=1e-30 never reached the ceiling: expected 9.007199254740993e-15 to be 30000 // Object.is equality

Python — 8 failed, 14 passed, e.g.:

E           AssertionError: base_delay=1e-30 attempt=1100 plateaued below the ceiling

Swift #567 and the cancellation regression

Against the pre-PR client — the branch base 5efc52f0, i.e. HEAD~2 now, which round 2 mislabelled HEAD~1 — the whole TransportNetworkErrorRetryTests file reports (reproducing it also requires removing BackoffCeilingTests.swift, which references HTTPClient.backoffDelayMs and so cannot compile against the pre-PR source):

	 Executed 14 tests, with 8 failures (3 unexpected) in 2.007 (2.010) seconds

failing, by name:

testDownloadHopRetriesTransportBasecampNetworkError
testTransportBasecampNetworkErrorIsRetried
testTransportBasecampNetworkErrorSurfacesUnwrappedOnExhaustion
testTransportNetworkErrorFinalizesEachAttempt
testWrappedNonCancellationCauseStillRetries

The two testWrappedCancellation… tests pass there — which is the point: pre-PR, .network was terminal, so wrapped cancellation was already correct. The regression is this PR's. Against round 1's client:

TransportNetworkErrorRetryTests.swift:348: error: -[...testWrappedCancellationIsTerminalOnPerformRequest] : XCTAssertEqual failed: ("3") is not equal to ("1") - CancellationError: a cancelled request must not be re-sent
TransportNetworkErrorRetryTests.swift:349: error: -[...testWrappedCancellationIsTerminalOnPerformRequest] : XCTAssertEqual failed: ("2") is not equal to ("0") - CancellationError: onRetry must not fire for a cancellation
TransportNetworkErrorRetryTests.swift:372: error: -[...testWrappedCancellationIsTerminalOnTheDownloadHop] : XCTAssertEqual failed: ("3") is not equal to ("1") - CancellationError: a cancelled download must not be re-sent
TransportNetworkErrorRetryTests.swift:373: error: -[...testWrappedCancellationIsTerminalOnTheDownloadHop] : XCTAssertEqual failed: ("2") is not equal to ("0") - CancellationError: onRetry must not fire for a cancellation
TransportNetworkErrorRetryTests.swift:372: error: -[...testWrappedCancellationIsTerminalOnTheDownloadHop] : XCTAssertEqual failed: ("3") is not equal to ("1") - URLError(.cancelled): a cancelled download must not be re-sent
TransportNetworkErrorRetryTests.swift:373: error: -[...testWrappedCancellationIsTerminalOnTheDownloadHop] : XCTAssertEqual failed: ("2") is not equal to ("0") - URLError(.cancelled): onRetry must not fire for a cancellation
Test Case '-[BasecampTests.TransportNetworkErrorRetryTests testWrappedCancellationIsTerminalOnTheDownloadHop]' failed (6.355 seconds).

([...] elides the repeated BasecampTests.TransportNetworkErrorRetryTests class prefix inside each bracket.) Three attempts where one is required, and onRetry fired 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:

TransportNetworkErrorRetryTests.swift:466: error: -[...testTransportNetworkErrorFinalizesEachAttempt] : XCTAssertEqual failed: ("0") is not equal to ("3") - every attempt the loop begins is finalized
TransportNetworkErrorRetryTests.swift:467: error: -[...testTransportNetworkErrorFinalizesEachAttempt] : XCTAssertEqual failed: ("[]") is not equal to ("[0, 0, 0]") - a transport failure is reported as status 0, like a raw URLError

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 precisely 0, 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 a min(…, 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 from echo $? after a pipe.

Gate Result
make go-check exit 0
make kt-check exit 0
make ts-check exit 0 — 78 files, 1135 tests
make py-check exit 0 — 879 passed
make rb-check exit 0 — 1088 runs, 0 failures; 147 files, no offenses
make swift-check exit 0 — 325 tests executed, not the macOS SKIP line
make conformance exit 0 — all six runners ran (Go, Kotlin, TypeScript, Ruby, Python, Swift)
make check-retry-metadata-parity exit 0
make check-idempotency-parity exit 0

Kotlin and Go are byte-identical to b67350b7 on 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, not FROM-CACHE) and go test -count=1 ./... — rather than quoted from a cache hit.

What changed in round 2

Finding Disposition
isCancellation never inspects the .network cause — a cancelled request is announced, slept on, re-authenticated and re-sent Fixed. Bounded cause-chain walk. Four new tests across both retry loops and both cause shapes, plus a non-cancellation control. Red proof above
The uniformity claim is false: Python/Ruby/TS plateau below the ceiling for a small base Fixed in all three, by deriving the exponent bound from the configured base. SPEC §7 requirement 1 rewritten to stop licensing a fixed cap. Two new tests per SDK (saturation + monotonicity across six bases from 1e-3 to 5e-324)
Undisclosed hooks-lifecycle change (onRequestEnd with statusCode: 0) Disclosed above and pinned, in both directions
Kotlin/Go/TS red proofs trimmed while presented as verbatim Re-captured from TEST-com.basecamp.sdk.RetryTest.xml and the equivalent runner logs. The Swift, Python and Ruby proofs were re-captured too rather than carried over. Every elision is now marked [...] and explained

Argued rather than changed

  • Ruby's validate! still has no base_delay check, and Python's still accepts any base_delay >= 0. Those were cited as reachability evidence for the plateau, and adding validation would not have closed it — 1e-30 is >= 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.
  • TypeScript linear backoff still cannot reach the ceiling for a base below ~3.3e-12ms. Linear growth is base × n with n an attempt count, so reaching 30,000ms needs n >= 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

Finding Disposition
The round-2 exponent bound is computed as MAX_BACKOFF_DELAY / base, and that ratio itself overflows below ~1.67e-307. The fixed-1023 fallback then saturates early — 1e-307 returns the 30s ceiling at attempt 1024 where the specified term is ~8.99s; TypeScript returns 30000ms where the term is ~899ms, 33x Fixed in all three. Bound now derived from log2(ceiling) - log2(base); term scaled with ldexp (Python/Ruby) or bounded repeated multiplication (TypeScript, which has no ldexp). The numeric backstop is deleted, not made rarer. One new test per SDK pinning the exact intermediate products
Round-2's tests could not catch it: monotonicity and eventual saturation both hold for a formula that saturates early New tests assert the exact term at the attempts below the ceiling, not just the shape
TypeScript's saturation probe used attempt 1023, which passed only because of the early saturation Moved to 1089, the exact saturating exponent of the smallest denormal
The Go red proof was printed with --- FAIL: moved below the indented lines and one trailing FAIL dropped Re-captured and pasted from the log file, with the exact command stated
The Swift pre-PR proof was labelled HEAD~1, which is round 1, not pre-PR Corrected to the branch base 5efc52f0, and the required removal of BackoffCeilingTests.swift disclosed
The Kotlin proof's reproduction step (revert the clamp, keep the constant) was unstated, and a wholesale revert does not compile Disclosed at the proof

Re-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)

  • Config still accepts base_delay=1e-307, and Ruby's validate! still has no base_delay check. 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, so make generate was not run and the three timestamp-churn files are untouched.

… 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
Copilot AI review requested due to automatic review settings August 3, 2026 06:16
@jeremy jeremy added the bug Something isn't working label Aug 3, 2026
@github-actions github-actions Bot added typescript Pull requests that update TypeScript code ruby Pull requests that update the Ruby SDK go kotlin swift python Pull requests that update the Python SDK labels Aug 3, 2026

@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: 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".

Comment thread python/src/basecamp/config.py Outdated
Comment thread swift/Sources/Basecamp/HTTP/HTTPClient.swift

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.

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):

  1. #577 — Backoff ceiling. The exponential backoff term base × 2^n was unbounded, and each language failed differently on a long failure streak: signed-64-bit wrap to a non-positive delay (Kotlin/Go tight-loop), a UInt64 multiply trap that crashes the process (Swift), Infinity that setTimeout clamps to 1ms (TypeScript), and bignum promotion to OverflowError/Float::INFINITY (Python/Ruby). The PR introduces a MAX_BACKOFF_DELAY = 30,000ms ceiling — adopted from Go's generated RetryConfig.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.
  2. #567 — Swift Transport network errors. Both Swift retry loops classified every BasecampError out of the Transport as terminal, so a consumer normalizing connectivity failures into BasecampError.network got only one attempt. The catch is split so .network routes to the retry branch, a new private InvalidResponseTypeError keeps 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 .network as retryable under the existing idempotency gate; introduce internal InvalidResponseTypeError.
  • Document MAX_BACKOFF_DELAY and 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.
Copilot AI review requested due to automatic review settings August 3, 2026 07:35

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 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

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: 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".

Comment thread python/src/basecamp/config.py Outdated
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.
Copilot AI review requested due to automatic review settings August 3, 2026 08:46

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 3, 2026

Copy link
Copy Markdown
Member Author

@codex review

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

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 383a6fe1fe

ℹ️ 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

jeremy commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Round 3 pushed as 383a6fe1.

Codex's follow-up P2 on config.py:46 was correct and is fixed: the round-2 exponent bound was computed as MAX_BACKOFF_DELAY / base_delay, and that ratio has its own overflow below ~1.67e-307s (~1.67e-304ms). The fixed-1023 fallback behind it then failed in the mirror direction of the plateau it replaced — saturating early, returning the 30s ceiling at an attempt whose specified term is ~8.99s (TypeScript: 30000ms where the term is ~899ms, 33x). Round-2's 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) and the term is scaled directly (ldexp in Python/Ruby, bounded repeated multiplication in TypeScript). The numeric backstop is deleted outright in all three rather than made rarer. Details and red proofs are in the thread and the description.

Also corrected in the description, all evidence-fidelity rather than code:

CI: all real checks pass on 383a6fe1. The two red copilot-pull-request-reviewer entries are the reviewer bot erroring out ("Copilot encountered an error and was unable to review this pull request"), not test failures — it has been re-requested twice and errored each time. Codex's review is the substantive one.

@jeremy
jeremy merged commit 6d4c836 into main Aug 3, 2026
46 of 48 checks passed
@jeremy
jeremy deleted the fix/backoff-ceiling-and-swift-network-retry branch August 3, 2026 10:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working go kotlin python Pull requests that update the Python SDK ruby Pull requests that update the Ruby SDK swift typescript Pull requests that update TypeScript code

Projects

None yet

2 participants