Skip to content

SMOODEV-3375: Method-aware retries; timeouts cancel the attempt (4.0.0) - #124

Merged
brentrager merged 1 commit into
mainfrom
SMOODEV-3375-safe-retries
Sep 27, 2026
Merged

brentrager merged 1 commit into
mainfrom
SMOODEV-3375-safe-retries

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem (SMOODEV-3375)

Retries ignored the HTTP method in every port. A POST that timed out or got a 429/5xx was sent again, up to twice more. In TypeScript the per-attempt timeout (mollitia's Timeout module) did not stop the request it timed out, so the first attempt kept running on the server while the retry sent it again. Image generation takes 20-50s against the 10s default, so it billed three images and returned nothing. Any non-idempotent POST or PATCH (CRM writes, message sends, payments) could run its side effect more than once.

Fix, the same in all five ports

  • Retries now depend on the method. Only GET, HEAD, OPTIONS, TRACE, PUT and DELETE are retried (RFC 9110 §9.2.2). Every other method makes exactly one attempt and returns that attempt's own error, not the retries-exhausted wrapper.
  • Two ways to opt back in:
    • set retry: { allowNonIdempotent: true } (allow_non_idempotent in Python and Rust, AllowNonIdempotent in Go and .NET);
    • or send a non-empty Idempotency-Key header, in any casing.
  • Eligibility is decided after the pre-request hooks and the auth provider run, so a hook can add the key. onRejection is never called for an ineligible request.
  • A 429 with Retry-After on a POST is not retried unless the caller opts in.
  • The client-side rate limiter's retry loop is unchanged. It rejects a request before anything is sent, so retrying is safe for every method.
  • A timeout now cancels the attempt.
    • TypeScript aborts each attempt with an AbortController combined with the caller's signal. It still races the fetch, so an implementation that ignores signal also times out on schedule.
    • Go waits for the cancelled attempt to finish before it retries.
    • Python adds a hard per-attempt deadline with asyncio.timeout.
    • Rust and .NET already cancelled; they now have tests that prove it.
  • TypeScript no longer retries a request the caller aborted.

Why a major version (4.0.0)

  • Callers that relied on POST retries quietly lose them.
  • Rust RetryOptions gains a field, which breaks existing struct literals. The 3.7.1 changelog explains why that must not ship as a minor. RetryOptions now implements Default, so future literals can end with ..Default::default().
  • Go's module path becomes /v4 when the release runs sync-versions. The READMEs already say /v4.

Verification

spec/retry-idempotency-corpus.json has 20 cases and a timeout-abort spec. Every port runs it against a real local server and counts the requests the server received. Each port also has a test that the connection of a timed-out attempt is actually closed.

Port Run locally
TS typecheck, lint, format, vitest (149 passed), build
Python poe test (173 passed), lint, format, typecheck, build
Rust cargo test and cargo test --all-features, clippy -D warnings, fmt
Go go test ./..., vet, gofmt
.NET format --verify-no-changes, dotnet test -c Release (67 passed)

Mutation checks. Retries were made method-blind again in each port, and the corpus tests failed:

Port Tests that failed
TS 7
Rust 2 (5 corpus cases)
Go 7
Python 7
.NET 6

In each port, the failures were the POST/PATCH 503, empty key, POST 429 + Retry-After, and POST timeout cases, plus the port's "ineligible POST" tests.

A second mutation made the timeout abandon the attempt instead of cancelling it. The connection-close tests then failed in TS, Rust, Go, Python and .NET.

Also in this PR

  • .NET: Microsoft.SourceLink.GitHub 8.0.0 → 10.0.303. 8.0.0 pulls in Microsoft.Build.Tasks.Git 8.0.0, which has advisory GHSA-23fw-v26w-5fgq against it, and a clean restore now fails with NU1902.
  • Python README: the default timeout said 10s; the code default is 30s.

Jira: SMOODEV-3375

🤖 Generated with Claude Code

https://claude.ai/code/session_01CfZaWemmpghhtofBauYdti

…attempt

Retries were method-blind in every port: a POST that timed out or got a
429/5xx was re-sent up to twice more. In TypeScript the per-attempt timeout
(mollitia's Timeout module) only raced the request, so the losing attempt kept
running server-side while the retry sent it again. Image generation (20-50s)
against the 10s default billed three images and returned nothing, and any
non-idempotent POST/PATCH could duplicate its side effect.

Now, in all five ports, only idempotent methods (RFC 9110 9.2.2: GET, HEAD,
OPTIONS, TRACE, PUT, DELETE) retry. Anything else makes exactly one attempt
and surfaces its own error, unless the caller opts in with
retry.allowNonIdempotent (allow_non_idempotent / AllowNonIdempotent) or the
request carries a non-empty Idempotency-Key header. Eligibility is decided
after pre-request hooks and the auth provider, and onRejection cannot override
it. The client-side rate limiter's retry loop is unaffected: it rejects before
anything is sent.

TypeScript now aborts each attempt through an AbortController combined with
the caller's signal, and still races the attempt so a fetch that ignores the
signal times out on schedule. A caller-aborted request is no longer retried.
Go waits for the cancelled attempt to unwind before retrying; Python adds a
hard per-attempt deadline; Rust and .NET already cancelled and now prove it.

spec/retry-idempotency-corpus.json pins the rule; every port runs it against a
real local server and counts what the server received, plus a test that the
timed-out attempt's connection is actually closed.

Major bump: callers relying on POST retries lose them, and the new Rust
RetryOptions field breaks struct literals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfZaWemmpghhtofBauYdti
@changeset-bot

changeset-bot Bot commented Sep 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2c06d66

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@smooai/fetch Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager merged commit 1a84633 into main Sep 27, 2026
6 checks passed
@brentrager
brentrager deleted the SMOODEV-3375-safe-retries branch September 27, 2026 05:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant