Skip to content

Conformance: pin idempotent-POST retry liveness#439

Merged
jeremy merged 2 commits into
mainfrom
fix/conformance-idempotent-post-retry
Jul 25, 2026
Merged

Conformance: pin idempotent-POST retry liveness#439
jeremy merged 2 commits into
mainfrom
fix/conformance-idempotent-post-retry

Conversation

@jeremy

@jeremy jeremy commented Jul 25, 2026

Copy link
Copy Markdown
Member

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.json untouched.

Why

conformance/tests/idempotency.json pinned only the retry safety direction:

  • PUT (UpdateProject) retries → requestCount 2
  • DELETE (TrashProject) retries → requestCount 2
  • non-idempotent POST (CreateTodo) does not retry → requestCount 1

It never pinned the liveness direction — that an idempotent-flagged POST must retry on 503. That metadata override (x-basecamp-idempotent / behavior-model idempotent: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, 503 then 204, requestCount: 2 + noError).

Dispatch (run + pass) in the four three-gate runners: Go, TypeScript, Python, Kotlin — each calls the existing completion method with todoId from pathParams.

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.tmpl selects x-basecamp-idempotent; doWithRetry sets maxAttempts only when isIdempotent; CompleteTodo is flagged {Idempotent: true}). Only Ruby is GET-only. The hand-written doRequestURL helper stays GET-only — the misleading path a shallow go/pkg/basecamp/ grep hits; the real gate lives in go/pkg/generated/. Both the Cross-SDK Divergence bullet and the Retry Strategy table row are corrected.

Verification

make conformance — 0 failures:

Runner idempotent-POST case Suite
Go ✅ PASS (2 requests) 73p / 2s
Kotlin ✅ PASS 67p / 8s
TypeScript ✅ PASS 92p / 5s
Python ✅ PASS 72p / 3s
Ruby ⏭️ SKIP (only retries GET) 66p / 9s

make check green.

Scope boundaries

  • Status-retry only. The conformance mock vocabulary is HTTP statuses (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.
  • Swift is not in the conformance matrix (macOS-only). This case is the ready-made acceptance if Swift ever joins; Swift's own idempotent-POST retry is unit-tested (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 like CompleteTodo; the hand‑written helper stays GET‑only.

Written for commit 2d608d5. Summary will update on new commits.

Review in cubic

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.
Copilot AI review requested due to automatic review settings July 25, 2026 06:24
@github-actions github-actions Bot added documentation Improvements or additions to documentation kotlin conformance Conformance test suite labels Jul 25, 2026

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.

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-POST conformance 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.

Comment thread SPEC.md Outdated

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 7 files

Re-trigger cubic

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.
Copilot AI review requested due to automatic review settings July 25, 2026 06:31

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.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@jeremy
jeremy merged commit 9c11075 into main Jul 25, 2026
46 checks passed
@jeremy
jeremy deleted the fix/conformance-idempotent-post-retry branch July 25, 2026 06:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conformance Conformance test suite documentation Improvements or additions to documentation kotlin

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants