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

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

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.

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

Comment thread scripts/check-retry-parity.rb Outdated
Comment thread go/pkg/generated/client.gen.go Outdated
Comment thread python/src/basecamp/_retry_policy.py Outdated
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

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

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
@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 commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Audited all nine unresolved threads against the merged tree. Six distinct claims; two shipped, four did not.

Claim At merge 21a657b2 Verdict
Go accessor aliases the package global present real — shipped in v0.10.0
Parity guard absent from CI present real — enforcement gap
Explicit retry_on: [] defaults to 429/503 fixed did not ship
SPEC §2 contradicts the Gate-3 table fixed did not ship
Python classification vetoes declared statuses fixed did not ship
Ruby guard encoding files absent intermediate-only, moot

Both real findings are fixed in #500, with a regression test that fails against the unfixed accessor (a second lookup returns [599 599], want [429 503]) and passes under -race. The four settled threads are resolved with final-head evidence; the four tracking the two real findings stay open until #500 merges.

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 (check-retry-parity.rb, _retry_policy.py) were replaced mid-PR and never merged at all.

Two things worth carrying forward:

isOutdated is unreliable in both directions. Two non-outdated threads described code that had already been corrected, while the one thread that was genuinely still defective — Copilot's, on the exact final head — sat untriaged. Staleness of the anchor says nothing about truth of the claim.

The merge window is the actual process failure. Copilot reviewed 904f38b0 at 22:46:36 and the PR merged at 22:53:17 — six minutes and 41 seconds, no triage. That review was correct.

Separately, and out of scope for #500: python/README.md:452 still advertises the pre-#486 contract ("retryable ApiError (500, 502, 503, 504)") when the merged gate is the declared {429, 503} set. This PR updated SPEC.md but left the Python README behind.

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