spec(retry): pin the Retry-After contract, honour it at every retried status, and give conformance a clock

Merged
JE
2mo ago
+1659-302

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 with Retry-After: 2) and downloads.json (502/503/504).
  • retry_after on 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's Api and Swift's .api have 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*DIGIT only (no sign, no fraction, no junk), 0 and 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_after integer any of the six ships (Go's 32-bit int, Kotlin's Int) 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's setTimeout clamp, 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 of floor(now) + N + 1 s. A compliant round-up parser computes at least N whole seconds for any serve-to-parse latency under a second, so the fixture asserts min: 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 way checkDelayGaps is; Ruby moves its header merge into the to_return block so the token sees the right now.

behavior-model.json is untouched: nothing in the contract is per-operation.

SDK changes in this PR (mechanical; each makes a new case green)

SDKChange
GoTemplate: 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.
TypeScriptBoth loops drop the status === 429 ternary; errorFromParsedBody passes retryAfter on every arm.
Rubyhandle_error threads retry_after into every arm (ApiError gains the kwarg); HTTP-date rounds up; parser takes a now: seam.
PythonHTTP-date rounds up; parser takes a now= seam. Already conformant on the status set.
KotlinBoth loops drop status == 429 &&.
SwiftcalculateDelay 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 .api gains retryAfterSeconds (breaking; MIGRATING) and drops the 86,400 s clamp at HTTPClient.swift sleepNanoseconds; Kotlin BasecampException.Api gains retryAfterSeconds and fromHttpStatus threads it. Then a conformance errorField retryAfter case at 503.
  • The ceiling in the other five parsers, plus sign rejection where the stdlib parse accepts +5: typescript/src/errors.ts parseRetryAfter, kotlin/.../Pagination.kt parseRetryAfter, swift/.../BasecampError.swift parseRetryAfter, python/src/basecamp/errors.py _parse_retry_after, ruby/lib/basecamp/http.rb parse_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 (bare setTimeout), typescript/src/download.ts (executeWithRetry called without a signal), ruby/lib/basecamp/http.rb (sleep), python/src/basecamp/_http.py (time.sleep).
  • Go: http.go's hook-facing 429 || 503 parse and resilience.go's rate-limiter block to any status; hand-written parseRetryAfter delegating to the generated one now that it exists.
  • Python _parse_retry_after on asctime: parsedate_to_datetime returns a naive datetime and the subtraction's TypeError is 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 sends Retry-After: 2 with a 2000ms floor, so ignoring the header fails.
  • The every-status retry_after claim 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 Atoi with the SPEC parser, stops adding jitter to a server-directed delay, and drives its retry tests through the public client against an httptest server; both Go clients parse the header once and carry RetryAfter on 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 retryAfter through error objects; TypeScript's onRetry hook receives the status-mapped error carrying httpStatus and the loop's single-parsed retryAfter.
  • Ruby's ApiError gains an optional retry_after kwarg, 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*DIGIT gate — 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.