Skip to content

Let Go's raw GET retry loop see the Retry-After it already parses - #796

Merged
jeremy merged 17 commits into
mainfrom
wt/lane-go-retryafter
Aug 21, 2026
Merged

Let Go's raw GET retry loop see the Retry-After it already parses#796
jeremy merged 17 commits into
mainfrom
wt/lane-go-retryafter

Conversation

@jeremy

@jeremy jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member

Closes #795. go/pkg/basecamp/client.go:694 read its server-specified retry delay off a *retryableError. Nothing in the tree ever built one.

$ rg -n "retryableError" go/
go/pkg/basecamp/http.go:115:// retryableError wraps an error with retry metadata.
go/pkg/basecamp/http.go:117:type retryableError struct {
go/pkg/basecamp/http.go:122:func (r *retryableError) Error() string {
go/pkg/basecamp/http.go:126:func (r *retryableError) Unwrap() error {
go/pkg/basecamp/client.go:694:		if re, ok := err.(*retryableError); ok {

The type, its two methods, one type assertion. No &retryableError{. So the branch was unreachable, every 429 landed on the else if apiErr arm, and the loop backed off locally against a server that had named a time. The number was parsed — client.go:847 calls parseRetryAfter — and then spent on a hint string, because Error had 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. downloadURL and the rate-limiter hook honour it too. This was Client.Get/GetAll and the escape hatch, which is why conformance never saw it.

Shape: Error.RetryAfter, and retryableError deleted

Of the two candidates in #775's remedy table, only one of them removes a mechanism. Constructing retryableError at the 429 arm keeps two error types flowing through one loop and changes what singleRequest returns to every other caller — including the mutation path's err.(*Error) at client.go:664. Putting the value on Error makes the *Error arm the only arm, and the dead type goes with the branch that was its only reader.

Nothing is lost by deleting it. Unwrap is load-bearing for errors.Is/errors.As only 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 — ErrRateLimit for the raw path, checkResponse for the generated service layer — so a caller reading err.RetryAfter gets 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 downloadURL and the generated loop. #775 decides 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 running go test ./pkg/basecamp/ -run RetryAfter:

    client_retry_after_test.go:127: computed a 1.42974ms retry delay, want 2s from the Retry-After header (the backoff curve here is ~1ms, so this is the backoff, not the server's number)
--- FAIL: TestClient_RetryAfterReplacesBackoff (0.00s)
    client_retry_after_test.go:147: computed a 1.639404ms retry delay for an HTTP-date 90s out, want ~90s
--- FAIL: TestClient_RetryAfterHTTPDateReplacesBackoff (0.00s)
--- PASS: TestClient_RetryAfterAbsentOrUnusableKeepsBackoff (0.00s)
FAIL	github.com/basecamp/basecamp-sdk/go/pkg/basecamp	1.342s
REAL_EXIT=1

Five mutants, five kills. Files restored by cp and verified with diff -q, never git checkout --.

Mutation Killed by
loop back to backoffDelay unconditionally RetryAfterReplacesBackoff, …HTTPDate… — 1.4ms vs 2s
if apiErr.RetryAfter > 0if true …AbsentOrUnusableKeepsBackoff[0s 0s], backoff collapsed
retry wait's select → plain time.Sleep both probe tests — 2 delays computed where a cancelled request must compute 1
checkResponse drops the field CheckResponse_CarriesRetryAfter/seconds0, want 17
ErrRateLimit drops the field …ErrorCarriesSeconds0, want 42; and …ReplacesBackoff

The tests spend no wall clock and add no seam

The first version of this branch gave Client a retrySleep func(...) field so tests could read the computed delay without sleeping it. The api-compat job from #776 rejects that, and correctly:

Incompatible changes:
- ./pkg/basecamp.Client: old is comparable, new is not

A func field makes the struct non-comparable. Rather than work around the gate, the seam is gone: the loop already logs the computed delay and then fires OnRetry, both before it sleeps. Cancelling from OnRetry returns the wait instantly however long it was, and the delay is read off the "retrying request" log record's delay attribute — an exact value, from observables the SDK already emits, at zero production cost. apidiff on the branch as it stands:

Compatible changes:
- ./pkg/basecamp.Error.RetryAfter: added

No test in this file measures elapsed time as an assertion (#783). The one timing check in retryAfterProbe is 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 select on ctx.Done() and time.After(delay), so a caller can abandon a long server-directed delay — which matters precisely because no ceiling is applied to it. The select is 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.Canceled that satisfies every assertion — that version passes against a plain uninterruptible time.Sleep, which is how the mutant survived the first round here. Cancelling from OnRetry is what pins it, and now every case in the file carries that guard.

download.go:205 and the generated loop are the same select; all three honour cancellation.

Generated path: not affected, but not identical either

client.gen.go:5805-5813 reads and honours Retry-After on 429 inline. It never had this defect, go/templates/client.tmpl is untouched, and nothing was regenerated.

Two pre-existing differences, reported rather than fixed:

Neither belongs in a bug fix for a dead branch; both are worth an issue.

SPEC

§5's BasecampError RECORD already listed retry_after, with a Go divergence note saying Go omits it. That note is now false and is rewritten. RequestResult.retry_after is 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=C on all of it: make go-check 0 · go-check-drift 0 · go-check-wrapper-drift 0 · doc-constants-check 0 · conformance-go 0 (all six Retry-After cases PASS) · check-retry-metadata-parity 0 · apidiff -m against origin/main compatible.

Review follow-up: the conversion is clamped

Two reviewers independently found the same defect in the honour hunk, and it is real: Retry-After: 9223372036854775807 parses cleanly on a 64-bit build and time.Duration(n) * time.Second wraps to -1s, so time.After fires 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.RetryAfter now normalize through clampRetryAfterSeconds: parseRetryAfter, which also feeds checkResponse, downloadURL and RequestResult, and the exported ErrRateLimit, which takes a bare int and 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 int64 cannot 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's UInt64 trap 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 of ErrRateLimit (ErrRateLimit_NormalizesRetryAfter/{negative, beyond duration range}), out of both — the reported state — (RetryAfterSaturatesAtDurationCeiling, which reports 2 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 unchecked Atoi feeding NewRateLimitError. 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.

  • Add basecamp.Error.RetryAfter (seconds). ErrRateLimit and checkResponse set it; the raw loop uses it when > 0. Remove dead retryableError.
  • Parse delta-seconds as digits-only into int64 and clamp via a shared clampRetryAfterSeconds; HTTP-date converts with integer math and rounds up. Shared by downloadURL and the rate-limiter hook.
  • Cancellation-first: both GET and download retry waits check ctx.Err() before the select.
  • Generated clients unchanged (seconds-only parse, jitter, no clamp). SPEC now states step-2 “round up” and clears pending markers.

Migration

  • Replace any unkeyed basecamp.Error{...} literals with keyed fields; RetryAfter was inserted between Retryable and RequestID.
  • When rescheduling from a returned error, use errors.As to extract *basecamp.Error and read RetryAfter. 429 waits can be long; prefer context cancellation or reschedule using RetryAfter.

Written for commit 93d1dde. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings August 19, 2026 06:43
@github-actions github-actions Bot added the go label Aug 19, 2026

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

Fixes Go’s raw GET retry loop so 429 responses honor parsed Retry-After values.

Changes:

  • Adds RetryAfter to structured errors.
  • Removes unreachable retryableError handling.
  • 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.

Comment thread go/pkg/basecamp/client.go
Comment thread go/pkg/basecamp/errors.go

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

Comment thread go/pkg/basecamp/client.go
jeremy added a commit that referenced this pull request Aug 19, 2026
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.
Copilot AI review requested due to automatic review settings August 19, 2026 19:55

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

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-int conversion 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 host int range.
		{name: "beyond duration range", header: "9223372036854775807", want: int(maxRetryAfterSeconds), needsWideInt: true},

Comment thread go/pkg/basecamp/client_retry_after_test.go Outdated
Copilot AI review requested due to automatic review settings August 19, 2026 20:12

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

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:457 still multiplies an unchecked strconv.Atoi result, 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

Comment thread go/pkg/basecamp/errors.go

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

Comment thread go/pkg/basecamp/client.go Outdated
Comment thread SPEC.md Outdated
Copilot AI review requested due to automatic review settings August 19, 2026 20:18

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

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.Get is 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. Run Get in 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.MaxInt seconds (for example, a valid date in 2100): converting the positive float64 to int produces 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 the time.Duration ceiling and math.MaxInt before converting it to int; this is distinct from the oversized decimal case, which Atoi rejects on 32-bit.
			return clampRetryAfterSeconds(seconds)

jeremy added a commit that referenced this pull request Aug 19, 2026
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.
Copilot AI review requested due to automatic review settings August 19, 2026 20:30

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

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, Atoi rejects valid delta-seconds above math.MaxInt before the new clamp can saturate them. For example, Retry-After: 2147483648 falls back to the local backoff instead of clamping to the largest delay the exported int field can represent. Parse in the clamp's int64 domain first; the PR already explicitly supports 386/ARM and bounds the result before converting to int.
	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: clampRetryAfterSeconds also applies math.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: RetryAfter is an int, and the clamp uses math.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

@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Four suppressed Copilot comments — all four taken

No threads to reply in, so they are answered here. All in 48a85d29.

client.go:1130Atoi rejects delta-seconds above math.MaxInt before the clamp can saturate them.

Right, and it made the same header mean two different things by target: Retry-After: 2147483648 was honoured on a 64-bit build and fell onto the millisecond backoff curve on a 32-bit one, because Atoi's range is int's. The parse is now strconv.ParseInt(header, 10, 64), which leaves the ceiling where it belongs — in the clamp, which already bounds the result by what the host can hold. A digit string too long for an int64 is still malformed on every target, which keeps the boundary SPEC §16's device parser draws, and the existing digits beyond int range case still pins it. 64-bit behaviour is unchanged.

client.go:1146 — integer division rounds a future date down.

Right, and the claim about the other SDKs checks out: TypeScript Math.ceil, Kotlin (remainingMs + 999) / 1000, Swift .rounded(.up) — and Kotlin's source states the rule as though it were the convention. SPEC never wrote it down, which is why Go, Python and Ruby all truncate.

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:

--- FAIL: TestParseRetryAfter_HTTPDateRoundsUp (0.00s)
    client_retry_after_test.go:313: parseRetryAfter(a date 5s out) = 4, want 5 — a truncated remainder waits less than the server asked, and under a second it rounds to 0 and is discarded
REAL_EXIT=1

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.

SPEC.md:838 and MIGRATING.md:260 — the saturation bound is architecture-dependent.

Right on both, and they are the same defect in two documents: RetryAfter is an int, the clamp applies math.MaxInt as well as the Duration ceiling, so the bound is ~292 years only where int is 64 bits and ~68 years where it is 32. SPEC now spells the bound min(math.MaxInt64 / time.Second, math.MaxInt) and names which limit binds where; MIGRATING gives both figures and says not to read the larger one as portable.

Gates after all four: LC_ALL=C make go-check 0 · go-check-drift 0 · go-check-wrapper-drift 0 · doc-constants-check 0 · apidiff -m -incompatible clean (still only Error.RetryAfter: added) · GOOS=linux GOARCH=386 and GOARCH=arm vet 0.

Comment thread go/pkg/basecamp/client.go Fixed

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

Comment thread go/pkg/basecamp/client.go Outdated
Comment thread go/pkg/basecamp/client_retry_after_test.go Outdated
Comment thread MIGRATING.md Outdated
Comment thread go/pkg/basecamp/client.go Fixed
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.
Copilot AI review requested due to automatic review settings August 20, 2026 06:01

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

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 line is inaccurate: §6 only says to parse a positive integer, while #799 records over-range handling as an unresolved cross-SDK decision. Label the int64 fallback 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

jeremy added a commit that referenced this pull request Aug 20, 2026
… 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.
@jeremy

jeremy commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Six suppressed Copilot comments across three rounds — four taken, one declined, one already absorbed

Rounds @f56784f (21:27Z), @bf45888 (04:51Z) and @6dd6702 (06:04Z). No threads to reply in, so they are answered here. All in 7c19452.

client.go:729 — the retry wait's select cannot promise "cancellation wins". TAKEN, in both loops.

Right. When both cases are ready Go picks pseudo-randomly, and the loop fires OnRetry immediately before it 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. That request fails fast, but what comes back is the transport's wrapping of context.Canceled, and sometimes the loop goes round again to request failed after 3 attempts. A ctx.Err() check now runs before the select, where nothing competes with it. fetchAPIDownload (download.go:205) has the identical select behind the identical OnRetry, so it takes the identical guard; the generated loop's two selects (go/templates/client.tmpl:416,462) have the same shape but no hook fires before them, so the window there is a concurrent cancellation only — noted for #798, not edited, since it is template-and-regenerate work this PR deliberately does not do.

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 OnRetry and a zero backoff so the timer is already ready, the loop must return ctx.Err() itself (identity, not errors.Is, because the transport's error also satisfies errors.Is) 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, five failures, by run 3 at the latest, on both loops:

--- FAIL: TestClient_RetryWaitChecksCancellationBeforeTheTimer (0.00s)
    client_retry_after_test.go:315: run 3: Get returned request failed after 3 attempts: Network error: Get "http://127.0.0.1:52700/test.json": context canceled, want ctx.Err() itself (context.Canceled) — the loop went round again after the caller cancelled at the retry boundary
--- FAIL: TestClient_RetryWaitChecksCancellationBeforeTheTimer (0.00s)
    client_retry_after_test.go:319: run 0: the transport was handed 2 requests, want 1 — a cancellation delivered before the wait must not be followed by another attempt
--- FAIL: TestDownloadURL_RetryWaitChecksCancellationBeforeTheTimer (0.00s)
    download_test.go:836: run 0: DownloadURL returned Network error: Get "http://127.0.0.1:52921/999/blobs/abc/download/file.png": context canceled, want ctx.Err() itself (context.Canceled) — the loop went round again after the caller cancelled at the retry boundary
REAL_EXIT=1

Against the guard it cannot fail — the check runs before there is anything to pick between — and 20× green, plus go test -race -count=20 -run 'RetryAfter|Cancel' clean. This is also what takes the load-sensitivity out of the probe tests: their 1ms backoff cases could lose the same flip under preemption, and now cannot.

client.go:1186 — positive ErrRange should saturate, not fall to backoff. DECLINED, already decided.

This is the reading bf45888b reverted (04:47Z note above), and the reason has not changed: a digit string the parser's own int64 cannot hold is the first of the two tiers #793 states in §6 "Retry-After Honouring" — malformed, step 3 — and #793 names Go as rejecting above its 64-bit integer, so saturating here would falsify that sentence on contact. Accurately bounded: this is Go's behaviour and #793's rule; main's §6 today says only "parse a positive integer", and the cross-SDK over-range decision is open in #799. Which is the next item.

MIGRATING.md:263 and client.go:1164 — "SPEC §6 draws that line" overclaims. TAKEN.

Right: §6's parsing algorithm on main draws no such line, and #799 records over-range as unresolved across the SDKs. Both now say the split is Go's, that it is the two-tier rule #793 states in §6 "Retry-After Honouring" (unrepresentable → malformed, unschedulable → saturate), and that #799 tracks the convergence — worded to be true on either side of #793 landing. The same phrase sat in two test comments; corrected with them. Two more stale comments from the 21:07–21:14Z rounds that never got a note went the same way: the loop and the wait said Retry-After carries "no ceiling" (it carries no policy ceiling; the representability clamp exists), and parseRetryAfter's doc said it clamps to what a Duration can hold, where the constant is the smaller portable bound.

client.go:1081 — the PR description said Go was added to §7's note 4. TAKEN, in the description.

Right, that was true of an earlier revision and 4aa068d0 dropped the hunk — the description kept the claim. Edited in place; the sentence now reads that this PR no longer touches that note and #793 is the lane that adds Go to it (its branch already does), and the same sentence's "~292 years" became the shipped 2147483647s / ~68 years with the tier-1 clause stated. Nothing in the tree changes for this one.

client_retry_after_test.go:279 and :101 (21:27Z round) — one absorbed, one taken.

:101's "~292 years" was already corrected to ~68 in 6dd6702b (its message says so). :279 was not: the far-future HTTP-date test asserted only <= maxRetryAfterSeconds, which any positive value satisfies — including a parser returning an arbitrary "safe" delay instead of saturating. It now asserts equality, which is exact and deterministic because time.Until saturates at the Duration maximum for a year-9999 date, far past the ceiling.

Merge state with #793. wt/lane-spec advanced to 7d2c37f6 since the last note, and its new [PENDING #796 …] paragraph now describes this PR as shipped — ParseInt into int64, over-range malformed, clamp at math.MaxInt32 — so the one-token correction the 04:47Z note asked for is absorbed. git merge-tree --write-tree --messages wt/lane-spec wt/lane-go-retryafterMT_REAL_EXIT=1, still exactly one CONFLICT (content) in SPEC.md, still the same adjacency hunk: #793 replaces the "This algorithm defines parsing only" paragraph and appends its section; this PR adds the step-2 rounding paragraph immediately above it. Resolution unchanged — take #793's block, keep the rounding paragraph above it. One more thing that rebase owes: #793 now marks its two "Go saturation" sentences [PENDING #796] (the §6 worked example and §7's note 4), because it merges first and its tree does not contain this PR. Those markers come out in this PR's rebase onto main after #793 lands — they are not anything this PR's branch carries today. One more flag for whoever takes #799: its Go row ("saturates at 2147483647s … in the parser") predates bf45888b and should read "rejects above int64, saturates in-range above math.MaxInt32".

Gates, REAL_EXIT read back from each log: go-check 0 · go-check-drift 0 · go-check-wrapper-drift 0 · doc-constants-check 0 · GOOS=linux GOARCH=386 vet 0 · GOARCH=arm vet 0 · apidiff -m -incompatible 0 with empty output, full diff still only Error.RetryAfter: added · go test -race -count=20 -run 'RetryAfter|Cancel' ./pkg/basecamp/... 0.

@jeremy
jeremy requested a balanced review from Copilot August 20, 2026 19:33
jeremy added a commit that referenced this pull request Aug 20, 2026
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.

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

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 *Error with fmt.Errorf after retry exhaustion (client.go:755), even when the attempt cap is one; the new test correctly uses errors.As for this reason. Describing the result as a returned *Error suggests direct access/type assertion, which will fail. Document the required errors.As extraction before reading RetryAfter.
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.

@jeremy

jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

One suppressed Copilot comment on 7c1945224, taken in cf2a1e874: MIGRATING.md:284 said err.RetryAfter is on the "returned *Error". Correct — the raw GET loop wraps the final error with fmt.Errorf on exhaustion (client.go:755), even at a cap of one attempt, and this PR's own test uses errors.As for that reason. The paragraph now says so and shows the errors.As extraction. Doc-only; LC_ALL=C make doc-constants-check exit 0.

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

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

jeremy added a commit that referenced this pull request Aug 21, 2026
…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
@jeremy
jeremy requested a balanced review from Copilot August 21, 2026 16:17
@jeremy

jeremy commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Brought up to main after #793 landed (19896d7f5), as a merge rather than a rebase so the per-commit history stays intact for the squash body — replaying the early commits against #793's final text conflicted at every step in places the final states do not.

92a205115 resolves the one SPEC.md hunk exactly as both PRs said it would: this PR's step-2 rounding paragraph kept, #793's replacement "parsing only" paragraph and new "Retry-After Honouring" section taken beneath it. Nothing else collided.

93d1dde88 clears the three [PENDING #796] markers SPEC carried for this PR (the representability observation at §6, the Go worked-example paragraph, and §7's requirement 4). Each said "until #796 lands, the hand-written path is unclamped and this paragraph describes the contract, not the tree" — true until this merge and false after it, which is exactly the class-A current-value claim this repo's marker discipline exists to keep out of prose. Doing it in the commit whose landing makes them true avoids a window where SPEC is wrong. Wording only; no contract changed.

LC_ALL=C make doc-constants-check and make sync-api-version-check exit 0 on the merged tree.

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

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

@jeremy
jeremy merged commit 8fcb39a into main Aug 21, 2026
46 checks passed
@jeremy
jeremy deleted the wt/lane-go-retryafter branch August 21, 2026 16:22
@jeremy jeremy added the breaking Breaking change to public API label Aug 22, 2026
jeremy added a commit that referenced this pull request Aug 22, 2026
* 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
jeremy added a commit that referenced this pull request Aug 22, 2026
…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)
jeremy added a commit that referenced this pull request Aug 22, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change to public API go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Go's raw GET retry loop reads Retry-After off a type nothing constructs, so no 429 delay is ever honoured

3 participants