Let Go's raw GET retry loop see the Retry-After it already parses - #796
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes Go’s raw GET retry loop so 429 responses honor parsed Retry-After values.
Changes:
- Adds
RetryAfterto structured errors. - Removes unreachable
retryableErrorhandling. - Adds regression tests and updates the specification.
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 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
SPEC.md |
Documents Go retry metadata behavior. |
go/pkg/basecamp/http.go |
Removes the unused error wrapper. |
go/pkg/basecamp/helpers.go |
Populates retry metadata for typed services. |
go/pkg/basecamp/errors.go |
Exposes retry delay on structured errors. |
go/pkg/basecamp/client.go |
Applies server-directed delays in raw GET retries. |
go/pkg/basecamp/client_retry_after_test.go |
Tests parsing, propagation, fallback, and cancellation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 72050ba6b9
ℹ️ 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".
A 64-bit build parses `Retry-After: 9223372036854775807` cleanly, and `time.Duration(n) * time.Second` then wraps to -1s. `time.After` on a non-positive duration fires at once, so the loop spends its whole attempt budget back to back against a server that just asked it to wait — the newly honoured header turned into a tight retry loop. Reported independently by two reviewers on #796. Both doors onto Error.RetryAfter now normalize: parseRetryAfter, which feeds checkResponse, downloadURL and RequestResult, and ErrRateLimit, which is exported and takes a bare int, so it can also carry a negative value the field's own doc calls invalid. Over-range saturates rather than falling back to "absent". Falling back would compute the millisecond backoff curve and hammer the peer, which is the same tight loop by another route; saturating waits as long as the host can express, and the wait is a select on ctx.Done() so it stays abandonable. Same split the device-flow parser draws: a digit string too long to be an int is malformed and falls back, a value that parses but exceeds what we can honour is clamped. This is a representability bound, not the policy cap #793 declined — at ~292 years it rejects nothing a server could sensibly ask for. SPEC §7 already carved out exactly this for Swift's UInt64 trap; Go joins it. The generated client's own loop (client.gen.go, from go/templates/ client.tmpl) has the identical unclamped conversion and is untouched here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
go/pkg/basecamp/client_retry_after_test.go:320
- This direct constant-to-
intconversion also prevents the package tests from compiling on 32-bit targets; the runtime skip cannot protect a compile-time overflow. Derive an expectation bounded by the hostintrange.
{name: "beyond duration range", header: "9223372036854775807", want: int(maxRetryAfterSeconds), needsWideInt: true},
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
SPEC.md:840
- This unqualified statement is false for Go's generated client:
go/templates/client.tmpl:457still multiplies an uncheckedstrconv.Atoiresult, which is the overflow tracked in #798 and explicitly left unchanged by this PR. Qualify the bound as applying only to the hand-written Retry-After paths so the SPEC does not promise safety that typed service calls lack.
86,400s because `UInt64(_:)` on an out-of-range `Double` is a trap, and Go
saturates its seconds→`time.Duration` conversion at `math.MaxInt64 / time.Second`
(~292 years) because the product otherwise wraps negative and `time.After` on a
non-positive duration fires at once — turning a server-directed wait into a tight
retry loop. Both are *representability* bounds on the conversion, not policy caps
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 51546fab1b
ℹ️ 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
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
go/pkg/basecamp/client_retry_after_test.go:101
- This 5-second tripwire cannot detect an uninterruptible retry wait:
client.Getis called synchronously, so if the wait blocks (especially the ~292-year saturation case), execution never reaches the elapsed-time check and the package test times out instead. RunGetin a goroutine and select between its result and a 5-second timer so the guard can actually fail with the intended diagnostic.
start := time.Now()
_, err := client.Get(ctx, "/test.json")
// Not the assertion — the assertions are all exact equalities below. This
// is the guard that keeps the whole file honest: if the loop's wait ever
// stopped observing cancellation, every case here would spend its full
go/pkg/basecamp/client.go:1130
- On 32-bit builds this clamp runs too late for HTTP-date values beyond
math.MaxIntseconds (for example, a valid date in 2100): converting the positivefloat64tointproduces an out-of-range implementation-specific value, which is non-positive with the Go compiler, so the parser returns 0 and the client falls back to millisecond backoff instead of saturating. Clamp the floating-point duration against both thetime.Durationceiling andmath.MaxIntbefore converting it toint; this is distinct from the oversized decimal case, whichAtoirejects on 32-bit.
return clampRetryAfterSeconds(seconds)
Three reviewers found the same defect from three directions: §9's new rule,
stated absolutely, captured text that an existing contract requires to reach
the caller — §6's API error messages, §6's statusless api_error echo of the
malformed wire value, and §23's origin-only URL rendering. §9 already drew
that boundary in prose one paragraph earlier; the normative sentence just
overrode its own boundary by being absolute. It now carries the scope, and a
guard so the clause cannot be used to write a new contract into existence.
Six independent findings on the Retry-After side, each checked against code:
- §14's DownloadURL loop honoured the header on 429 while retrying four
statuses, which prescribed two delays for the same 503. Widened to the whole
DOWNLOAD_RETRY_ON set, which is the same derivation §6 makes — honouring
follows retry eligibility, so a loop declaring its own set inherits the rule
over that set. Marked as owed convergence; the existing 429 case still holds.
- Swift's 86,400s clamp is a policy cap wearing a host limit's reasoning. The
UInt64 nanosecond trap it cites is real but sits about five orders of
magnitude higher, and the comment concedes it ("clamp to a day instead").
Recorded as a conflict rather than permitted, since declining a policy cap is
the position this section takes.
- Unrepresentable values are permitted a bound in the parser, which a policy
cap is not: §16's device-flow parser already settled the two tiers, and they
are adopted here. Ruby and Python have no width at all and raise out of the
retry loop instead — a defect, now recorded. Go's wrapping seconds-to-
Duration conversion is #796's; the generated client's copy is #798's.
- Generated Go sleeps retryDelay + jitter with no branch distinguishing a
server-directed delay from the local backoff, so "honoured as given" now says
nothing may be added either.
- TypeScript's DownloadURL loop is a fourth no-escape path: sleep() takes a
signal, the loop never supplies one, and downloadURL(rawURL) gives a caller
nowhere to put one. The per-attempt controller is discharged in the fetch's
finally, before the sleep.
- RFC 9110 §10.2.3 also gives 3xx explicit semantics, so the claim is qualified
to statuses these retry sets carry.
Plus two from the suppressed set: the CONFLICT banner said five SDKs gate on
429 alone, which the Go paragraph directly below it contradicts; and Swift and
TypeScript gate the parser call itself where Ruby and Kotlin gate only the use,
a distinction that decides whether convergence moves a call or widens a test.
LC_ALL=C make doc-constants-check -> 0 (27 marked spans across 7 files)
LC_ALL=C make sync-api-version-check -> 0
No marked span added, removed, or hand-edited; the writer is a no-op.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (4)
go/pkg/basecamp/client.go:1130
- On 32-bit builds,
Atoirejects valid delta-seconds abovemath.MaxIntbefore the new clamp can saturate them. For example,Retry-After: 2147483648falls back to the local backoff instead of clamping to the largest delay the exportedintfield can represent. Parse in the clamp'sint64domain first; the PR already explicitly supports 386/ARM and bounds the result before converting toint.
if seconds, err := strconv.Atoi(header); err == nil && seconds > 0 {
return clampRetryAfterSeconds(int64(seconds))
go/pkg/basecamp/client.go:1146
- Integer division rounds a future HTTP-date down, so the retry can happen almost one second before the server's named time; a date less than one second away becomes zero and is discarded entirely. Kotlin, Swift, and TypeScript all round this conversion up for the same reason. Compute a ceiling in integer arithmetic before clamping.
if seconds := int64(time.Until(t) / time.Second); seconds > 0 {
return clampRetryAfterSeconds(seconds)
SPEC.md:838
- This bound is not always
math.MaxInt64 / time.Second:clampRetryAfterSecondsalso appliesmath.MaxInt, so Go saturates at about 68 years on 32-bit builds and about 292 years on 64-bit builds. The canonical SPEC should describe both host limits, especially since this PR explicitly keeps 32-bit builds supported.
hand-written client saturates its seconds→`time.Duration` conversion at
`math.MaxInt64 / time.Second` (~292 years) because the product otherwise wraps
MIGRATING.md:260
- The new migration claim is architecture-dependent:
RetryAfteris anint, and the clamp usesmath.MaxInt, so a 32-bit build can represent only about 68 years rather than 292 years. Document both limits so consumers do not rely on a value this public field cannot carry on supported 32-bit targets.
ceiling beyond what a `time.Duration` can represent (over-range values saturate
at ~292 years rather than wrapping negative). Typed service methods, downloads
Four suppressed Copilot comments — all four takenNo threads to reply in, so they are answered here. All in
Right, and it made the same header mean two different things by target:
Right, and the claim about the other SDKs checks out: TypeScript Go now rounds up (remainder bump on the integer division, so nothing overflows at the saturation boundary), and SPEC §6 step 2 states the rule and its two reasons: a positive remainder must never round to zero, because zero reads as "no usable value" and drops the request onto the backoff curve; and truncating retries before the moment the server named. Red-proved — with the truncating mutant restored: Python and Ruby still truncate, and this PR does not touch them — that is #799, which carries the six-SDK table and the SPEC wording. Go was brought into line here only because the line was already being edited; the convergence is not complete and the issue says so.
Right on both, and they are the same defect in two documents: Gates after all four: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 48a85d298d
ℹ️ 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".
parseRetryAfter carried both readings at once: fifteen lines arguing that a positive range error saturates "like every other over-range value", followed by the tier that says it is malformed. The code does the second. The first is the reading two reviewers rejected, sitting in the file as though it were current — and a comment like that gets the next change made in its direction. Three more witnesses of reverted or disproved claims went with it: - the note that TypeScript and Kotlin "fall back at their own parse limits rather than saturating", which implied Go differs from them at tier 1 when it now matches them; - maxRetryAfterSeconds citing Kotlin as saturating this header at the same number, which review disproved — Kotlin saturates only in its HTTP-date branch and its integer branch rejects. It was removed from SPEC and left here; - TestClient_RetryAfterSaturatesAtDurationCeiling and two rows named "beyond duration range", where the Duration bound is no longer what binds; the ceiling is the schedulable one. Renamed to say so, and the probe's "~292 years" figure corrected to ~68. Comments and names only. The tier-1 rejection still fails both malformed cases when removed.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (3)
MIGRATING.md:263
SPEC §6 draws that lineis inaccurate: §6 only says to parse a positive integer, while #799 records over-range handling as an unresolved cross-SDK decision. Label theint64fallback as Go-specific rather than normative.
wrapping negative, and that figure does not vary by architecture. A value too
large for the parser's own `int64` is malformed instead and falls through to the
backoff curve, as it always did — SPEC §6 draws that line.
go/pkg/basecamp/client.go:1164
- SPEC §6 does not define these two tiers; it only says to parse a valid positive integer, and #799 explicitly tracks over-range behavior as unresolved. The fallback may remain Go's behavior, but this comment should not claim the SPEC mandates it.
// A value too large for that int64 is MALFORMED and falls through to step
// 3's backoff — SPEC §6's first tier — rather than saturating. So is any
// other unparseable input: ParseInt returns 0 with ErrSyntax, and a
// negative range error clamps to math.MinInt64, both caught by the `> 0`
// guard alongside the err check. The second tier, saturation, is for a
go/pkg/basecamp/client.go:1081
- The PR description says Go is added to SPEC §7's host-limit note, but that note still names only Swift. Please record Go's 2,147,483,647-second cross-architecture bound there so the normative documentation matches this new implementation.
// maxRetryAfterSeconds is the largest delta-seconds value this SDK honours:
// 2147483647, ~68 years. It is a REPRESENTABILITY bound taken at the portable
// limit, not a policy ceiling — SPEC §7's "Retry-After is exempt" note already
// carves out exactly this ("implementations may still bound it against host
// limits"), and Swift clamps its own seconds→nanoseconds conversion for the
… the mapping algorithm §16's device poll accepts delta-seconds only while §6's parser accepts the HTTP-date form too, so one 429 carried two prescribed waits. Declared as an exception in the composition table rather than re-pointed at the §6 parser: that loop measures every wait on an injectable monotonic clock against a deadline fixed at issuance, and a date can only be resolved against wall-clock now(), which it deliberately never reads. The fallback is the server's own cadence, not a local backoff, and the wait rule clamps to the code lifetime regardless, so the cost is bounded by the loop's shape. §16's block already pinned the shape; the five SDKs implementing it were read and all agree. §23's row 4 read as if Retry-After floored the backoff after an unauthorized mint, while §6 said no loop declares 401 retryable. The seam's `unauthorized` kind carries no retry_after at all - only throttled(retry_after) does - so the header cannot reach that branch. Row 4 now attaches the floor to transient/throttled only, §6 walks the authorization-recovery cycle back as out on the same clause as §4, and the state-machine bullet says which sense of "retry" it means. The preservation sentence promised the unclamped value through the public error, but the mapping algorithm populates retry_after in its 429 arm only. Narrowed to integrity rather than presence; growing the field at 502/503/504 is #775's status convergence, and is the same change as the status gate for the SDKs whose loop reads the delay off the error. Also: the per-loop composition MUST now binds only loops the definition puts inside, so repair-poll and the deadline timers are not made under-specified by it; Kotlin's width observation distinguishes its delta-seconds rejection from its date-form saturation (Pagination.kt:184 vs :196-210, the parser- output carve-out); and the two places that said Go's saturation shipped are marked [PENDING #796], since that PR merges after this one and the tree does not yet contain it.
"Cancellation must win over the wait" was the comment on a select that cannot promise it: when both cases are ready Go picks pseudo-randomly. The loop fires OnRetry and then waits, so a hook that cancels there, with a delay that has already elapsed, saw the timer win about half the time and sent one more request on a dead context — failing fast, but as the transport's wrapping of context.Canceled rather than ctx.Err(), and sometimes round again to "request failed after 3 attempts". A ctx.Err() check before the select closes it, and the same order goes into fetchAPIDownload, which has the identical select behind the identical OnRetry. The interleaving cannot be forced — that is what pseudo-random means — so the contract is pinned the one way it can be: with the context cancelled before the wait and a zero backoff so the timer is already ready, the loop must return ctx.Err() itself having handed the transport exactly one request, on every one of 64 runs. Against the bare select each run is a coin flip; five invocations failed five times, by run 3 at the latest, on both loops. Against the guard it cannot fail. This is also what takes the load-sensitivity out of the probe tests, whose 1ms backoff cases could lose the same flip under preemption. Three comment corrections from the same review rounds, none changing behaviour: the loop and the wait said Retry-After carries "no ceiling", which has not been true since the representability clamp landed — it carries no policy ceiling; parseRetryAfter's doc said it clamps to what a Duration can hold, where the constant is the smaller portable bound; and MIGRATING, parseRetryAfter and two test comments said "SPEC §6 draws that line" about the int64-malformed split, which §6's algorithm on its own does not — it says only to parse a positive integer. The split is Go's, it is the first tier of the rule #793 states in §6 "Retry-After Honouring", and the cross-SDK decision is #799's. Worded to be true on either side of #793 landing. The far-future HTTP-date test asserted only `<= ceiling`, which any positive value satisfies; it now asserts equality, which time.Until's saturation at the Duration maximum makes exact.
Six suppressed Copilot comments across three rounds — four taken, one declined, one already absorbedRounds @
Right. When both cases are ready Go picks pseudo-randomly, and the loop fires Red-proved, stated honestly: the interleaving cannot be forced — pseudo-random means exactly that — so the contract is asserted the one way it can be. With the context cancelled at Against the guard it cannot fail — the check runs before there is anything to pick between — and 20× green, plus
This is the reading
Right: §6's parsing algorithm on
Right, that was true of an earlier revision and
Merge state with #793. Gates, |
The width observation said Go rejects above a 64-bit integer. At the revision it cites both Go parsers are strconv.Atoi, whose range is native int - 32 bits on the 32-bit targets this repository keeps viable. Say "native int", and say which parser #796 moves to int64 and which stays Atoi until #798. "MAY bound it against a host limit" read as optional while the second representability tier, stated once below, is mandatory. Now: an implementation MAY have a host limit, and where a parsed value meets one it MUST saturate there - never trap or wrap, never fall back. The tier rule is still stated once; this sentence points at it. §16's scope paragraph said the 429 + too_many_requests branch is the only retry in the loop, while §6's table marks the connection-timeout branch in, which the pseudocode repeats with backoff. Qualified to completed-response branches; the timeout path is in scope by the first clause and has no header to honour.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (1)
MIGRATING.md:284
- The raw GET loop wraps its final
*Errorwithfmt.Errorfafter retry exhaustion (client.go:755), even when the attempt cap is one; the new test correctly useserrors.Asfor this reason. Describing the result as a returned*Errorsuggests direct access/type assertion, which will fail. Document the requirederrors.Asextraction before readingRetryAfter.
backoff may now hit it. The wait observes cancellation — the loop selects on
`ctx.Done()` — so cancelling is the escape, and `err.RetryAfter` on the returned
`*Error` is there if you would rather reschedule the work yourself.
|
One suppressed Copilot comment on |
…composes it (#793) * SPEC §6: decide which statuses honour Retry-After, and how each loop composes it Closes what #775 asked for. Re-cut from main to carry only the Retry-After material; the peer-derived-text work that shared this branch is split to its own PR because its boundary is still being designed. The status rule. A parsed Retry-After is honoured at any status a retry is already going to happen at - no status gate of its own, derived from retry eligibility rather than from a list, so it needs no amendment when a retry set grows. This is what §7's algorithm has always spelled: step 3h has no status test and is reachable only past step 3f's declared-set check. The word "retry" is then defined, because leaving it to intuition cost three review rounds - each finding another repeating loop the rule appeared to reach and the code deliberately did not. A retry is the re-issue of a request whose previous attempt produced no answer: the transport failed, or the origin declined to serve it with a status the loop DECLARES retryable. Re-issuing because the answer was "not yet" is a poll. §16's authorization_pending and slow_down are therefore outside by definition rather than by exception. Both clauses carry weight - §4's 401 replay re-issues after a refusal and stays out only because no loop declares 401 retryable. Composition is separated from honouring and made per-loop with no default, so a delay loop added later is under-specified until it states its own. Five rows, each verified in the section that owns it: §7 and §14 replace, §16 takes max(interval, retryAfter), §23's backoff timer floors and its poll-retry timer waits exactly. §23 needs two rows because its timers genuinely differ, which is the evidence one rule would not have fitted. Representability is split into two tiers - unrepresentable in the parser's own type is malformed, representable but unschedulable saturates - with the per-SDK widths and the Go ceiling recorded, and the date-form inventory rewritten per parser after probing found the previous split backwards. No behaviour changes: every row and tier records what the code already does, with the divergences marked CONFLICT and tracked in #775, #798 and #799. * Correct three claims the re-review caught, two of them mine to own I asserted verification I had not done, twice, in the table whose whole purpose was to be checkable. The §23 rows claimed "code today". No event-feed connector ships in any of the six SDKs - Appendix A says it lands in later PRs - so those four rows are contract-only and were checked against §23's written text, not against code. The column now says which of the two each row is, and notes that the §23 rows are the weaker evidence and should be re-checked when the connector exists rather than assumed. Seven code rows were genuinely read; four were not, and the table said otherwise. The download conflict claimed every SDK honours Retry-After on 429 alone. False for both Python clients: get_download passes DOWNLOAD_RETRY_ON = {429,502,503,504} into the shared loop, error_from_response attaches the parsed header to the ApiError at every status, and _calculate_delay honours any positive retry_after with no status test - so a Python hop-1 503 already waits what the origin named. Python is conformant on this axis and owes nothing; four of six diverge, not six. Third is an internal inconsistency rather than a fact: the escape remedy offered "a bound against a caller-supplied total-time budget" as an option, which cannot satisfy the MUST two paragraphs above it. That MUST asks for a handle the caller can act on AFTER the call begins, and a numeric deadline fixed beforehand cannot be. It is a policy cap by another name - the exact trade this section declined - so it is now named as non-satisfying rather than listed as an alternative. * Move per-SDK current-state inventories out of SPEC into #775 Fixes Kotlin's missing row in the download divergence first: Download.kt declares DOWNLOAD_RETRY_ON = {429,502,503,504} at line 24 but its delay branch tests status == 429, so it falls back to local backoff on the other three like the rest. Five of six diverge, not four. That is the fourth round in which one of these tables needed a factual correction - §23's rows labelled "Code today" with no connector shipping anywhere, the download loop claimed 429-only when Python already conforms, the date-form split backwards, now Kotlin. Every one was caught by a reviewer or by me, never by the table. These state per-SDK current behaviour, which by this repo's own doc-constants convention is a class-A current-value claim living in prose, and the convergence work they describe changes the very rows they state. They are stale by design and nothing in CI can see it. So they move to #775 where they can be edited as work lands, verified as of this branch. SPEC keeps the normative rule and a pointer. Removed: the nine-row cancellation-escape table, the six-bullet status-gate divergence, the added-jitter and bounds bullets, the seven-row date-form parser table, the Ruby/Python representability detail, and the per-SDK download call sites. Kept as contract: the composition table, which is normative per loop rather than observed; the retry walk-back verdicts, which are what the criterion decides; and the two-tier rule with Go as its one worked example. The walk-back table loses its "Source" column - the verdicts are contract, whether today's code agrees is observation, and that column is the one that needed correcting twice. Kept as an explicit as-of observation, because the rule is unreadable without it: the four host widths behind "the width is deliberately not fixed here". Marked with what it was verified against so the next reader knows it is not a live claim. Net effect is 107 fewer lines of SPEC stating things that were going to drift. * Close the three prescriptions §6 still left open against §16, §23 and the mapping algorithm §16's device poll accepts delta-seconds only while §6's parser accepts the HTTP-date form too, so one 429 carried two prescribed waits. Declared as an exception in the composition table rather than re-pointed at the §6 parser: that loop measures every wait on an injectable monotonic clock against a deadline fixed at issuance, and a date can only be resolved against wall-clock now(), which it deliberately never reads. The fallback is the server's own cadence, not a local backoff, and the wait rule clamps to the code lifetime regardless, so the cost is bounded by the loop's shape. §16's block already pinned the shape; the five SDKs implementing it were read and all agree. §23's row 4 read as if Retry-After floored the backoff after an unauthorized mint, while §6 said no loop declares 401 retryable. The seam's `unauthorized` kind carries no retry_after at all - only throttled(retry_after) does - so the header cannot reach that branch. Row 4 now attaches the floor to transient/throttled only, §6 walks the authorization-recovery cycle back as out on the same clause as §4, and the state-machine bullet says which sense of "retry" it means. The preservation sentence promised the unclamped value through the public error, but the mapping algorithm populates retry_after in its 429 arm only. Narrowed to integrity rather than presence; growing the field at 502/503/504 is #775's status convergence, and is the same change as the status gate for the SDKs whose loop reads the delay off the error. Also: the per-loop composition MUST now binds only loops the definition puts inside, so repair-poll and the deadline timers are not made under-specified by it; Kotlin's width observation distinguishes its delta-seconds rejection from its date-form saturation (Pagination.kt:184 vs :196-210, the parser- output carve-out); and the two places that said Go's saturation shipped are marked [PENDING #796], since that PR merges after this one and the tree does not yet contain it. * Tighten three sentences the last round's own text left loose The width observation said Go rejects above a 64-bit integer. At the revision it cites both Go parsers are strconv.Atoi, whose range is native int - 32 bits on the 32-bit targets this repository keeps viable. Say "native int", and say which parser #796 moves to int64 and which stays Atoi until #798. "MAY bound it against a host limit" read as optional while the second representability tier, stated once below, is mandatory. Now: an implementation MAY have a host limit, and where a parsed value meets one it MUST saturate there - never trap or wrap, never fall back. The tier rule is still stated once; this sentence points at it. §16's scope paragraph said the 429 + too_many_requests branch is the only retry in the loop, while §6's table marks the connection-timeout branch in, which the pseudocode repeats with backoff. Qualified to completed-response branches; the timeout path is in scope by the first clause and has no header to honour. * Fix the §23 seam mapping, the portable-ceiling rule and a count the last round understated
…ounding paragraph beside #793's section
…that it is what lands
|
Brought up to
|
* origin/main: Police the endpoints a discovered issuer names, not only the issuer (#810) Refuse redirects on the signed download hop in every SDK (#809) Quiet known-noise CodeQL alerts without losing coverage (#807) Judge the advertised OAuth issuer's address, not just its spelling (#804) Let Go's raw GET retry loop see the Retry-After it already parses (#796) Deflake three tests that raced a wall clock, and gate the class that produced two of them (#794) SPEC §6: decide which statuses honour Retry-After, and how each loop composes it (#793) Pin the conformance runners' fixture reads, and give CI a leg that can see them break (#791) Report an anonymous embed the timestamp walk cannot resolve, instead of skipping it (#790) # Conflicts: # go/go.mod
…nt-feed-go-connector * origin/event-feed-foundations: Make the fake transport honour the oversize sentinel it mirrors Police the endpoints a discovered issuer names, not only the issuer (#810) Refuse redirects on the signed download hop in every SDK (#809) Quiet known-noise CodeQL alerts without losing coverage (#807) Judge the advertised OAuth issuer's address, not just its spelling (#804) Let Go's raw GET retry loop see the Retry-After it already parses (#796) Deflake three tests that raced a wall clock, and gate the class that produced two of them (#794) SPEC §6: decide which statuses honour Retry-After, and how each loop composes it (#793) Pin the conformance runners' fixture reads, and give CI a leg that can see them break (#791) Report an anonymous embed the timestamp walk cannot resolve, instead of skipping it (#790)
…ent-feed-conformance-driver * origin/event-feed-go-connector: Event feed: verdicts that lost coin flips, and a driver blind to eras Fix the filters-clone comment that stated the opposite of the code Event feed: a policy reason never echoes what the server wrote Event feed: wakes are not verdicts, and three more places the order was the bug Make the fake transport honour the oversize sentinel it mirrors Police the endpoints a discovered issuer names, not only the issuer (#810) Refuse redirects on the signed download hop in every SDK (#809) Event feed: the oversize verdict survived the seam but not the sanitizer Event feed: close the rendering-cap class, type the oversize rejection Event feed: cancellation could not reach the one exit, and the origin was checked after the mangle Event feed: own the ledger's bytes, cap the dial rendering, test the store fake Quiet known-noise CodeQL alerts without losing coverage (#807) Judge the advertised OAuth issuer's address, not just its spelling (#804) Let Go's raw GET retry loop see the Retry-After it already parses (#796) Deflake three tests that raced a wall clock, and gate the class that produced two of them (#794) SPEC §6: decide which statuses honour Retry-After, and how each loop composes it (#793) Pin the conformance runners' fixture reads, and give CI a leg that can see them break (#791) Report an anonymous embed the timestamp walk cannot resolve, instead of skipping it (#790)
Closes #795.
go/pkg/basecamp/client.go:694read its server-specified retry delay off a*retryableError. Nothing in the tree ever built one.The type, its two methods, one type assertion. No
&retryableError{. So the branch was unreachable, every 429 landed on theelse if apiErrarm, and the loop backed off locally against a server that had named a time. The number was parsed —client.go:847callsparseRetryAfter— and then spent on a hint string, becauseErrorhad nowhere to put it.Scope: the raw path only. Typed service methods go through the generated client's loop, which reads the header inline and honours it.
downloadURLand the rate-limiter hook honour it too. This wasClient.Get/GetAlland the escape hatch, which is why conformance never saw it.Shape:
Error.RetryAfter, andretryableErrordeletedOf the two candidates in #775's remedy table, only one of them removes a mechanism. Constructing
retryableErrorat the 429 arm keeps two error types flowing through one loop and changes whatsingleRequestreturns to every other caller — including the mutation path'serr.(*Error)atclient.go:664. Putting the value onErrormakes the*Errorarm the only arm, and the dead type goes with the branch that was its only reader.Nothing is lost by deleting it.
Unwrapis load-bearing forerrors.Is/errors.Asonly through a value that reaches a caller, and no value ever existed — apidiff confirms it was never public surface either.The field is populated at both 429 construction sites —
ErrRateLimitfor the raw path,checkResponsefor the generated service layer — so a caller readingerr.RetryAftergets the same answer whichever door it came through. Half-populating it would have been worse than not having it.What did NOT change: which statuses carry a value
Only the two 429 sites set the field, matching
downloadURLand the generated loop.#775decides whether 503 and the rest join them, and its five-SDK convergence is not this PR's. The code says so at the branch. No ceiling is applied either — #793 is settling that in SPEC.Red proof
Reverting only the honour hunk — the loop back to
delay = c.backoffDelay(attempt), everything else including the tests unchanged — and runninggo test ./pkg/basecamp/ -run RetryAfter:Five mutants, five kills. Files restored by
cpand verified withdiff -q, nevergit checkout --.backoffDelayunconditionallyRetryAfterReplacesBackoff,…HTTPDate…— 1.4ms vs 2sif apiErr.RetryAfter > 0→if true…AbsentOrUnusableKeepsBackoff—[0s 0s], backoff collapsedselect→ plaintime.SleepcheckResponsedrops the fieldCheckResponse_CarriesRetryAfter/seconds—0, want 17ErrRateLimitdrops the field…ErrorCarriesSeconds—0, want 42; and…ReplacesBackoffThe tests spend no wall clock and add no seam
The first version of this branch gave
ClientaretrySleep func(...)field so tests could read the computed delay without sleeping it. Theapi-compatjob from #776 rejects that, and correctly:A
funcfield makes the struct non-comparable. Rather than work around the gate, the seam is gone: the loop already logs the computed delay and then firesOnRetry, both before it sleeps. Cancelling fromOnRetryreturns the wait instantly however long it was, and the delay is read off the"retrying request"log record'sdelayattribute — an exact value, from observables the SDK already emits, at zero production cost.apidiffon the branch as it stands:No test in this file measures elapsed time as an assertion (#783). The one timing check in
retryAfterProbeis a 5s tripwire whose only job is to turn "the wait stopped observing cancellation" into a message instead of a package timeout, and it is one-sided: load can only push a ~2ms measurement toward a bound three orders of magnitude away.Interruptibility: yes, and now proven
The wait is a
selectonctx.Done()andtime.After(delay), so a caller can abandon a long server-directed delay — which matters precisely because no ceiling is applied to it. Theselectis unchanged by this PR; what changed is that it can now be reached holding a five-minute delay instead of a one-second one.It was previously untested. The obvious test does not work: signalling from the handler and cancelling from a goroutine cancels while the response is still in flight, so the loop takes the network-error path, backs off ~1ms, and returns a wrapped
context.Canceledthat satisfies every assertion — that version passes against a plain uninterruptibletime.Sleep, which is how the mutant survived the first round here. Cancelling fromOnRetryis what pins it, and now every case in the file carries that guard.download.go:205and the generated loop are the sameselect; all three honour cancellation.Generated path: not affected, but not identical either
client.gen.go:5805-5813reads and honoursRetry-Afteron 429 inline. It never had this defect,go/templates/client.tmplis untouched, and nothing was regenerated.Two pre-existing differences, reported rather than fixed:
strconv.Atoi), so SPEC §6 step 2's HTTP-date form is dropped — the same shape Fix SPEC §6's Retry-After parsing in Kotlin and TypeScript, and make the fixture able to fail #781 just closed in Kotlin and TypeScript, in a fourth copy of the algorithm that PR did not reach. The raw path resolves both forms; a test here covers it.downloadURLreplace the curve outright.Neither belongs in a bug fix for a dead branch; both are worth an issue.
SPEC
§5's
BasecampErrorRECORD already listedretry_after, with a Go divergence note saying Go omits it. That note is now false and is rewritten.RequestResult.retry_afteris untouched — it stays the hook-facing copy, no longer the only one.Left alone deliberately: §6's "Go on 429 and 503" in the status-set paragraph. It measures
http.go:203, which feeds hooks and the rate limiter, not the sleep — and that paragraph is explicitly #775's.Verification
LC_ALL=Con all of it:make go-check0 ·go-check-drift0 ·go-check-wrapper-drift0 ·doc-constants-check0 ·conformance-go0 (all sixRetry-Aftercases PASS) ·check-retry-metadata-parity0 ·apidiff -magainstorigin/maincompatible.Review follow-up: the conversion is clamped
Two reviewers independently found the same defect in the honour hunk, and it is real:
Retry-After: 9223372036854775807parses cleanly on a 64-bit build andtime.Duration(n) * time.Secondwraps to-1s, sotime.Afterfires at once and the loop spends its whole attempt budget back to back — the newly honoured header turned into a tight retry loop.Both doors onto
Error.RetryAfternow normalize throughclampRetryAfterSeconds:parseRetryAfter, which also feedscheckResponse,downloadURLandRequestResult, and the exportedErrRateLimit, which takes a bareintand could therefore also carry a negative value the field's own doc calls invalid.Over-range that the parser can hold saturates rather than falling back to absent — falling back would compute the ~1ms backoff curve and hammer the peer, the same tight loop by another route — while a digit string the parser's own
int64cannot hold is malformed and falls through to backoff (bf45888bc; the tiers are #793's, the cross-SDK decision #799's). This amends "no ceiling is applied either" above: what is applied is a representability bound, not the policy cap #793 declined. At 2147483647s (~68 years) it rejects nothing a server could sensibly ask for, and SPEC §7 note 4 already carves out bounding against host limits (Swift'sUInt64trap is its worked example). This PR no longer touches that sentence; #793 is the lane that adds Go to it.Four mutants, four kills: the clamp out of
parseRetryAfter(CheckResponse_CarriesRetryAfter/beyond_duration_range), out ofErrRateLimit(ErrRateLimit_NormalizesRetryAfter/{negative, beyond duration range}), out of both — the reported state — (RetryAfterSaturatesAtDurationCeiling, which reports2 delays ([-1s 2.802726ms]), the tight loop itself), and saturate replaced by treat-as-absent (same test,1.964215ms, want 2562047h47m16s). Neither single-guard mutant is caught by the end-to-end case, so each clamp carries its own killing test.The generated client keeps the identical unclamped conversion at
client.gen.go:5810(go/templates/client.tmpl:457), plus an uncheckedAtoifeedingNewRateLimitError. Not fixed here — it needs a template change and a regeneration this PR deliberately does not do — and filed as #798 alongside the two other divergences in that loop reported above.Summary by cubic
Honor Retry-After in Go’s raw GET retry loop and make waits cancellation-first. Previously 429s ignored Retry-After and used millisecond backoff; now the loop sleeps the server’s delay (delta-seconds or HTTP-date), rounds HTTP-date up to whole seconds, clamps to 2,147,483,647s, and checks context before waiting.
basecamp.Error.RetryAfter(seconds).ErrRateLimitandcheckResponseset it; the raw loop uses it when > 0. Remove deadretryableError.clampRetryAfterSeconds; HTTP-date converts with integer math and rounds up. Shared bydownloadURLand the rate-limiter hook.ctx.Err()before the select.Migration
basecamp.Error{...}literals with keyed fields;RetryAfterwas inserted betweenRetryableandRequestID.errors.Asto extract*basecamp.Errorand readRetryAfter. 429 waits can be long; prefer context cancellation or reschedule usingRetryAfter.Written for commit 93d1dde. Summary will update on new commits.