Retry: honor the declared retry_on and max_attempts in Go and Python - #486
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4dfa5f9288
ℹ️ 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
This PR fixes retry behavior drift in the Go and Python SDKs by honoring each operation’s declared retry_on status set and max_attempts ceiling (as defined in behavior-model.json), and adds a parity guard to keep generated retry metadata consistent across all SDKs. It also updates SPEC.md to state the declared retry_on set is exhaustive and to define attempt resolution as a ceiling (not a floor).
Changes:
- Go: extend generated
OperationMetadatawithMaxAttempts/RetryOn, gate retries on the per-operation declared status set, and cap attempts viamin(configured_attempts, operation_max). - Python (sync + async): introduce a shared
_retry_policymodule, apply declared status gating + attempt ceiling to generated traffic, and explicitly keepget_absolute()(OAuth) ungoverned. - Add
scripts/check-retry-parity(wired intomake check) to assert generated retry metadata parity across all six SDKs and enforce the “readonly uniform policy” invariant used by Python’s read path.
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 10 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
SPEC.md |
Updates Gate 3 to treat retry_on as exhaustive, defines attempt ceiling resolution, and documents current cross-SDK consumption. |
Makefile |
Adds check-retry-parity and wires it into make check. |
scripts/check-retry-parity.sh |
Shell wrapper to run the Ruby parity guard with a Ruby presence check. |
scripts/check-retry-parity.rb |
New guard asserting per-operation retry metadata parity across SDKs and readonly uniformity invariant. |
go/templates/client.tmpl |
Template changes to generate RetryOn/MaxAttempts into operation metadata and use them in retry gating/capping. |
go/pkg/generated/client.gen.go |
Regenerated Go client implementing per-operation retry gating and attempt ceiling via OperationMetadata. |
go/pkg/basecamp/generated_retry_test.go |
Adds Go tests pinning declared-status gating and operation max ceiling behavior. |
python/src/basecamp/_retry_policy.py |
New module modeling per-operation retry policy + ungoverned policy and attempt/status decision helpers. |
python/src/basecamp/_http.py |
Applies policy-aware retry gating/capping for sync HTTP client; keeps OAuth traffic ungoverned. |
python/src/basecamp/_async_http.py |
Same policy-aware retry logic for async HTTP client. |
python/tests/test_http.py |
Updates old “retry on 500” expectation and adds comprehensive sync/async read/write/ceiling/ungoverned regression coverage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
All reported issues were addressed across 11 files
You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
Correction to the PR description: I stated Fixed by running |
4dfa5f9 to
8b117ca
Compare
|
Re-verified after the formatting fix: |
Review carefully before merging. Consider a major version bump. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8b117ca8c0
ℹ️ 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".
d2ea6f6 to
3edf1b3
Compare
8b117ca to
55ad0b7
Compare
f9ef4b3 to
a9c0941
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a9c09410dc
ℹ️ 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 8 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
go/templates/client.tmpl:669
- GetOperationRetryOn returns the underlying slice stored in the internal operationRetryOn table. Because slices are mutable, callers can inadvertently (or concurrently) modify the SDK’s retry policy globally, which can also introduce data races if the client is used across goroutines. Return a defensive copy of the slice instead.
// GetOperationRetryOn returns the declared retryable status set for the given
// operation ID. ok is false when the operation carries no per-op set.
func GetOperationRetryOn(operationId string) ([]int, bool) {
r, ok := operationRetryOn[operationId]
return r, ok
}
6b71f7a to
8af46a1
Compare
a9c0941 to
91784cb
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
go/templates/client.tmpl:864
- GetOperationRetryOn returns the underlying []int stored in the global operationRetryOn map. Because this accessor is exported, callers can mutate the returned slice and silently change the SDK’s retry gate for all future requests (the slice shares the same backing array). Return a defensive copy instead.
// GetOperationRetryOn returns the declared retryable status set for the given
// operation ID. ok is false when the operation carries no per-op set.
func GetOperationRetryOn(operationId string) ([]int, bool) {
r, ok := operationRetryOn[operationId]
return r, ok
}
go/templates/client.tmpl:851
- The template only emits retryOn when
index $retry "retryOn"is truthy. In Go templates, an explicitly empty list is falsey, so a declaredretryOn: []would be treated as absent and the runtime would incorrectly fall back to defaultRetryOn. Since isRetryableStatus explicitly distinguishes “absent” vs “present-but-empty”, the generator should preserve an empty list when the key exists (i.e., test for nil, not truthiness).
This issue also appears on line 859 of the same file.
{{if index $retry "retryOn" -}}
{{range $i, $status := index $retry "retryOn" -}}
{{if $i}}{{$retryOn = printf "%s, %v" $retryOn $status}}{{else}}{{$retryOn = printf "%v" $status}}{{end -}}
{{end -}}
{{end -}}
Stacked on the per-operation ceiling work, which fixed retry.max. This
fixes the other half of Gate 3: the status set.
behavior-model.json declares retry_on: [429, 503] for all 226 operations.
Go used a global {429, 500, 502, 503, 504} allowlist regardless of what an
operation declared, and Python had no status gate at all — its loop keyed
off the retryable flag errors.py sets for 500/502/503/504. Both retried
three statuses the spec never sanctioned.
Go emits the declared set as operationRetryOn, a sibling of
operationRetryMax and likewise kept off the exported OperationMetadata
struct so wiring it up cannot break external unkeyed composite literals.
isRetryableStatus now takes the operationId and consults it.
Python gates on the same declared set. errors.py is untouched: its
retryable flag is the caller-facing classification and must not widen the
transport's gate. A network error carries no status and stays retryable
under the section 7 network-error rule.
Ungoverned traffic is protected structurally rather than by convention.
get_absolute() — the hand-written Launchpad authorization request — passes
no operation id, so both the status gate and the attempt ceiling no-op on
it and it keeps its pre-Smithy contract. Four regressions, sync and async,
fail if generated policy ever reaches OAuth traffic; they pass identically
before and after this change, which is the point.
check-retry-metadata-parity gains a Go operationRetryOn parity check and
reclassifies Go and Python from "max only" to "max + retry_on". Its
forbidden-token list previously asserted Go does NOT consume RetryOn,
which this makes false.
SPEC: removed "Implementations may expand this set to include other 5xx
statuses" — that sentence sanctioned the bug. Added the Gate 3 consumption
table: five SDKs now gate on the declared retryOn, Ruby does not; only Go
and Python apply maxAttempts as a ceiling rather than a replacement.
Tracked in #485.
Breaking: callers relying on a 500 being retried get one attempt.
91784cb to
904f38b
Compare
|
Audited all nine unresolved threads against the merged tree. Six distinct claims; two shipped, four did not.
Both real findings are fixed in #500, with a regression test that fails against the unfixed accessor ( Why nine. The branch was force-pushed 7 times over 16 hours and bots reviewed 8 distinct SHAs. Each force-push orphans a thread's anchor commit, so GitHub marks it outdated but never resolved, and the bots refile the same finding against the new head — the slice-aliasing point alone appears three times from three reviewers. Two files that drew comments ( Two things worth carrying forward:
The merge window is the actual process failure. Copilot reviewed Separately, and out of scope for #500: |
… guard in CI (#500) 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.
…502) * Python README: state the retry contract the code actually implements The Retry Behavior section still described the pre-#486 contract. Three claims were wrong or incomplete, all the same failure: the contract changed and the README did not follow. "Retried on RateLimitError (429), NetworkError, and retryable ApiError (500, 502, 503, 504)" — 500, 502 and 504 have not been retried since #486. The gate is the operation's declared retry_on set, which every one of the 226 operations declares as [429, 503], and it is exhaustive in both directions. A reader planning around this would have expected three retries on a 500 and got one. "Max retries - Controlled by config.max_retries" — #483 made the effective budget min(config.max_retries, the operation's declared max). This is not hypothetical: 43 of 226 operations declare max 2 against a default cap of 3, so they retry once fewer than the README promised. The error table itself was accurate — 500 really is ApiError(retryable=True) — but it now reads as a retry prediction when it is only a taxonomy. That gap is what makes the first bullet's error believable, so `retryable` now says what it is: a hint for your code, not a statement about the transport. SPEC.md:432 already drew this line; the README hadn't. Verified rather than asserted, after a first probe passed a Response where the signature wants an int status and wrongly reported 500 as non-retryable — which would have turned correct documentation into incorrect documentation. Corrected: 429 -> RateLimitError, 500/502/503/504 -> ApiError, all retryable=True, 418 False. Metadata confirms 226/226 declare [429, 503] and max is 3 (183 ops) or 2 (43). Behavior is pinned by TestDeclaredRetryStatuses ([500-1], [502-1], [504-1], [429-3], [503-3]) and TestPerOperationRetryMax (CapTwoOp-5-2). make py-check: 508 passed. * Python README: scope the no-retry claim to API operations Both reviewers caught the same real error, and they were right. The claim that a 500 "is not retried" was categorical, but it only holds for governed operations. client.authorization.get() reaches the Launchpad endpoint through get_absolute(), which passes no operation id, so _is_retryable_error falls back to the error classification and a 500 does retry there. That is a public flow, not internal plumbing, so the previous wording misdocumented it. Measured both paths rather than reasoning about them: UNGOVERNED launchpad 500 -> 3 attempts GOVERNED GetProjects 500 -> 1 attempt GOVERNED GetProjects 429 -> 3 attempts Scoped the status and ceiling rules to Basecamp API operations, and added a bullet for the authorization endpoint stating what it actually does. The `retryable` note is scoped the same way, since its example is the 500 case. I knew about this carve-out — it is commented in _http.py and specified at SPEC.md:454 — and judged it too internal for a consumer README. That was the wrong call: `client.authorization` is public API.
The §7 paragraph I added claimed the floor-before-ceiling formula
min(max(1, cap), op_max) holds for every SDK it names, and that the floor
does not depend on the operation declaring a retry block. That is true of
Go, Python and Ruby. It is not true of Kotlin, which computes
minOf(config.maxRetries.coerceAtLeast(1), opRetry?.maxRetries ?: config.maxRetries)
so an ungoverned operation at a cap of 0 yields minOf(1, 0) = 0. Kotlin
still makes the one request, because its loop fires the attempt before it
consults the budget — §14's Kotlin row is right, the formula-level claim
was not. Say both halves.
The two "ungoverned traffic keeps the pre-metadata contract" lines are now
imprecise about the budget: the floor applies there too. Scope them to the
status half and name the floor.
ruby/README.md's retry bullets were flatly wrong in three places, all
predating this branch:
- "500 ... and any other 5xx all retry" — a governed GET does not retry
500. test_governed_get_does_not_retry_500 has pinned that since #486.
- the declared policy "is inert in Ruby — every API GET ... rides the
same classification-based loop" — generated services pass canonical
operation IDs, so essentially every SDK GET is governed and gated on
the declared retryOn [429, 503]. Only get_absolute and the Launchpad
authorization fetch it backs are ungoverned.
- "the raw upload and download paths skip the retry loop entirely" —
get_download goes through request_with_retry under DOWNLOAD_RETRY_ON.
Only the upload path and the signed-URL second hop are single-request.
check-retry-metadata-parity.py's docstring carried the same "Ruby consumes
NONE" claim its own RUNTIME_CONSUMPTION table contradicts. And http.rb no
longer floors the cap "for downloads" — that is every path now.
…656) * Ruby: max_retries: 0 made zero requests on an ungoverned GET (#532) A client configured with `max_retries: 0` made no HTTP request at all on any ungoverned GET and raised `Request failed after 0 attempts`. The governed branch floored the cap at one attempt; the ungoverned branch of the same expression used the raw config value, so `break if attempt > max_attempts` fired before the first request. Whether a request reached the wire depended on whether the operation carried a declared retry block — same client, same method, same config. Floor the cap on every path. The `retry_on` and ungoverned branches only ever shared `caller_cap`, so the three-branch expression collapses to a ternary; `retry_on` stays load-bearing for the retryable-status set. Ruby now matches Kotlin's `coerceAtLeast(1)`. The tests assert the request count, not the error class: the un-fixed path raises the same class from the same method, so only "did a request happen" separates them. The governed test passes against un-fixed code and pins the two branches together. SPEC §2 drops to two divergent outcomes across four implementations; ruby/README.md no longer documents the zero-request outcome as behavior. * Correct the two places that still described the ungoverned zero-attempt path SPEC §14's per-SDK download attempt budget still said Ruby floors at one "for downloads" and that the general ungoverned GET path's zero-attempt behavior was tracked separately. §2 rule 4 now says the floor applies on every path, so the normative spec contradicted itself. The download test carried the same claim in a comment, citing #532 as the tracker for behavior this change removed. The rest of the sweep found no other statement of the old behavior. §7's per-op ceiling notes say governed paths coerce the cap to at least one attempt, which remains true and is about ceiling arithmetic that only exists where an operation declares a retry block. * SPEC §7: the attempt floor is universal, the ceiling is not Two lines in the per-operation ceiling section still scoped the floor to governed paths. Not false about governed paths, but misleading by scoping in a normative document — and that exact scoping was the defect this PR fixes. §2 and §14 already say the floor is universal; these were the last places implying otherwise. The ceiling really is governed-only (it needs a declared retry block); the floor is not. Separate the two rather than describing both as governed. * Say what the attempt floor actually does, per SDK The §7 paragraph I added claimed the floor-before-ceiling formula min(max(1, cap), op_max) holds for every SDK it names, and that the floor does not depend on the operation declaring a retry block. That is true of Go, Python and Ruby. It is not true of Kotlin, which computes minOf(config.maxRetries.coerceAtLeast(1), opRetry?.maxRetries ?: config.maxRetries) so an ungoverned operation at a cap of 0 yields minOf(1, 0) = 0. Kotlin still makes the one request, because its loop fires the attempt before it consults the budget — §14's Kotlin row is right, the formula-level claim was not. Say both halves. The two "ungoverned traffic keeps the pre-metadata contract" lines are now imprecise about the budget: the floor applies there too. Scope them to the status half and name the floor. ruby/README.md's retry bullets were flatly wrong in three places, all predating this branch: - "500 ... and any other 5xx all retry" — a governed GET does not retry 500. test_governed_get_does_not_retry_500 has pinned that since #486. - the declared policy "is inert in Ruby — every API GET ... rides the same classification-based loop" — generated services pass canonical operation IDs, so essentially every SDK GET is governed and gated on the declared retryOn [429, 503]. Only get_absolute and the Launchpad authorization fetch it backs are ungoverned. - "the raw upload and download paths skip the retry loop entirely" — get_download goes through request_with_retry under DOWNLOAD_RETRY_ON. Only the upload path and the signed-URL second hop are single-request. check-retry-metadata-parity.py's docstring carried the same "Ruby consumes NONE" claim its own RUNTIME_CONSUMPTION table contradicts. And http.rb no longer floors the cap "for downloads" — that is every path now.
Closes #479. Stacked on #484 → #483 →
main.#483 fixed the per-operation attempt ceiling. This fixes the other half of Gate 3: the status set.
What was wrong
behavior-model.jsondeclaresretry_on: [429, 503]for all 226 operations. Neither Go nor Python consulted it:isRetryableStatuscovering{429, 500, 502, 503, 504}(client.gen.go:3964-3974), regardless of what an operation declared.e.retryable, anderrors.py:153-156marks 500 and 502/503/504 retryable.So both retried three statuses the spec never sanctioned.
What this does
Go. The declared set is emitted as
operationRetryOn, amap[string][]intsibling of #483'soperationRetryMax— and for the same reason kept off the exportedOperationMetadatastruct, so wiring it up cannot break an external unkeyed composite literal.isRetryableStatusnow takes theoperationIdand consults it, falling back todefaultRetryOnfor an id absent from the table (unreachable from a generated call site).Python.
_is_retryable_error(error, operation)gates on the operation's declaredretry_on, resolved by_operation_retry_on(operation)from the metadata #483 already threads through every call site. Because #483 threads a canonical operation id intoget()as well, the read path is governed too — no separate mechanism.errors.pyandParseHTTPErrorare untouched. They still classify 500/502/503/504 as retryable to callers. That is the caller-facing hint; it must not widen the transport's gate.error-mapping.jsonconformance cases pass unchanged.Network errors stay retryable. They carry no HTTP status, so the status gate does not apply — SPEC §7's network-error rule still governs.
Ungoverned traffic is protected structurally, not by convention
get_absolute()— the hand-written Launchpad authorization request — passes no operation id. So both the status gate and #483's attempt ceiling no-op on it automatically, and it keeps its pre-Smithy contract. That is a better outcome than the explicit policy object this PR carried in its first revision, and it falls out of #483's threading rather than needing new machinery.Four regressions (sync and async) pin it. They pass identically before and after this change — which is the point: they exist to fail if generated policy ever reaches OAuth traffic.
Guard
Extended #483's
scripts/check-retry-metadata-parity.pyrather than adding a second script:from_go_retry_on()extractor +check_retry_on_only()parity check foroperationRetryOn;max only→max + retry_on. Go + Python: honor per-operation retry.max as a ceiling; add parity guard #483'sforbiddentoken list asserted Go does not consumeRetryOn; this makes that false, so leaving it would have failed the build.Proof matrix — all fail against the un-fixed code
Python, the full 2×2 (sync/async × mutation/read):
Go:
made 3 attempts for status 500, want 1(likewise 502 and 504), plus a read-path case onGetAccount.SPEC
opRetry?.maxRetries ?: config.maxRetries). TypeScript and Swift expose no numeric cap, so there is nothing to discard; Ruby's gap is the mirror image, honoring the caller and never consulting the operation. Kotlin applies per-operation maxAttempts as a floor; Ruby ignores it entirely #485 has been rescoped accordingly, and Go + Python: honor per-operation retry.max as a ceiling; add parity guard #483's contradicting rationale paragraph corrected on that branch.Note for the merge
The base is a stack branch, so GitHub shows no closing reference for #479. Retarget to
mainbefore final merge, or close #479 explicitly with landed evidence.make checkexits 0 — real exit code, captured into the log rather than read from a trailingecho.