Retry: hand out a copy of the declared status set, and run the parity guard in CI - #500
Conversation
… guard in CI Two tails from #486, both left behind when it merged with nine unresolved review threads. Neither is user-visible today; both are worth closing before the next release. GetOperationRetryOn returned the slice stored in the package-level operationRetryOn map, and isRetryableStatus reads that same table on every response. So any caller who inspected the declared set could rewrite the retry policy for every client in the process — and race with in-flight requests while doing it. Reproduced from an external module before fixing: an always-429 server saw three requests, then one after `set[0], set[1] = 599, 599`. It is the only exported accessor in the generated package that hands out a reference type from a package-level table; RetryMax returns an int and Metadata a struct of bools. Return a copy. `append([]int(nil), r...)` rather than slices.Clone because the generated import block comes from oapi-codegen's base template, and this needs no new import. The early return keeps `ok` — not nilness — as the signal that distinguishes a declared-empty set from an absent one, which is the distinction isRetryableStatus depends on. The regression test fails against the unfixed accessor with "a second lookup returns [599 599], want [429 503]", and covers the behavioral half too: after a caller mutates the result, a 429 must still retry. Passes under -race. Separately, check-retry-metadata-parity never ran anywhere but a developer's `make check`. It is reachable only through the root check target, and no workflow invokes that — `grep -rn "check-retry-metadata-parity\|make check" .github/` returned nothing. So retry metadata could drift into main unnoticed, which is the exact failure the guard was added to prevent. Wire it into the Go job beside the idempotency guard it was modelled on. The residual risk was narrower than it first looked, and worth stating: retry_on is uniform [429, 503] across all 226 operations, so a template regression would be global and the generated_retry_statusset_test behavioral tests already catch it in CI. What the guard uniquely covers is a generator bug in the TS, Kotlin, Swift, or Ruby metadata values, plus the token-smoke consumption criterion.
Sensitive Change Detection (shadow mode)This PR modifies control-plane files:
|
There was a problem hiding this comment.
Pull request overview
This PR addresses two retry-metadata hardening gaps: it prevents external callers from mutating Go’s per-operation retry status tables via GetOperationRetryOn, and it ensures the existing cross-SDK retry metadata parity guard actually runs in CI.
Changes:
- Go:
GetOperationRetryOnnow returns a defensive copy of the declared retryable status set to prevent global policy mutation and potential races. - Go: adds a regression test proving the accessor doesn’t leak the shared slice and that 429 retry behavior remains intact after attempted mutation.
- CI: runs
make check-retry-metadata-parityin thetest-goworkflow job so retry metadata drift is caught on PRs.
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 4 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| Makefile | Documents the retry-metadata parity guard as CI-enforced. |
| go/templates/client.tmpl | Updates the generated Go template so GetOperationRetryOn returns a copied slice. |
| go/pkg/generated/client.gen.go | Regenerates the Go client with the safe GetOperationRetryOn implementation. |
| go/pkg/basecamp/generated_retry_statusset_test.go | Adds a regression test covering slice non-leakage and preserved retry behavior. |
| .github/workflows/test.yml | Wires the retry-metadata parity guard into the test-go CI job. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
go-check-wrapper-drift is reachable only through the root check: prereq list, and no workflow runs make check -- the same gap #500 closed for check-retry-metadata-parity. The only field-level gate comparing hand-written wrappers in go/pkg/basecamp/ against generated structs in go/pkg/generated/client.gen.go ran nowhere, so wrapper drift could reach main unnoticed. Insert it into test-go between the service-layer and generated-client drift checks, mirroring the check: prereq order. working-directory: . because the job defaults to go/ and the checker module resolves via go.work at the repo root. No new actions, no Makefile changes; the gate regenerates nothing and writes nothing (identical tree digests before/after a run on a pristine archive).
Two tails from #486. It merged with nine of ten review threads unresolved; an audit of all of them found exactly two real defects, and these are they. Neither is user-visible today.
1.
GetOperationRetryOnhanded out the shared tablego/pkg/generated/client.gen.goreturned the slice stored in the package-leveloperationRetryOnmap, andisRetryableStatusreads that same table on every response. Any caller who inspected the declared set could rewrite the retry policy for every client in the process, and race with in-flight requests while doing it.Reproduced from an external module before fixing — an always-429 server:
It is the only exported accessor in the generated package that returns a reference type from a package-level table —
GetOperationRetryMaxreturns anint,GetOperationMetadataa struct of two bools. Confirmed by sweeping for exported funcs returning slices or maps: one hit.Fixed in
go/templates/client.tmpland regenerated.append([]int(nil), r...)rather thanslices.Clonebecause the generated import block comes from oapi-codegen's base template and this needs no new import. The early return keepsok— not nilness — as the signal separating a declared-empty set from an absent one, which is whatisRetryableStatusdepends on.The regression test is a real red proof. Against the unfixed accessor it fails with:
It also covers the behavioural half — after a caller mutates the result, a 429 must still retry — and passes under
-race.2. The parity guard never ran in CI
scripts/check-retry-metadata-parity.pywas reachable only through the rootchecktarget, and no workflow invokes it.grep -rn "check-retry-metadata-parity\|make check" .github/returned nothing. Retry metadata could therefore drift into main unnoticed — the exact failure the guard was added to prevent. Now wired into the Go job beside the idempotency guard it was modelled on, and the Makefile comment matches that sibling's "enforced in CI" convention.Stating the residual honestly, because it is narrower than it first appears:
retry_onis uniform[429, 503]across all 226 operations, so a Go template regression would be global andgenerated_retry_statusset_test.goalready catches it in CI. What this guard uniquely covers is a generator bug in the TypeScript, Kotlin, Swift, or Ruby metadata values, plus the criterion-2 consumption token-smoke. That makes it low severity, not medium.Verification
go test ./pkg/basecamp/ -run TestGeneratedRetryOn -racego test ./...make go-check(clean lint cache)make go-check-generated-driftmake check-retry-metadata-paritymake lint-actions(actionlint + zizmor)The regenerated diff is exactly the accessor — 13 insertions, 3 deletions, no incidental churn.
Deliberately not touched
python/README.md:452still says GETs retry on "retryableApiError(500, 502, 503, 504)", but the merged gate is the declared{429, 503}set. That is a real stale doc introduced by Retry: honor the declared retry_on and max_attempts in Go and Python #486, but it is out of scope here and belongs in its own change..github/workflows/is a control-plane path, so the sensitive-change gate will comment — informational, shadow mode.Summary by cubic
Return a copy from
GetOperationRetryOnto prevent global retry policy mutation and data races. Add a CI step to enforce retry metadata parity across SDKs.Bug Fixes
GetOperationRetryOnnow returns a copy of the per-operation status set, preserving theoksignal to distinguish empty vs. absent.New Features
make check-retry-metadata-parityto enforce cross-SDK retry metadata parity alongside the idempotency guard.Written for commit 12fcdf5. Summary will update on new commits.