Skip to content

Retry: honor the declared retry_on and max_attempts in Go and Python - #486

Merged
jeremy merged 1 commit into
mainfrom
fix/retry-metadata-fidelity
Jul 28, 2026
Merged

Retry: honor the declared retry_on and max_attempts in Go and Python#486
jeremy merged 1 commit into
mainfrom
fix/retry-metadata-fidelity

Conversation

@jeremy

@jeremy jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes #479. Stacked on #484#483main.

#483 fixed the per-operation attempt ceiling. This fixes the other half of Gate 3: the status set.

What was wrong

behavior-model.json declares retry_on: [429, 503] for all 226 operations. Neither Go nor Python consulted it:

  • Go used a global isRetryableStatus covering {429, 500, 502, 503, 504} (client.gen.go:3964-3974), regardless of what an operation declared.
  • Python had no status gate at all — its loop keyed off e.retryable, and errors.py:153-156 marks 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, a map[string][]int sibling of #483's operationRetryMax — and for the same reason kept off the exported OperationMetadata struct, so wiring it up cannot break an external unkeyed composite literal. isRetryableStatus now takes the operationId and consults it, falling back to defaultRetryOn for an id absent from the table (unreachable from a generated call site).

Python. _is_retryable_error(error, operation) gates on the operation's declared retry_on, resolved by _operation_retry_on(operation) from the metadata #483 already threads through every call site. Because #483 threads a canonical operation id into get() as well, the read path is governed too — no separate mechanism.

errors.py and ParseHTTPError are 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.json conformance 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.py rather than adding a second script:

Proof matrix — all fail against the un-fixed code

Python, the full 2×2 (sync/async × mutation/read):

FAILED TestDeclaredRetryStatuses::test_sync_mutation_status_gate[500-1]   assert 3 == 1
FAILED TestDeclaredRetryStatuses::test_sync_mutation_status_gate[502-1]   assert 3 == 1
FAILED TestDeclaredRetryStatuses::test_sync_mutation_status_gate[504-1]   assert 3 == 1
FAILED TestDeclaredRetryStatuses::test_async_mutation_status_gate[500-1]  assert 3 == 1
FAILED TestDeclaredRetryStatuses::test_async_mutation_status_gate[502-1]  assert 3 == 1
FAILED TestDeclaredRetryStatuses::test_async_mutation_status_gate[504-1]  assert 3 == 1
FAILED TestDeclaredRetryStatuses::test_sync_read_status_gate              assert 3 == 1
FAILED TestDeclaredRetryStatuses::test_async_read_status_gate             assert 3 == 1

Go: made 3 attempts for status 500, want 1 (likewise 502 and 504), plus a read-path case on GetAccount.

