Skip to content

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

Description

@jeremy

Follow-up to #479 and #483, which together made Go and Python honor the per-operation retry block. Two SDKs still do not, in opposite directions.

SPEC.md §2 Per-operation retry ceiling specifies:

caller_cap         = max(1, configured_attempts)
effective_attempts = min(caller_cap, operation_max)

The operation's declared maxAttempts is a ceiling on the caller's configured attempt count, not a replacement for it.

Where each SDK stands

SDK numeric client-side cap? how the operation max is applied can a caller ask for fewer?
Go yes — RetryConfig.MaxRetries min(cap, op_max) yes
Python yes — config.max_retries min(cap, op_max) yes
Kotlin yesBasecampConfig.maxRetries: Int opRetry?.maxRetries ?: config.maxRetries — the operation value overrides no — bug 1
Ruby yes — config.max_retries operation max never consulted yes, but the operation max is unenforced — bug 2
TypeScript no — only enableRetry?: boolean operation value used directly n/a — no cap exists to honor
Swift no — only enableRetry: Bool operation value used directly n/a — same

TypeScript and Swift are not in scope. They expose no numeric caller cap, so there is nothing for the operation value to override; using it directly is the only thing they can do. Adding such a cap is a separate API-surface question, not a bug. (An earlier revision of this issue listed all four SDKs — that was wrong, and #483's SPEC.md rationale was corrected alongside it.)


Bug 1 — Kotlin: the ceiling behaves as a floor

kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/http/BasecampHttpClient.kt:

val maxAttempts = opRetry?.maxRetries ?: config.maxRetries

config.maxRetries is consulted only when the operation has no retry block — which is never, since all 226 carry one. So the client setting is effectively dead for retry sizing.

Repro. Build a client with maxRetries = 1 and call any operation against a server returning 503. Expected 1 attempt; actual 3.

This is the more serious direction: it overrides an explicit caller instruction. Someone who sets maxRetries = 1 because they are inside a request-scoped deadline still gets three attempts and blows the deadline.

Fix: val maxAttempts = minOf(config.maxRetries.coerceAtLeast(1), opRetry?.maxRetries ?: config.maxRetries).

Bug 2 — Ruby: the operation max is never enforced

ruby/lib/basecamp/http.rb bounds its GET-only loop by @config.max_retries alone and never reads the operation's metadata. An operation declaring max: 2 is attempted config.max_retries times.

Repro. max_retries = 5 against a GET whose operation declares max: 2, server returning 503. Expected 2 attempts; actual 5.

Lower severity — it sends more traffic to an endpoint the spec said to back off from sooner, rather than overriding the caller.

Ruby also does not gate on retryOn

Separate from attempt counts, and worth fixing in the same pass. Ruby's retry loop keys off e.retryable?, and errors.rb marks 500/502/503/504 retryable. Every operation declares retry_on: [429, 503], so Ruby retries GETs on 500. Go, Python, TypeScript, Kotlin and Swift all now gate on the declared set; Ruby is the only holdout.

Fixing this would also unblock a cross-SDK 500 is not retried conformance case in retry.json, which cannot be added today because Ruby would fail it.

Note the two directions of the declared set, both settled in #486 and worth matching:

  • the set must not be widened by the error taxonomy, and
  • it must not be vetoed by it either — if an operation declares a status retryable, a classification saying otherwise must not disable the retry;
  • and retry_on: [] means never retry on any status, which is not the same as declaring nothing.

Not covered by existing guards

scripts/check-retry-metadata-parity.py asserts raw generated metadata parity across all six SDKs — that passes here, because every SDK ships the right numbers. It deliberately does not assert effective behavior, since TypeScript's one-retry cap (waiver 2B.1) and Ruby's GET-only transport are sanctioned divergences. check-idempotency-parity is classification-only. So nothing currently fails on either bug.

Current state is documented in the Gate 3 consumption table in SPEC.md §7; that table should shrink as these are fixed.

Scope

Behavior change in Kotlin and Ruby. Label breaking: callers relying on the operation's count overriding a lower client setting (Kotlin), or on a raised cap applying to GETs (Ruby), will see fewer attempts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    breakingBreaking change to public APIbugSomething isn't workingkotlinrubyPull requests that update the Ruby SDK

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions