Skip to content

Retry ceilings everywhere: Kotlin honors the caller cap, Ruby enforces the per-op block - #515

Merged
jeremy merged 5 commits into
mainfrom
retry-ceilings
Jul 31, 2026
Merged

Retry ceilings everywhere: Kotlin honors the caller cap, Ruby enforces the per-op block#515
jeremy merged 5 commits into
mainfrom
retry-ceilings

Conversation

@jeremy

@jeremy jeremy commented Jul 31, 2026

Copy link
Copy Markdown
Member

Why

#485: the per-operation retry.max is a ceiling on the caller's configured attempts, never a replacement — but two SDKs disagreed, in opposite directions. Kotlin resolved opRetry?.maxRetries ?: config.maxRetries, so the operation value overrode an explicit caller instruction (a maxRetries = 1 caller inside a request deadline still got 3 attempts). Ruby never consulted the operation block at all: attempts bounded by config.max_retries alone, and its error-taxonomy gate retried GETs on 500 — a status no operation declares retryable.

Stacked on #514 (both touch SPEC.md §2/§7 and the retry counts).

What

  • Kotlin (BasecampHttpClient.kt): effective attempts = min(caller cap coerced ≥ 1, op max), matching Go/Python. BasecampClientBuilder gains the maxRetries knob — there was previously no public way to express the cap the ceiling honors. Red proof: callerCapWinsOverOperationMax and zeroCapCoercesToOneAttempt FAIL against the old resolution line; operationCeilingBoundsRaisedCap pins min(5, op 2) = 2.
  • Ruby: the canonical operation ID threads from every generated GET/paginate call site through AccountClientHttp#get/#paginate*request_with_retry (explicit kwargs — the Python pattern, fiber-safe), and pagination follow-up pages reuse the governed get(), so page 2+ can't silently degrade. Governed GETs: min(max(cap, 1), op max) attempts, status retries gated on the declared retryOn — errors.rb's 500/502/504 taxonomy neither widens nor vetoes it (Retry: honor the declared retry_on and max_attempts in Go and Python #486 semantics). Ungoverned traffic (get_absolute, OAuth discovery) keeps the pre-metadata contract. Metadata memoized at class level. Red proof (enforcement neutered, plumbing intact): ceiling, 500-gate, and paginate-page-2 tests fail on request counts; caller-lower-cap and ungoverned-500 pin preserved behavior.
  • Parity gate: Ruby's runtime-consumption row flips from none+forbidden-tokens to max + retry_on with required tokens — the gate now fails if the governed path is removed. Kotlin's row requires the minOf/coerceAtLeast ceiling tokens.
  • SPEC.md: both Kotlin applies per-operation maxAttempts as a floor; Ruby ignores it entirely #485 divergence notes removed; Gate 3 table shows Kotlin/Ruby honoring both dimensions; Appendix F Ruby row rewritten. (SECURITY.md's "GETs retry on 429/503" claim was previously false for Ruby-on-500 and simply becomes true.)
  • Conformance: new retry.json case "GET 500 is not retried" (GetProject, one 500, requestCount 1) — passes in all five runners with the fix; only un-fixed Ruby fails it.

Breaking

  • Kotlin callers who raised maxRetries above an operation's declared max now clamp to the ceiling; callers who lowered it are finally honored.
  • Ruby governed GETs no longer retry 500 (one attempt, error surfaces) and clamp to the per-op ceiling.

Verification

Full make green (exit 0). GET 500 is not retried PASSES in Go/Kotlin/Ruby/Python explicitly + TS totals. Parity gate: Kotlin min(caller cap, opRetry.maxRetries), Ruby max + retry_on governed-GET rows.

Closes #485.


Summary by cubic

Unify retry ceilings across SDKs: Kotlin now treats the caller’s maxRetries as a cap and keeps pagination follow-ups governed, and Ruby GETs honor each operation’s retry.max and retryOn. This prevents extra attempts and stops retrying 500s when not declared; pagination stays governed.

  • Bug Fixes

    • Kotlin: Use min(max(1, caller cap), operation max) for attempts; BasecampClientBuilder adds maxRetries; pagination follow-ups pass operationName to keep the ceiling; tests cover lowered/raised caps, pagination governance, and zero-cap coercion.
    • Ruby: Thread the canonical operation ID into all generated GET/paginate calls, through AccountClientHttp#get/#paginate*request_with_retry; governed GETs retry min(max(config cap, 1), op maxAttempts) and only on declared retryOn; follow-up pages remain governed; ungoverned GETs and network errors keep taxonomy behavior; per-op metadata memoized.
    • Spec/Conformance: SPEC clarifies ceiling semantics and zero-cap coercion (Kotlin rejects negatives and coerces 0; Ruby splits governed vs ungoverned GETs); new conformance case “GET 500 is not retried”; parity gate now requires Kotlin ceiling tokens and Ruby max + retry_on.
  • Migration

    • Kotlin: Raised caps clamp to each operation’s max; lowered caps are honored; maxRetries = 0 makes one attempt.
    • Ruby: Governed GETs no longer retry 500 and are capped by each operation’s max; non-GETs and ungoverned GETs unchanged.

Written for commit 4b8d202. Summary will update on new commits.

Review in cubic

Copilot AI review requested due to automatic review settings July 31, 2026 07:34
@jeremy jeremy added the breaking Breaking change to public API label Jul 31, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation ruby Pull requests that update the Ruby SDK kotlin conformance Conformance test suite labels Jul 31, 2026
Base automatically changed from idempotent-subscribe to main July 31, 2026 07:36

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@jeremy

jeremy commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Copilot errored again (third PR today — service-side). Merging on green CI: full make exit 0, red proofs for both fixes recorded in the commit messages (Kotlin: 2 tests fail pre-fix; Ruby: 3 tests fail with enforcement neutered), parity gate now hard-requires the ceiling/gate tokens, and the new conformance case passes in all five runners.

@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: 07dbded756

ℹ️ 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 Outdated
jeremy added 4 commits July 31, 2026 00:39
…floor

BasecampHttpClient resolved attempts as opRetry?.maxRetries ?:
config.maxRetries — and since every operation carries a retry block, the
client's maxRetries was dead for retry sizing. A caller who set
maxRetries = 1 inside a request-scoped deadline still got the
operation's three attempts (#485 bug 1).

Now effective attempts = min(caller cap coerced to >= 1, operation max):
lowered caps are honored, raised caps still clamp to the declared
ceiling — matching Go and Python's min(cap, op_max).

BasecampClientBuilder gains the maxRetries knob; without it there was no
public way to express the caller cap the ceiling is supposed to honor.

Red proof: callerCapWinsOverOperationMax and zeroCapCoercesToOneAttempt
FAIL against the old resolution line; operationCeilingBoundsRaisedCap
pins min(5, op 2) = 2.
Ruby's retry loop was bounded by config.max_retries alone and keyed off
the error taxonomy's retryable flag, so an operation declaring max: 2
was attempted up to the config count, and 500 — retryable in errors.rb
but absent from every operation's declared retryOn [429, 503] — was
retried on GET. The last SDK ignoring the per-operation retry block
(#485 bug 2).

The canonical operation ID now threads from every generated GET/paginate
call site through AccountClient and Http#get/#paginate* into
request_with_retry — explicit keywords at each layer (the Python
pattern, fiber-safe), and the same governed get() serves pagination
follow-up requests, so page 2+ cannot silently fall back to ungoverned
retry. Generated services emit operation: "OperationId" on the
GET family only; mutations never reach Ruby's retry loop.

In the transport, a governed GET is bounded by min(max(config cap, 1),
op maxAttempts), and a status-bearing error retries exactly when the
declared retryOn says so — the taxonomy neither widens the set (no more
500 retries) nor vetoes it. Status-less network errors and all
ungoverned traffic (get_absolute, OAuth discovery) keep the taxonomy
contract. Metadata loads once, memoized at class level.

Red proof (enforcement neutered, plumbing intact): ceiling, 500-gate,
and paginate-page-2 tests fail on request counts; caller-lower-cap and
ungoverned-500 pin the preserved behavior.
check-retry-metadata-parity.py: Ruby's runtime-consumption row flips
from consumes-none (with forbidden tokens) to max + retry_on with
required tokens — the gate now FAILS if the governed path is removed.
Kotlin's row requires the min()/coerceAtLeast tokens of ceiling
semantics.

SPEC.md drops both #485 divergence notes: the Gate 3 consumption table
shows Kotlin and Ruby honoring caller caps and the declared retryOn,
and Appendix F's Ruby row describes governed-GET semantics. TypeScript
and Swift remain the two SDKs with no numeric cap to honor.
500 is retryable in Ruby's error taxonomy but absent from every
operation's declared retryOn [429, 503]. All six SDKs must let the
declared set win: one request, error surfaced. Only un-fixed Ruby
fails this case — GetProject dispatch already exists in all five
runners, so the fixture needs no new branches.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…ro-cap coercion

Codex review caught the Kotlin mirror of the Ruby page-2 hazard: all
three BaseService pagination loops called requestWithRetry without
operationName, so follow-up pages fell back to the caller cap as both
operands — a raised cap of 5 made 5 attempts on page 2 of ListProjects
(declared max 3) while page 1 clamped correctly. info.operation now
threads into every follow-up request. Red proof: the new
paginationFollowUpPagesKeepTheOperationCeiling test fails (5 attempts)
without the threading.

SPEC.md §2 now states the governed-path coercion honestly: caps are
total-attempt counts coerced to at least one, so max_retries = 0 yields
a single attempt on governed paths (min(max(1, cap), op_max)) — and the
§2 validation divergence note gains Kotlin (builder rejects negatives,
transport coerces 0) and splits Ruby's governed (one request) vs
ungoverned (zero requests) outcomes.
Copilot AI review requested due to automatic review settings July 31, 2026 07:58

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

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 conformance Conformance test suite documentation Improvements or additions to documentation kotlin ruby Pull requests that update the Ruby SDK

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Kotlin applies per-operation maxAttempts as a floor; Ruby ignores it entirely

2 participants