fix(retry): converge every Retry-After parser on SPEC §6's table and surface the field in Kotlin and Swift

Merged
JE
2mo ago
+648-154

The per-SDK sweep that #855's spec-first PR left listed: every remaining parser converges on SPEC §6's Retry-After table, and the two SDKs whose error shapes had no slot for retry_after gain one. Stacked on #855 (spec/retry-after-contract); the diff against main includes that PR until it merges.

Parsers (#799)

All five remaining parsers now match the table Go already implemented in #855:

  • Sign rejected. +5 is not 1*DIGIT; Ruby, Python, Kotlin and Swift honoured it as 5 because Integer(), int(), toIntOrNull() and Int(_:) all read a sign. TypeScript's DELAY_SECONDS drops its [+-]?.
  • One ceiling, saturating. A value over MAX_RETRY_AFTER_SECONDS (2,147,483,647) saturates there in both wire forms, whatever its width — TypeScript refused above MAX_SAFE_INTEGER, Kotlin above Int.MAX_VALUE, Swift above Int, and Python/Ruby parsed the bignum and then raised on the retry path (OverflowError from float(), RangeError from sleep). Width is compared on the digit string before any conversion, so nothing overflows on the way to the comparison. Each SDK names the constant (MAX_RETRY_AFTER_SECONDS, BasecampError.maxRetryAfterSeconds, Basecamp::Http::MAX_RETRY_AFTER_SECONDS).
  • Python asctime. parsedate_to_datetime hands the zoneless asctime form back naive, and the subtraction's TypeError was swallowed into "no delay"; it is now read as UTC per RFC 7231.

The error field at every status (#775)

  • Swift .api gains a sixth associated value, retryAfterSeconds, populated by fromHTTPResponse at every api_error status; a new retryAfterSeconds property covers it and .rateLimit. Source-breaking for anyone matching .api(...) — MIGRATING has the section, and the PR carries breaking. With the parser saturating at a ceiling whose nanosecond product fits UInt64, the sleep drops its 86,400 s clamp — the one policy cap SPEC §7 named — and keeps only the representability bound.
  • Kotlin BasecampException gains a base-class retryAfterSeconds, populated on Api (public constructor grows a trailing defaulted parameter; ApiConstructorSurfaceTest's shape pin moves with it) and RateLimit (now an override).
  • Go RequestResult.RetryAfter is populated at every status rather than 429/503 only; the resilience hook's own 429/503 policy is unchanged.
  • Conformance: error-mapping.json pins the field on a 503 api_error (a non-idempotent POST, so nothing sleeps), and retry.json's non-retried 429 POST now asserts it too. All six runners learn the retryAfter error field.

Still open after this PR (on the card)

  • Cancellation handles on the four bare sleeps — typescript/src/services/base.ts, typescript/src/download.ts, ruby/lib/basecamp/http.rb, python/src/basecamp/_http.py. Each is a caller-facing API decision (where the handle comes from), not a mechanical change.
  • Kotlin and Swift carry the field on their rate-limit and api_error shapes, not on the 401/403/404/400/422/507 classes; SPEC's conflict note says so.
  • Go's hand-written parseRetryAfter delegating to the generated copy.

Verification

make check-equivalent per SDK on the branch (Go vet/lint/tests, TypeScript typecheck/lint/1733 tests, Ruby rubocop/1515 tests, Python ruff/mypy/tests, Swift 471 tests, Kotlin jvmTest + runner tests), doc-constants-check, and all six conformance runners.

Refs #775
Refs #799


Summary by cubic

Aligns every SDK's Retry-After parser and retry path with SPEC §6, completing #775 and #799. Signed values now fall back to backoff, over-range values saturate at 2,147,483,647 seconds, and Swift honors delays beyond its former 24-hour cap.

Behavior

  • Python accepts only the three RFC 7231 date forms and treats asctime dates as UTC; Swift accepts only IMF-fixdate.
  • SPEC §6 now treats an HTTP-date's shape as its template, not its token alphabet: enumerating day or month names (Swift does) and not enumerating them (Python does not) are both compliant.
  • Retry loops reuse one parsed value for sleeping and retry hooks; Go now fills RequestResult.RetryAfter for every response status, and the Kotlin and Swift hop-1 redirect errors carry it too.
  • Kotlin exposes BasecampException.retryAfterSeconds on Api and RateLimit; Swift exposes it on .api and .rateLimit.
  • Conformance covers the field on 503 api_error and non-retried 429 responses across all runners.

Compatibility

  • Swift .api pattern matches and constructor calls need the new sixth retryAfterSeconds value; MIGRATING.md documents the source break.
  • Kotlin keeps its six-argument Api constructor for binary and Java compatibility; the retryAfterSeconds overload requires its seventh parameter, and untyped constructor references now need an expected function type or a lambda.