SPEC

  • Removed "Implementations may expand this set to include other 5xx statuses (500, 502, 504)" — that sentence sanctioned the bug. The declared set is now stated as exhaustive, with an explicit note that an SDK may still classify those statuses as retryable in its error taxonomy without widening the gate.
  • Added the Gate 3 consumption table. Corrected since first posting: only Kotlin turns the ceiling into a floor (it is the one SDK with both a numeric caller cap and a resolution order that discards it — 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 main before final merge, or close #479 explicitly with landed evidence.

make check exits 0 — real exit code, captured into the log rather than read from a trailing echo.

Copilot AI review requested due to automatic review settings July 28, 2026 06:48
@jeremy jeremy added bug Something isn't working go spec Changes to the Smithy spec or OpenAPI breaking Breaking change to public API python Pull requests that update the Python SDK labels Jul 28, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation and removed spec Changes to the Smithy spec or OpenAPI breaking Breaking change to public API labels Jul 28, 2026
@github-actions github-actions Bot removed the documentation Improvements or additions to documentation label Jul 28, 2026

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

Comment thread go/templates/client.tmpl Outdated
MaxAttempts int
// RetryOn is the operation's declared retryable status set from
// x-basecamp-retry. Statuses outside it are never retried.
RetryOn []int

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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

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 OperationMetadata with MaxAttempts/RetryOn, gate retries on the per-operation declared status set, and cap attempts via min(configured_attempts, operation_max).
  • Python (sync + async): introduce a shared _retry_policy module, apply declared status gating + attempt ceiling to generated traffic, and explicitly keep get_absolute() (OAuth) ungoverned.
  • Add scripts/check-retry-parity (wired into make 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.

@cubic-dev-ai cubic-dev-ai 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.

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

Comment thread scripts/check-retry-parity.rb Outdated

def parse_go(path)
# "OpId": {Idempotent: …, HasSensitiveParams: …, MaxAttempts: 3, RetryOn: []int{429, 503}},
File.read(path).scan(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread go/pkg/generated/client.gen.go Outdated
MaxAttempts int
// RetryOn is the operation's declared retryable status set from
// x-basecamp-retry. Statuses outside it are never retried.
RetryOn []int

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Comment thread python/src/basecamp/_retry_policy.py Outdated
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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,

Comment thread SPEC.md Outdated
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Correction to the PR description: I stated make check passes. It did not — py-check failed on ruff format --check for _http.py and tests/test_http.py. My verification command ended in ; echo "EXIT=$?", so the exit code I read was the echo's, not make's.

Fixed by running ruff format and amending the commit; the amended commit is the formatted one. Re-verifying the full suite now and will report the actual result.

@jeremy
jeremy force-pushed the fix/retry-metadata-fidelity branch from 4dfa5f9 to 8b117ca Compare July 28, 2026 07:02
Copilot AI review requested due to automatic review settings July 28, 2026 07:02
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Re-verified after the formatting fix: make check exits 0 (real exit code, checked properly this time).

@github-actions github-actions Bot added documentation Improvements or additions to documentation breaking Breaking change to public API and removed documentation Improvements or additions to documentation labels Jul 28, 2026
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

⚠️ Potential breaking changes detected:

  • Change in retry behavior due to honoring declared retry_on statuses (429, 503) and removing retrying for statuses (500, 502, 504), which alters the retry contract.
  • Modification of the retry behavior may affect clients relying on previous retryable statuses, potentially leading to unexpected behavior and failures.

Review carefully before merging. Consider a major version bump.

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

Comment thread Makefile Outdated

# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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 10 out of 11 changed files in this pull request and generated no new comments.

@jeremy
jeremy force-pushed the fix/retry-metadata-fidelity branch from 5aa39b8 to f9ef4b3 Compare July 28, 2026 20:25
@jeremy
jeremy requested a review from Copilot July 28, 2026 20:25
@github-actions github-actions Bot added documentation Improvements or additions to documentation and removed breaking Breaking change to public API labels Jul 28, 2026
@jeremy

jeremy commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Restacked. #483 merged while this was open, so the whole chain moved: #484 is now 1 commit on the post-#483 main (2 files, was 55 when it was sitting on the superseded 7a4c3297), and this PR is 1 commit on #484 — 8 files.

The PR body has also been rewritten. It described my first implementation, not what is in the branch: it claimed OperationMetadata.MaxAttempts, _retry_policy.effective_attempts, and a constant read-path gate guarded by a 118-operation invariant. None of those exist here — after rebasing onto #483 I dropped all of that in favour of #483's operation-id threading, and the shipped code uses sibling operationRetryMax/operationRetryOn maps plus a _is_retryable_error(error, operation) gate. Body now matches the diff.

The Gate 3 consumption table in SPEC.md is also corrected: only Kotlin turns the ceiling into a floor. TypeScript and Swift expose no numeric caller cap at all, so there is nothing for the operation value to override — I had lumped them in. Ruby is the mirror image. #485 is rescoped to Kotlin + Ruby, and #483's rationale paragraph was corrected before it merged (the corrected text is in main now).

make check exits 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 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_count assertion 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),

@jeremy
jeremy force-pushed the docs/retry-truth-up branch from 4908d52 to 6b71f7a Compare July 28, 2026 21:26
Copilot AI review requested due to automatic review settings July 28, 2026 21:27
@jeremy
jeremy force-pushed the fix/retry-metadata-fidelity branch from f9ef4b3 to a9c0941 Compare July 28, 2026 21:27
@github-actions github-actions Bot added the breaking Breaking change to public API label Jul 28, 2026

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

Comment thread SPEC.md
Comment on lines +416 to +417
| Go | yes | yes — `min(caller_cap, operation_max)` |
| Python | yes | yes — `min(caller_cap, operation_max)` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

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 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
}

@jeremy
jeremy force-pushed the docs/retry-truth-up branch from 6b71f7a to 8af46a1 Compare July 28, 2026 22:22
Copilot AI review requested due to automatic review settings July 28, 2026 22:23
@jeremy
jeremy force-pushed the fix/retry-metadata-fidelity branch from a9c0941 to 91784cb Compare July 28, 2026 22:23

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 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 declared retryOn: [] 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 -}}

Base automatically changed from docs/retry-truth-up to main July 28, 2026 22:42
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.
@jeremy
jeremy force-pushed the fix/retry-metadata-fidelity branch from 91784cb to 904f38b Compare July 28, 2026 22:42
@jeremy
jeremy requested a review from Copilot July 28, 2026 22:42
@github-actions github-actions Bot removed the documentation Improvements or additions to documentation label Jul 28, 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

Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.

Comment thread go/templates/client.tmpl
Comment on lines +859 to +864
// 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
}
@jeremy
jeremy merged commit 21a657b into main Jul 28, 2026
48 checks passed
@jeremy
jeremy deleted the fix/retry-metadata-fidelity branch July 28, 2026 22:53
@jeremy jeremy mentioned this pull request Jul 29, 2026
11 tasks
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 bug Something isn't working go python Pull requests that update the Python SDK

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Retry metadata fidelity: Go and Python ignore per-operation retry_on and max_attempts

2 participants