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 |
yes — BasecampConfig.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.
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:The operation's declared
maxAttemptsis a ceiling on the caller's configured attempt count, not a replacement for it.Where each SDK stands
RetryConfig.MaxRetriesmin(cap, op_max)config.max_retriesmin(cap, op_max)BasecampConfig.maxRetries: IntopRetry?.maxRetries ?: config.maxRetries— the operation value overridesconfig.max_retriesenableRetry?: booleanenableRetry: BoolTypeScript 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.mdrationale was corrected alongside it.)Bug 1 — Kotlin: the ceiling behaves as a floor
kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/http/BasecampHttpClient.kt:config.maxRetriesis 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 = 1and 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 = 1because 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.rbbounds its GET-only loop by@config.max_retriesalone and never reads the operation's metadata. An operation declaringmax: 2is attemptedconfig.max_retriestimes.Repro.
max_retries = 5against a GET whose operation declaresmax: 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
retryOnSeparate from attempt counts, and worth fixing in the same pass. Ruby's retry loop keys off
e.retryable?, anderrors.rbmarks 500/502/503/504 retryable. Every operation declaresretry_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 retriedconformance case inretry.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:
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.pyasserts 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-parityis 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.