spec(retry): pin the Retry-After contract, honour it at every retried status, and give conformance a clock
Spec-first convergence of the Retry-After cluster (#775, #799, #798, #780): SPEC §6 now states the whole contract in one place, the conformance suite can finally fail on the parts that were unpinnable, and the SDK changes that were one-line gate deletions ride along so the new cases are green in all six. What is not mechanical is listed at the end for a second PR.
What SPEC pins (§6, §7, §14, §19, Appendix A/D)
- Status set. Unchanged from #793 — honoured at every status a retry is already going to happen at — but now converged in every loop, §7 and §14 hop 1 alike, and pinned by
retry.json(503 withRetry-After: 2) anddownloads.json(502/503/504). retry_afteron the error at every status. The Status Mapping Algorithm populates it wherever the header parses, not only in the 429 arm; one parse feeds the sleep and the field. Go, TypeScript, Ruby and Python do this now; Kotlin'sApiand Swift's.apihave no slot, recorded as a conflict (Swift's is a source-breaking enum change, so it is PR 2).- Parsing table replacing the three-step prose:
1*DIGITonly (no sign, no fraction, no junk),0and a past date fall through, HTTP-date rounded up, and one ceiling —MAX_RETRY_AFTER_SECONDS = 2,147,483,647, Appendix A — that both wire forms saturate at, in the parser. - Over-range decision (#799). The single ceiling replaces #793's two tiers (wider than the parser's own integer → malformed; inside it but unschedulable → saturate). That tier boundary was the parser's word size, which is exactly the inherited-not-chosen state this table exists to remove: the same header was honoured on a 64-bit build and dropped on a 32-bit one, and "no delay" is the wrong reading of "wait a very long time". The number is the narrowest
retry_afterinteger any of the six ships (Go's 32-bitint, Kotlin'sInt) and the ceiling §16 already shares, so it is representability, not policy. Every host can schedule it, so no second bound is needed anywhere but TypeScript'ssetTimeoutclamp, and Swift's 86,400 s clamp is named as the one policy cap. - Date forms. IMF-fixdate MUST; RFC 850 and asctime MAY; anything that is not an HTTP-date MUST NOT. Recorded reasoning: senders may only emit IMF-fixdate and BC5 does; requiring the obsolete forms costs Kotlin and TypeScript a hand-rolled two-digit-year pivot for a header no conformant origin can send, and the cost of not accepting one is a ~1 s backoff. What it removes is the accidental state — two permissive by inheritance, four strict by inheritance, the contract silent.
- Jitter. Already forbidden on a server-directed delay; the one divergence (generated Go) is closed.
- Conformance clock (#780). A header value may be
{{httpdate+Ns}}, resolved by every runner at serve time to the IMF-fixdate offloor(now) + N + 1s. A compliant round-up parser computes at least N whole seconds for any serve-to-parse latency under a second, so the fixture assertsmin: N × 1000; a parser that drops the date form lands on the ~1 s curve. Relative and near on purpose — an absolute far-future date is differently behaved per host. Each runner's resolver is unit-tested against a frozen instant, the waycheckDelayGapsis; Ruby moves its header merge into theto_returnblock so the token sees the rightnow.
behavior-model.json is untouched: nothing in the contract is per-operation.
SDK changes in this PR (mechanical; each makes a new case green)
| SDK | Change |
|---|---|
| Go | Template: drop the 429 gate, wait a server-directed delay exactly (jitter stays on the local curve), replace the bare Atoi with a SPEC §6 parser (digits-only, saturating, HTTP-date rounded up) used by the loop and checkResponse — closes #798. Hand-written: 5xx arms carry RetryAfter (which is what lets the raw loop honour a 503), download hop parses at every status in its set, parser saturates any over-ceiling digit string instead of treating a >int64 one as malformed. |
| TypeScript | Both loops drop the status === 429 ternary; errorFromParsedBody passes retryAfter on every arm. |
| Ruby | handle_error threads retry_after into every arm (ApiError gains the kwarg); HTTP-date rounds up; parser takes a now: seam. |
| Python | HTTP-date rounds up; parser takes a now= seam. Already conformant on the status set. |
| Kotlin | Both loops drop status == 429 &&. |
| Swift | calculateDelay drops the statusCode == 429 guard and the parameter that existed for it. |
Tests: TypeScript's "ignores Retry-After on a status that is not 429" is flipped to the new contract; Go's fall-through table loses its two width rows to the saturating table and gains a 503 pin plus a generated-package parser test; Ruby/Python pin rounding against a frozen clock and a 503 that carries the field; Kotlin and Swift pin 503 honouring.
Not breaking: every public change is additive (Error.RetryAfter set at more statuses, retryAfter present on more errors, a new optional kwarg on Ruby's ApiError).
Left for PR 2 (per-SDK sweep), with the sites
- Swift
.apigainsretryAfterSeconds(breaking; MIGRATING) and drops the 86,400 s clamp atHTTPClient.swiftsleepNanoseconds; KotlinBasecampException.ApigainsretryAfterSecondsandfromHttpStatusthreads it. Then a conformanceerrorField retryAftercase at 503. - The ceiling in the other five parsers, plus sign rejection where the stdlib parse accepts
+5:typescript/src/errors.tsparseRetryAfter,kotlin/.../Pagination.ktparseRetryAfter,swift/.../BasecampError.swiftparseRetryAfter,python/src/basecamp/errors.py_parse_retry_after,ruby/lib/basecamp/http.rbparse_retry_after. Python and Ruby currently raise at the scheduler above their ceilings. - Cancellation handles on the four no-handle sleeps:
typescript/src/services/base.ts(baresetTimeout),typescript/src/download.ts(executeWithRetrycalled without a signal),ruby/lib/basecamp/http.rb(sleep),python/src/basecamp/_http.py(time.sleep). - Go:
http.go's hook-facing429 || 503parse andresilience.go's rate-limiter block to any status; hand-writtenparseRetryAfterdelegating to the generated one now that it exists. - Python
_parse_retry_afteron asctime:parsedate_to_datetimereturns a naive datetime and the subtraction'sTypeErroris swallowed (a MAY, but a swallowed exception is a bug).
Verification
make check -k (all six SDK checks, drift gates, doc-constants, six conformance runners); lint-actions fails on the seven pre-existing zizmor lows and nothing else. Expected merge note: #853 also regenerates go/pkg/generated/client.gen.go; the hunks are disjoint (gauges types vs. the retry loop), so git should merge them cleanly, but whichever lands second wants a make -C go generate to confirm no drift.
Fixes #798
Fixes #780
Refs #775
Refs #799
Summary by cubic
Pins the Retry-After contract in SPEC §6 and aligns all six SDKs to honour it at every retried status instead of only 429. Adds a conformance clock token so the HTTP-date branch is finally testable.
Contract
- SPEC now defines one parsing table: digits only, saturate at 2,147,483,647, HTTP-date rounded up, IMF-fixdate required, obsolete forms optional.
- The two-tier width rule is gone; the ceiling is the narrowest integer any SDK ships, so behaviour no longer depends on build word size.
- Conformance fixtures can use
{{httpdate+Ns}}header values (N capped at nine digits), resolved at serve time to floor(now)+N+1 seconds. The download 429 case now sendsRetry-After: 2with a 2000ms floor, so ignoring the header fails. - The every-status
retry_afterclaim now binds at the status mapping algorithm; hand-built error sites outside it are named as owed by the follow-up sweep (#857).
SDK changes
- The 429-only gate is removed from every retry loop.
- Go's generated client replaces bare
Atoiwith the SPEC parser, stops adding jitter to a server-directed delay, and drives its retry tests through the public client against anhttptestserver; both Go clients parse the header once and carryRetryAfteron every status-mapped error, and the download loop sleeps the mapped value instead of parsing a second time. - TypeScript, Ruby, Python, Kotlin, and Swift drop the status check and thread
retryAfterthrough error objects; TypeScript'sonRetryhook receives the status-mapped error carryinghttpStatusand the loop's single-parsedretryAfter. - Ruby's
ApiErrorgains an optionalretry_afterkwarg, its public mapper forwards it on every status arm, and the download paths pass the parsed value through the hop-1 fallback and hop-2 errors too. No breaking changes. - Swift and Kotlin have no error field yet; TypeScript's parser still owes the sign row, and Python's and Ruby's owe the whole
1*DIGITgate — all left for a follow-up PR. Ruby's download hops carry the header; Go, Python, and TypeScript download sites still build errors without it. - Fixes #798 and #780; refs #775 and #799.