Conformance: pin idempotent-POST retry liveness#439
Merged
Conversation
The idempotency.json suite pinned only the retry *safety* direction — PUT and DELETE retry, and a non-idempotent POST (CreateTodo) does NOT. It never pinned the *liveness* direction: that an idempotent-*flagged* POST MUST retry. That metadata override is the exact behavior distinguishing the three-gate SDKs (Go/TS/Kotlin/Python) from Ruby's GET-only retry, and it was only unit-tested per-SDK — it would silently regress without conformance noticing. This gap surfaced reviewing the Swift idempotency work (#411/#417). Add one fixture case: CompleteTodo (POST /todos/{todoId}/completion.json, 503 then 204, requestCount 2, noError). Dispatch it in the four three-gate runners (Go/TS/Python/Kotlin), which run and pass; skip it in Ruby, whose HTTP layer only retries GET. No SDK source, no generator, behavior-model untouched. Also correct a stale SPEC.md claim. SPEC said "Go is stricter: only GET retries … no idempotency gate," which the conformance run disproves. Go implements the full three-gate on its generated operation path: it retries operations classified idempotent at generation time (GET/HEAD or ops carrying x-basecamp-idempotent, including CompleteTodo) with exponential backoff; non-idempotent ops are single-attempt. Only Ruby is GET-only. The hand-written doRequestURL helper stays GET-only, which is the misleading path a shallow go/pkg/basecamp/ grep hits — the real gate lives in go/pkg/generated/. make conformance: Go 73p/2s, Kotlin 67p/8s, TS 92p/5s, Ruby 66p/9s, Python 72p/3s — 0 failures. make check green.
There was a problem hiding this comment.
Pull request overview
Adds a cross-SDK conformance fixture to pin liveness of retries for an idempotent-flagged POST (CompleteTodo) on transient failures, and updates SPEC documentation to reflect current Go retry behavior on the generated client path.
Changes:
- Add an
idempotent-POSTconformance test case (CompleteTodo, 503 → 204,requestCount: 2). - Wire the new operation into Go / TypeScript / Python / Kotlin conformance dispatchers; skip it in Ruby with the existing “Ruby retries GET only” rationale.
- Correct SPEC.md’s Go retry documentation (with a small follow-up needed to accurately list naturally-idempotent methods).
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 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| SPEC.md | Updates retry-strategy documentation to reflect Go’s generated-path idempotency gate (needs a small correction to include PUT/DELETE in the method list). |
| conformance/tests/idempotency.json | Adds the new CompleteTodo idempotent-POST retry conformance fixture. |
| conformance/runner/go/main.go | Dispatches CompleteTodo through the Go SDK conformance runner. |
| conformance/runner/typescript/runner.test.ts | Dispatches CompleteTodo through the TypeScript conformance runner. |
| conformance/runner/python/runner.py | Dispatches CompleteTodo through the Python conformance runner. |
| kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/Main.kt | Dispatches CompleteTodo through the Kotlin conformance runner. |
| conformance/runner/ruby/runner.rb | Skips the new idempotent-POST retry case for Ruby, consistent with GET-only retry behavior. |
Comments suppressed due to low confidence (1)
SPEC.md:1566
- Same issue as above: this Go retry-strategy table row mentions only GET/HEAD, but the generated client marks PUT/DELETE operations idempotent as well; the row should reflect the full naturally-idempotent method set to avoid contradicting the three-gate definition.
| Go | Generated operation path retries operations classified idempotent at generation time — GET/HEAD, or operations carrying `x-basecamp-idempotent` (including `CompleteTodo`) — with exponential backoff; non-idempotent operations are single-attempt. The separate hand-written `doRequestURL` helper remains GET-only for ordinary retries, with a mutation-specific single re-attempt after successful 401 token refresh. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Copilot review: the Go retry description listed only GET/HEAD as
naturally-idempotent methods, obscuring that PUT/DELETE mutations
(UpdateProject/TrashProject) also retry. They carry x-basecamp-idempotent
{natural:true}, so they fall in the extension category — now named
explicitly alongside the flagged-idempotent POSTs (CompleteTodo) in both
the Cross-SDK Divergence bullet and the Retry Strategy table row.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds one cross-SDK conformance case pinning that an idempotent-flagged POST must retry, plus a stale-SPEC correction. No SDK source, no generator,
behavior-model.jsonuntouched.Why
conformance/tests/idempotency.jsonpinned only the retry safety direction:UpdateProject) retries → requestCount 2TrashProject) retries → requestCount 2CreateTodo) does not retry → requestCount 1It never pinned the liveness direction — that an idempotent-flagged POST must retry on 503. That metadata override (
x-basecamp-idempotent/ behavior-modelidempotent:true) is the exact behavior distinguishing the three-gate SDKs from the GET-only one. Swift had focused unit coverage (RetryTests.testIdempotentPostRetriedOn503/testGeneratedIdempotentPostRetries), but the shared conformance suite did not pin the positive behavior across implementations — and among the matrix SDKs, TypeScript only had the negative non-idempotent-POST case while Go/Kotlin/Python had no positive unit test at all. It would silently regress without conformance noticing.This gap surfaced in a fresh-eyes review of the Swift idempotency retry work (#411 → #417), which gated Swift's transport on operation idempotency (three-gate: retry iff the method is naturally idempotent or the operation is flagged idempotent).
What changed
Fixture — one new case in
idempotency.json:CompleteTodo(POST/todos/{todoId}/completion.json,503then204,requestCount: 2+noError).Dispatch (run + pass) in the four three-gate runners: Go, TypeScript, Python, Kotlin — each calls the existing completion method with
todoIdfrompathParams.Skip in Ruby only — its HTTP layer retries only GET (
ruby/lib/basecamp/http.rb), so it already skips the PUT/DELETE idempotency cases; this joins them with reason "Ruby SDK only retries GET".SPEC.md correction — SPEC claimed "Go is stricter: only GET retries … No idempotency gate," which the conformance run disproves. Go implements the full three-gate on its generated operation path (
go/templates/client.tmplselectsx-basecamp-idempotent;doWithRetrysetsmaxAttemptsonly whenisIdempotent;CompleteTodois flagged{Idempotent: true}). Only Ruby is GET-only. The hand-writtendoRequestURLhelper stays GET-only — the misleading path a shallowgo/pkg/basecamp/grep hits; the real gate lives ingo/pkg/generated/. Both the Cross-SDK Divergence bullet and the Retry Strategy table row are corrected.Verification
make conformance— 0 failures:make checkgreen.Scope boundaries
mockResponses[].status); there's no network-drop primitive, so the network-error retry path can't be conformance-tested (a harness change, out of scope). Swift's XCTest covers the network path directly.RetryTests.testGeneratedIdempotentPostRetries).Summary by cubic
Add a cross‑SDK conformance case to ensure idempotent‑flagged POSTs retry on 503, using
CompleteTodo(POST 503→204), wired in Go/TypeScript/Python/Kotlin and skipped in Ruby (GET‑only retry).Also correct SPEC: Go’s generated operations implement the three‑gate retry and now explicitly list the idempotent set as GET/HEAD, PUT/DELETE mutations (
UpdateProject,TrashProject), and flagged POSTs likeCompleteTodo; the hand‑written helper stays GET‑only.Written for commit 2d608d5. Summary will update on new commits.