Let the conformance suite state a retry cap and pin the one-attempt floor

Merged
JE
2mo ago
+118-6

The gap

conformance/schema.json configOverrides carried only baseUrl, maxPages and maxItems, and every runner built its client on the default cap of 3. The suite could not say "max_retries: 0 sends exactly one request", so the one-attempt floor Go gained in #180 and Ruby in #181 was pinned by a unit test in its own language and nowhere else — and whether the other SDKs floor at all was unstated.

The precedent

basecamp-sdk pins it: conformance/tests/retry.json "max_retries 0 sends exactly one request on a governed operation" — configOverrides.maxRetries: 0, a single queued 503, requestCount: 1 — with each runner routing the override to its SDK's own spelling of the cap, and the fixture description recording which runners pre-check the budget (a real guard) versus post-check it (0 and 1 indistinguishable). This copies the shape. The basecamp-sdk companion case on ungoverned download traffic has no fizzy analogue: every one of the 112 modelled operations carries a retry block, so there is no ungoverned path to reach.

What lands

  • Schema: configOverrides.maxRetries, integer ≥ 0, documented as a TOTAL attempt count.
  • Fixture: one queued 503 on GetBoard (modelled budget 3, retry_on [429, 500, 503]), requestCount: 1, statusCode: 503. Every runner answers past the end of its queue with a 500, which is also in the declared set, so an SDK that retried keeps going to its cap and the count cannot pass vacuously.
  • Runners: Go WithMaxRetries (pointer field so 0 survives), Ruby max_retries, Kotlin maxRetries, Rust max_attempts (Option<u32> so 0 survives, plus a serde test) take the number. TypeScript exposes no numeric cap — its retry middleware is driven by the per-operation retry.max — so a cap of 0 or 1 maps to enableRetry: false and anything above to true, the cutoff being > 1 because 0 and 1 both mean one attempt.

Results, all run locally on this branch

RunnerResultFloor
Go128 passed, 0 failedpre-check loop, floored since #180 — real guard
TypeScript128 passed, 0 failedpost-check (middleware); no numeric cap, override → enableRetry: false
Ruby128 passed, 0 failedpre-check loop, floored since #181 (merged) — real guard
Rust128 passed, 0 failed; runner tests 19 passedfloored in the builder (max_attempts(0) reads as one), route budget clamped under it
Kotlin128 passed, 0 failed on this head (stacked on #182; a compile error against main alone)post-check; client cap is a ceiling on the operation budget only with #182
Swiftno runner existsSDK loop is max(enableRetry ? maxAttempts : 1, 1) — floored, unpinned by conformance

Stacked on #182

The Kotlin SDK's builder DSL never exposed maxRetries, and FizzyHttpClient sized its budget as opRetry?.maxRetries ?: config.maxRetries — every operation has a modelled budget, so the client cap was dead for governed traffic. Both are fixed in #182, following the brief's rule that an SDK that does not honour the cap is its own fix. This PR's base is #182's branch so the Kotlin runner compiles and the suite is green at the head CI builds; GitHub retargets it to main when #182 merges.

Not in this PR

Swift: Makefile's conformance-swift target checks for conformance/runner/swift/Package.swift and prints SKIP, and CI's test.yml has no Swift conformance job. Building a runner from nothing is not cap plumbing; the SDK's own loop already floors at one attempt. Flagged rather than done.