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".
| MaxAttempts int | ||
| // RetryOn is the operation's declared retryable status set from | ||
| // x-basecamp-retry. Statuses outside it are never retried. | ||
| RetryOn []int |
There was a problem hiding this comment.
Return a copy of generated retry statuses
When an SDK consumer calls GetOperationMetadata and modifies an element of the returned RetryOn slice, it modifies the backing array stored in the package-global operationMetadata map. Subsequent requests for that operation then use the caller-modified retry policy (and concurrent requests can race on it); for example, assigning meta.RetryOn[0] = 500 makes 500 retryable and stops treating 429 as retryable. Clone the slice before exposing it from the generated getter, implementing the change in this template rather than patching generated output.
AGENTS.md reference: AGENTS.md:L70-L70
Useful? React with 👍 / 👎.
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.
3 issues found 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.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="scripts/check-retry-parity.rb">
<violation number="1" location="scripts/check-retry-parity.rb:96">
P1: `make check` cannot run this new guard: scanning Go generated source raises an invalid US-ASCII byte-sequence error before checking metadata. Read the file as UTF-8 before applying the regex.</violation>
</file>
<file name="go/pkg/generated/client.gen.go">
<violation number="1" location="go/pkg/generated/client.gen.go:19593">
P1: `OperationMetadata` now includes a mutable `[]int` `RetryOn` slice, so returning metadata by value leaks a reference to the package-global backing array. A caller can mutate `meta.RetryOn` and change retry behavior for subsequent requests, and concurrent callers can race on that shared slice. Cloning `RetryOn` in `GetOperationMetadata` before returning would keep the metadata read-only to consumers.</violation>
</file>
<file name="python/src/basecamp/_retry_policy.py">
<violation number="1" location="python/src/basecamp/_retry_policy.py:71">
P2: An operation declaring an empty `retry_on` set would retry 429/503 anyway, rather than retrying no HTTP statuses. Preserve an explicit empty list; only default when the field is absent.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
|
|
||
| def parse_go(path) | ||
| # "OpId": {Idempotent: …, HasSensitiveParams: …, MaxAttempts: 3, RetryOn: []int{429, 503}}, | ||
| File.read(path).scan( |
There was a problem hiding this comment.
P1: make check cannot run this new guard: scanning Go generated source raises an invalid US-ASCII byte-sequence error before checking metadata. Read the file as UTF-8 before applying the regex.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/check-retry-parity.rb, line 96:
<comment>`make check` cannot run this new guard: scanning Go generated source raises an invalid US-ASCII byte-sequence error before checking metadata. Read the file as UTF-8 before applying the regex.</comment>
<file context>
@@ -0,0 +1,242 @@
+
+def parse_go(path)
+ # "OpId": {Idempotent: …, HasSensitiveParams: …, MaxAttempts: 3, RetryOn: []int{429, 503}},
+ File.read(path).scan(
+ /"([A-Za-z0-9_]+)":\s*\{[^}]*?MaxAttempts:\s*(\d+),\s*RetryOn:\s*\[\]int\{\s*([0-9,\s]*?)\s*\}/
+ ).each_with_object({}) do |(op, max, statuses), acc|
</file context>
| MaxAttempts int | ||
| // RetryOn is the operation's declared retryable status set from | ||
| // x-basecamp-retry. Statuses outside it are never retried. | ||
| RetryOn []int |
There was a problem hiding this comment.
P1: OperationMetadata now includes a mutable []int RetryOn slice, so returning metadata by value leaks a reference to the package-global backing array. A caller can mutate meta.RetryOn and change retry behavior for subsequent requests, and concurrent callers can race on that shared slice. Cloning RetryOn in GetOperationMetadata before returning would keep the metadata read-only to consumers.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go/pkg/generated/client.gen.go, line 19593:
<comment>`OperationMetadata` now includes a mutable `[]int` `RetryOn` slice, so returning metadata by value leaks a reference to the package-global backing array. A caller can mutate `meta.RetryOn` and change retry behavior for subsequent requests, and concurrent callers can race on that shared slice. Cloning `RetryOn` in `GetOperationMetadata` before returning would keep the metadata read-only to consumers.</comment>
<file context>
@@ -19564,238 +19585,244 @@ type OperationMetadata struct {
+ MaxAttempts int
+ // RetryOn is the operation's declared retryable status set from
+ // x-basecamp-retry. Statuses outside it are never retried.
+ RetryOn []int
}
</file context>
| retry_on = retry.get("retry_on") | ||
| return RetryPolicy( | ||
| max_attempts=max_attempts if isinstance(max_attempts, int) and max_attempts > 0 else DEFAULT_MAX_ATTEMPTS, | ||
| retry_on=frozenset(retry_on) if retry_on else DEFAULT_RETRY_ON, |
There was a problem hiding this comment.
P2: An operation declaring an empty retry_on set would retry 429/503 anyway, rather than retrying no HTTP statuses. Preserve an explicit empty list; only default when the field is absent.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At python/src/basecamp/_retry_policy.py, line 71:
<comment>An operation declaring an empty `retry_on` set would retry 429/503 anyway, rather than retrying no HTTP statuses. Preserve an explicit empty list; only default when the field is absent.</comment>
<file context>
@@ -0,0 +1,99 @@
+ retry_on = retry.get("retry_on")
+ return RetryPolicy(
+ max_attempts=max_attempts if isinstance(max_attempts, int) and max_attempts > 0 else DEFAULT_MAX_ATTEMPTS,
+ retry_on=frozenset(retry_on) if retry_on else DEFAULT_RETRY_ON,
+ )
+
</file context>
| retry_on=frozenset(retry_on) if retry_on else DEFAULT_RETRY_ON, | |
| retry_on=frozenset(retry_on) if retry_on is not None else DEFAULT_RETRY_ON, |
|
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".
|
|
||
| # Run all checks (Smithy + Go + TypeScript + Ruby + Kotlin + Swift + Python + Behavior Model + Conformance + Provenance + Actions lint) | ||
| check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check url-routes-check go-check-drift go-check-wrapper-drift go-check-generated-drift auth-routable-check kt-check-drift swift-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays-and-scalars check-idempotency-parity | ||
| check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check url-routes-check go-check-drift go-check-wrapper-drift go-check-generated-drift auth-routable-check kt-check-drift swift-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays-and-scalars check-idempotency-parity check-retry-parity |
There was a problem hiding this comment.
Run retry parity from a CI job
When generated retry metadata drifts, this prerequisite only catches it for developers who manually run the root make check: I checked .github/workflows/test.yml and the other workflow files, and none invokes make check or check-retry-parity; the Go job explicitly runs only scripts/check-idempotency-parity at lines 114–118. As a result, the new cross-SDK parity guard can remain green locally yet never execute on pull requests, allowing precisely the stale metadata this change is intended to prevent to merge.
Useful? React with 👍 / 👎.
d2ea6f6 to
3edf1b3
Compare
8b117ca to
55ad0b7
Compare
5aa39b8 to
f9ef4b3
Compare
|
Restacked. #483 merged while this was open, so the whole chain moved: #484 is now 1 commit on the post-#483 The PR body has also been rewritten. It described my first implementation, not what is in the branch: it claimed The Gate 3 consumption table in
|
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:665
- GetOperationRetryOn returns the underlying slice stored in operationRetryOn. Because slices are mutable and share backing arrays, external callers can accidentally (or intentionally) modify the returned slice and thereby mutate the client's retry policy at runtime. Return a defensive copy to keep operationRetryOn effectively immutable from outside the package.
// 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
}
python/tests/test_http.py:127
- This test documents that the no-operation GET path "still retries 500", but it doesn't assert the call count. Adding an explicit
route.call_countassertion makes the intended retry contract precise.
# Generated reads always pass an operation; see
# TestDeclaredRetryStatuses for the governed path.
route = respx.get("https://3.basecampapi.com/test")
route.side_effect = [
httpx.Response(500),
4908d52 to
6b71f7a
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".
| | Go | yes | yes — `min(caller_cap, operation_max)` | | ||
| | Python | yes | yes — `min(caller_cap, operation_max)` | |
There was a problem hiding this comment.
Update the earlier retry-field consumption description
These new rows correctly say Go and Python consume the per-operation status set, but the §2 configuration discussion at SPEC.md:115 still says both SDKs consume only max and that retry_on remains inert. After this change those statements directly contradict each other, so readers using the specification to determine which metadata fields affect transport behavior receive the wrong contract; update the earlier paragraph to describe max + retry_on consistently.
Useful? React with 👍 / 👎.
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
| // 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 | ||
| } |
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.