Skip to content

Swift transport: gate retries on operation idempotency (#411)#417

Merged
jeremy merged 2 commits into
mainfrom
fix/swift-idempotency-retry-411
Jul 25, 2026
Merged

Swift transport: gate retries on operation idempotency (#411)#417
jeremy merged 2 commits into
mainfrom
fix/swift-idempotency-retry-411

Conversation

@jeremy

@jeremy jeremy commented Jul 25, 2026

Copy link
Copy Markdown
Member

Fixes #411.

Problem

The Swift SDK's HTTP transport retried any request whose response status was in retry_on (429/503) — and retried on network errors — without checking operation idempotency. So non-idempotent POSTs were retried, contrary to the SDK contract (SPEC §7). A 503 returned after the backend created a resource issues a second create; for CreateWormhole a double-create consumes one of the scarce four-per-board slots (surfaced by #402). Documented known bug at SPEC.md:407/:1568.

Exposure: 41 POST operations — 3 idempotent (CompleteTodo, PauseQuestion, SubscribeToCardColumn), 38 non-idempotent (including all 30 Create*). The 3 idempotent POSTs must keep retrying, so a method-only gate is insufficient.

Fix — three-gate algorithm (mirrors Kotlin, metadata-driven)

An operation is retry-eligible iff the method is naturally idempotent (GET/HEAD/PUT/DELETE) or behavior-model.idempotent == true. Non-idempotent POST → single attempt; everything else retries per config on both the status path and the network-error path.

  • Generator (BehaviorModel.swift, MetadataEmitter.swift): parse the always-present idempotent flag and emit a sorted idempotentOperations set + Metadata.isIdempotent(for:). No service files change — only Metadata.swift regenerates.
  • Transport (HTTPClient.performRequest): new defaulted idempotent: Bool = false param (fail-safe). The gate allowlists the naturally-idempotent methods (so PATCH/OPTIONS/future methods stay fail-closed) and folds into maxAttempts, covering both retry paths at once.
  • BaseService (single caller of performRequest): computes Metadata.isIdempotent(for: info.operation) and threads it through all four request variants. Generated services and hand-written extensions inherit the gate unchanged.
  • SPEC.md: replaced the Swift over-retry known-bug entries with the three-gate behavior and corrected the stale "Swift surfaces network errors immediately" claim (Swift already retried network errors; that retry is now correctly gated).

No public API break

OperationInfo unchanged; performRequest gains a defaulted param (source-compatible); BaseService request signatures unchanged (idempotency computed internally); Metadata.isIdempotent internal. behavior-model.json unmodified (already carried the flag) → behavior-model-check stays green. No cross-SDK change.

Tests (swift/Tests/BasecampTests)

  • Layer A (transport): non-idempotent POST not retried on 503/429/network-error → 1 attempt; idempotent POST retried on 503 → 2 attempts; PATCH not retried (method gate fail-closed); GET+503 regression still retries.
  • Layer B (BaseService → Metadata → transport): projects.create (non-idempotent POST) against 503 → 1 request; todos.complete (idempotent POST CompleteTodo) against 503-then-204 → 2 requests, proving the metadata lookup wires through.
  • Generator parser/emitter (IdempotencyMetadataTests, @testable import BasecampGenerator): flag parses true/false/absent; emitted set is deterministically sorted and contains exactly the true ops.

Verification

  • make swift-generate → only Metadata.swift changes; Services/ diff empty. Post-commit regen git diff --exit-code clean (drift proof).
  • Metadata.swift: idempotentOperations contains CompleteTodo/PauseQuestion/SubscribeToCardColumn, excludes every Create*, sorted.
  • make swift-check green (216 tests). make check green.

Swift is macOS-only and not in the conformance matrix, so the XCTest suite is the acceptance gate.


Summary by cubic

Gate Swift transport retries on operation idempotency to stop reattempting non‑idempotent POSTs and align with SPEC §7. Non‑idempotent POSTs run once; naturally idempotent methods and POSTs marked idempotent: true still retry on 429/503 and network errors.

  • Bug Fixes
    • Implemented the three‑gate in HTTPClient: retry only for GET/HEAD/PUT/DELETE or when idempotent is true; gate folded into maxAttempts to cover both status and network‑error retries. Hoisted retryableMethods to a private static set to avoid per‑request allocations.
    • Generator reads idempotent from behavior-model.json and emits idempotentOperations plus Metadata.isIdempotent(for:); emitter comment clarified to note it lists all idempotent‑flagged ops (any method).
    • BaseService looks up Metadata.isIdempotent(for: operation) and threads it to the transport across all request paths.
    • SPEC.md updated to document Swift’s three‑gate behavior and that Swift retries network errors, gated by idempotency.
    • Tests added for metadata parsing/emission and retry gating at both transport and service layers.
    • No public API break; service method signatures unchanged.

Written for commit 089abb6. Summary will update on new commits.

Review in cubic

The Swift HTTP transport retried any request whose status was in retry_on
(429/503) and retried on network errors without checking operation
idempotency, so non-idempotent POSTs were retried against the SDK contract.
A 503 returned after the backend created a resource issued a second create;
for CreateWormhole a double-create consumes one of the four-per-board slots.

Thread a per-operation idempotent bit from generated Metadata into the
transport's retry decision (SPEC §7): retry-eligible iff the method is
naturally idempotent (GET/HEAD/PUT/DELETE) or the operation is marked
idempotent. Non-idempotent POST -> single attempt; the three idempotent
POSTs (CompleteTodo, PauseQuestion, SubscribeToCardColumn) keep retrying.

- Generator emits idempotentOperations set + isIdempotent(for:) into
  Metadata.swift from the existing behavior-model.json idempotent flag;
  no service files change.
- HTTPClient.performRequest gains a defaulted idempotent param and folds the
  method allowlist gate into maxAttempts, covering both the status-retry and
  network-error retry paths. Allowlisting (not excluding POST) keeps
  PATCH/OPTIONS and future methods fail-closed.
- BaseService computes idempotency from Metadata and threads it through all
  four request variants; generated services and hand-written extensions
  inherit the gate unchanged.
- SPEC.md: replace the Swift over-retry known-bug entries with the three-gate
  behavior and correct the stale network-error claim.
Copilot AI review requested due to automatic review settings July 25, 2026 04:20
@github-actions github-actions Bot added documentation Improvements or additions to documentation swift bug Something isn't working and removed documentation Improvements or additions to documentation 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

This PR fixes Swift SDK retry behavior to match SPEC §7 by gating retries (HTTP 429/503 and network errors) on operation idempotency: naturally idempotent methods retry as before, and POST retries only when behavior-model.json marks the operation idempotent: true.

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.

Changes:

  • Extend the Swift generator to parse idempotent from behavior-model.json and emit Metadata.isIdempotent(for:) backed by an idempotentOperations set.
  • Add an idempotent parameter to HTTPClient.performRequest (default false) and gate retry attempts on (method allowlist OR metadata idempotency); thread the flag from BaseService.
  • Add/extend XCTest coverage for transport-level gating, service-layer wiring, and generator parsing/emission; update SPEC.md to reflect the corrected Swift behavior.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
swift/Tests/BasecampTests/RetryTests.swift Adds transport- and service-level regression tests for idempotency-gated retries.
swift/Tests/BasecampTests/IdempotencyMetadataTests.swift Adds generator tests for parsing/emitting the idempotent flag into Metadata.swift.
swift/Sources/BasecampGenerator/MetadataEmitter.swift Emits idempotentOperations + Metadata.isIdempotent(for:) into generated metadata.
swift/Sources/BasecampGenerator/BehaviorModel.swift Parses idempotent from behavior-model operations into BehaviorRetryConfig.
swift/Sources/Basecamp/Services/BaseService.swift Computes per-operation idempotency via Metadata and threads it to the transport.
swift/Sources/Basecamp/HTTP/HTTPClient.swift Adds idempotent param and gates retries on idempotency/method allowlist.
swift/Sources/Basecamp/Generated/Metadata.swift Regenerated metadata including the idempotent operation set + lookup.
SPEC.md Updates spec text to reflect Swift’s corrected three-gate retry behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread swift/Sources/Basecamp/HTTP/HTTPClient.swift Outdated
Comment thread swift/Sources/BasecampGenerator/MetadataEmitter.swift Outdated
…mpotent-set comment

- HTTPClient.retryableMethods is now a private static let so the retry gate
  does not allocate a Set per request on the hot path (Copilot).
- MetadataEmitter: reword the comment above idempotentOperations — the set
  holds every idempotent-flagged op (any method), not only POSTs; naturally
  idempotent methods retry via the method gate regardless (Copilot).
Copilot AI review requested due to automatic review settings July 25, 2026 04:26
@github-actions github-actions Bot added documentation Improvements or additions to documentation and removed documentation Improvements or additions to documentation 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

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

@jeremy
jeremy merged commit b184e3b into main Jul 25, 2026
47 checks passed
@jeremy
jeremy deleted the fix/swift-idempotency-retry-411 branch July 25, 2026 04:42
jeremy added a commit that referenced this pull request Jul 25, 2026
* Conformance: pin idempotent-POST retry liveness

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.

* SPEC: name PUT/DELETE mutations in Go's idempotent-retry set

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working swift

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swift transport: honor operation idempotency in the retry gate (don't retry non-idempotent POSTs)

2 participants