Skip to content

fix(retry): never replay a non-idempotent write; bound server retry signals (#104) - #115

Merged
karlwaldman merged 1 commit into
mainfrom
fix/104-no-replay-of-writes
Sep 13, 2026
Merged

karlwaldman merged 1 commit into
mainfrom
fix/104-no-replay-of-writes

Conversation

@karlwaldman

Copy link
Copy Markdown
Member

Closes #104.

Confirmed live, not already fixed

Reproduced on origin/main (6e1a66c) with a mock transport and a fixture key:

POST timeout -> attempts: 3 err: TimeoutError
GET  timeout -> attempts: 3 err: TimeoutError
POST 503     -> attempts: 3 err: ServerError
max_retries=0 -> 3 | retry_on=[] -> [429, 500, 502, 503, 504]
429 Retry-After=31612 -> waits: [60.0, 60.0]     <- already capped on main
429 Retry-After=-30   -> waits: [-30.0, -30.0]   <- time.sleep() raises ValueError

One part of the issue is already fixed on main and was not re-filed: durable quota exhaustion (X-RateLimit-State: exhausted + a counter window) is already suppressed, and the 429 Retry-After already had a 60s upper cap. A regression test now pins both.

(a) Writes are no longer replayed

A timeout or transport error is an ambiguous outcome, not a failure — the server may have committed the write before the response was lost. A 5xx is equally ambiguous, since a gateway can return 502 after the origin already committed. Subscription and webhook creates go through these helpers, so a lost response became two subscriptions.

  • POST and PATCH are now sent exactly once on timeout, transport error, and 5xx.
  • GET, HEAD, OPTIONS, TRACE, PUT, DELETE retry exactly as before — a transient GET still recovers.
  • A 429 still retries any method. It is an outright refusal, so the write definitively did not happen and replay is safe. This is the one deliberate asymmetry.
  • A caller who knows a write is repeatable passes idempotent=True to request(). That is a caller's assertion; no server-side idempotency-key contract is claimed, because none was verified.
  • When a write is not replayed, the error explains that it was not retried and carries .ambiguous_write = True, so the caller checks whether it landed rather than blindly resending.

Applies to OilPriceAPI.request, OilPriceAPI.request_with_headers and AsyncOilPriceAPI.request alike.

(b) Explicit configuration survives the constructor

max_retries or DEFAULT and retry_on or DEFAULT discarded a caller's explicit 0 and []. Now explicit None checks.

max_retries counts total attempts — that is what the docstring always said and what for attempt in range(self.max_retries) does. It must be ≥ 1. 0, a negative, or a non-int now raises ConfigurationError naming the fix, instead of silently becoming 3:

max_retries=0 -> ConfigurationError - max_retries counts total attempts and must be
at least 1, got 0. Pass max_retries=1 for a single attempt with no retries.
retry_on=[] -> []

retry_on=[] is preserved and really does disable status-code retries (RetryStrategy.__init__ had the same or bug and is fixed too).

(c) Retry-After is bounded in both directions

One shared RetryStrategy.bounded_wait() clamps to [0, 60], used by all three request paths:

429 Retry-After=31612 -> waits: [60.0, 60.0]
429 Retry-After=-30   -> waits: [0.0, 0.0]

The upper bound is the one with scale behind it: the keyless demo returns retry-after: 31612, which unbounded parks a process for 8.8 hours (that is what the Go SDK does today). The lower bound stops time.sleep() raising ValueError on a negative header.

Red

Test written first, against unmodified origin/main:

$ .venv/bin/python -m pytest tests/unit/test_write_replay_and_retry_config.py --no-cov -q
>       assert counter.methods == [method], f"write was replayed: {counter.methods}"
E       AssertionError: write was replayed: ['POST', 'POST', 'POST']
E       assert ['POST', 'POST', 'POST'] == ['POST']
...
FAILED ...::test_write_is_sent_exactly_once[POST-_timeout]
FAILED ...::test_write_is_sent_exactly_once[POST-_connect_error]
FAILED ...::test_write_is_sent_exactly_once[POST-handler0]     # 503
FAILED ...::test_write_is_sent_exactly_once[POST-handler1]     # 500
FAILED ...::test_write_is_sent_exactly_once[PATCH-...]         # x4
FAILED ...::test_async_write_is_sent_exactly_once[...]         # x2
FAILED ...::test_request_with_headers_does_not_replay_a_write
FAILED ...::test_ambiguous_write_error_says_it_was_not_retried
FAILED ...::test_caller_can_opt_into_replaying_a_write
FAILED ...::test_empty_retry_on_is_preserved
FAILED ...::test_empty_retry_on_actually_stops_status_retries
FAILED ...::test_async_empty_retry_on_is_preserved
FAILED ...::test_invalid_max_retries_fails_loudly[0|-1|-5]
FAILED ...::test_non_integer_max_retries_fails_loudly[3|2.5|True]
FAILED ...::test_async_invalid_max_retries_fails_loudly
FAILED ...::test_negative_retry_after_never_reaches_sleep[-30|-1]
FAILED ...::test_retry_strategy_knows_which_methods_are_replay_safe
======================== 26 failed, 12 passed in 4.03s =========================

Every assertion counts what reached the transport, not an internal flag.

Green

$ .venv/bin/python -m pytest tests/unit/test_write_replay_and_retry_config.py --no-cov -q
============================== 38 passed in 0.44s ==============================

And the original repro script, re-run after the change:

POST timeout -> attempts: 1 err: TimeoutError
GET  timeout -> attempts: 3 err: TimeoutError
POST 503     -> attempts: 1 err: ServerError

Pre-existing failures unchanged

tests/unit on clean origin/main: 109 failed, 453 passed (mostly test_streaming.py).
tests/unit on this branch: 109 failed, 491 passed — same 109, +38 new passing.

Rest of the suite: 115 passed, 21 skipped, including the existing tests/unit/test_retry.py and tests/test_retry_remedy.py, untouched and still green.

Compatibility

RetryStrategy.should_retry() and should_retry_on_exception() keep their existing signatures — method and idempotent are optional keyword arguments, and omitting them means "unknown", which is treated as replay-safe so the public helper behaves as before. The SDK's own clients always pass the method.

Two intentional behaviour changes for existing callers:

  • A caller passing max_retries=0 now gets a loud ConfigurationError instead of silently getting 3 attempts. There is no value of max_retries for which behaviour changes silently.
  • client.diesel.get_stations() is a POST and so is now sent once rather than three times on a 503. That is the point of the change, but it is worth naming.

🤖 Generated with Claude Code

https://claude.ai/code/session_015ao5paex73xXvuM424Libo

…ignals (#104)

Three defects, all reproduced on origin/main with a mock transport.

1. Automatic retry replayed writes. A POST that timed out was sent three
   times, and so was a POST that got a 503:

       POST timeout -> attempts: 3
       POST 503     -> attempts: 3
       GET  timeout -> attempts: 3

   A timeout or transport error is an AMBIGUOUS outcome, not a failure: the
   server may have committed the write before the response was lost. A 5xx is
   equally ambiguous, because a gateway can return 502 after the origin
   committed. Subscription and webhook creates go through these helpers, so a
   lost response became two subscriptions.

   POST and PATCH are now sent exactly once. Idempotent methods (GET, HEAD,
   OPTIONS, TRACE, PUT, DELETE) retry exactly as before. A 429 still retries
   any method, because it is an outright refusal — the write definitively did
   not happen. A caller who knows a write is safe to repeat can pass
   idempotent=True to request(). When a write is not replayed the error says
   so and carries .ambiguous_write = True, so the caller knows to check
   whether it landed rather than blindly resending.

2. The constructor's `or` defaults discarded explicit configuration:

       max_retries=0 -> 3        retry_on=[] -> [429, 500, 502, 503, 504]

   Now explicit None checks. retry_on=[] is preserved and really does disable
   status-code retries. max_retries counts total ATTEMPTS — which is what the
   docstring always said and what `for attempt in range(self.max_retries)`
   does — so it must be >= 1; 0, a negative, or a non-int raises
   ConfigurationError naming the fix instead of silently becoming 3.

3. Retry-After was bounded above but not below:

       Retry-After: 31612 -> waits [60.0, 60.0]   (already capped)
       Retry-After: -30   -> waits [-30.0, -30.0] (time.sleep raises ValueError)

   Now clamped to [0, 60] through one shared RetryStrategy.bounded_wait(),
   used by the sync client, request_with_headers() and the async client alike.
   The 60s cap matters: the keyless demo returns retry-after 31612, 8.8 hours.

Durable-quota suppression (X-RateLimit-State: exhausted + a counter window) was
already correct on main and is unchanged; a regression test pins it.

RetryStrategy's public signatures are backward compatible: method/idempotent are
optional, and omitting them means "unknown", which is treated as replay-safe.
The SDK's own clients always pass the method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 390887d1-18e3-40a2-bfd4-0c2b45a79705


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@karlwaldman
karlwaldman merged commit fcc4edf into main Sep 13, 2026
7 checks passed
@karlwaldman
karlwaldman deleted the fix/104-no-replay-of-writes branch September 13, 2026 16:07
karlwaldman added a commit that referenced this pull request Sep 13, 2026
#122) (#125)

* fix(url,retry,futures): un-break today's three regressions (#119, #118, #122)

All three shipped today and all three are reachable from the SDK's own code.

#119 -- `_url.py` rejected U+0020 SPACE. `range(0x21)` is 0x00-0x20 INCLUSIVE;
the comment above it names CR/LF request splitting and NUL, all below 0x20, so
the range over-reached by exactly one character. `/v1/prices?name=Brent Crude`
raised where it previously worked, and `resources/demo.py` builds exactly that
shape (`path = f"{path}?codes={','.join(codes)}"`). A space cannot introduce an
authority or split a request line -- httpx percent-encodes it -- so nothing the
guard exists to stop is enabled by allowing it. Now `range(0x20)`; 0x00-0x1F,
0x7F and backslash stay refused, pinned by tests.

#118 -- #115 keyed replay safety on the HTTP method, which is right, but no
audit of this SDK's own call sites was done, so its POST-shaped READS lost all
retries. A station lookup is idempotent by construction: same lat/lng/radius,
same answer, nothing created. Removing its retries was a pure availability
regression -- a call that used to ride out one bad gateway response now fails
on the first blip.

The rule used to classify them: a POST is read-shaped when the endpoint creates
or mutates no server-side resource AND the same request sent twice returns the
same answer. Six call sites qualify and now pass `idempotent=True` -- diesel
`get_stations`, `webhooks.test`, `alerts.test`, each in both clients.
`data_sources.test` deliberately does NOT: it triggers a fetch against the
customer's configured source and can write to its ingest log. `create()` and
`rotate_credentials()` are unchanged and still sent exactly once.

`test_every_post_call_site_declares_intent` walks the package with `ast` and
fails until every `method="POST"` call either passes `idempotent=` or is listed
as a known write with a reason, so the next POST-shaped read cannot quietly
lose its retries the same way.

#122 -- `normalize_futures_slug` raised a builtin `ValueError`, outside the SDK
hierarchy. That predates #111, but #111 turned 18 live catalog codes from
"resolved to a DIFFERENT instrument" into "raises" -- the right call -- so user
code written as `except OilPriceAPIError: fall_back()` went from a silently
wrong answer to an uncaught crash. #111's own comment argues a refusal is
recoverable; it is only recoverable if it is catchable.

New `FuturesContractError(ValidationError, ValueError)`: catchable through the
documented base class, and still a `ValueError` so existing callers are
unaffected. It renders its own message, which lists every valid slug and
contract code, so the guidance survives regardless of #117's merge order.

Tests: tests/unit/test_url_allows_space.py (10),
tests/unit/test_read_shaped_post_retries.py (12),
tests/unit/test_futures_contract_error.py (26). Proven red against
`origin/main` source: 28 failed / 23 passed.

Suite: 112 failed / 681 passed / 63 skipped, against a measured baseline of
112 failed / 630 passed / 63 skipped on clean main (the 112 are pre-existing,
all missing pytest-asyncio and respx in the environment).

Note for reviewers: `resources/alerts.py` and `resources/diesel.py` are CRLF
files in this repo. Their line endings are preserved -- the diff is 5 and 6
lines, not the whole file.

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

* style: sort FuturesContractError into the __init__ import block

ruff's isort rule failed CI: the new export was inserted before
ValidationError rather than in alphabetical position after DataNotFoundError.
No behaviour change.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
karlwaldman added a commit that referenced this pull request Sep 13, 2026
…ies=0 (#120, #121) (#126)

Both halves of the constructor argument handling #115 started and did not
finish, plus the version bump and changelog that change needed.

#120 -- #115 replaced two of the three `or` defaults and left the third, on the
line directly above its own fix, in both clients:

    self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")   # unchanged
    self.timeout  = timeout or self.DEFAULT_TIMEOUT                   # unchanged
    self.max_retries = (... if max_retries is None else ...)          # fixed
    self.retry_on = (... if retry_on is None else ...)                # fixed

`timeout=0` is a real httpx timeout meaning "fail immediately" and is what
`float(os.getenv("OPA_TIMEOUT", "0"))` produces. It silently became 30, and the
resulting hang is very hard to attribute back to the constructor. A negative
timeout was passed down to httpx unvalidated. `base_url=""` pointed the client
at PRODUCTION, which also pins the #113 origin guard to an origin the caller
did not choose -- the worst available answer for an input nobody meant.

Both are now explicit None checks with validation, matching the two lines
below them. `timeout=None` and `base_url=None` still take the defaults.

#121 -- `max_retries=0` and `max_retries=3.0` started raising
`ConfigurationError` AT CLIENT CONSTRUCTION, having constructed fine in 1.13.0,
with no version bump and no changelog. The validation is right; the delivery
was not. This fails at startup, so it takes a whole process down rather than
degrading one call, and `int(os.getenv("OPA_MAX_RETRIES", "0"))` is the common
way to produce it.

Recommendation taken: accept them again, do not break.

  0    -> 1 attempt, with a DeprecationWarning saying the argument counts
          total ATTEMPTS, not retries after the first
  3.0  -> 3, with a DeprecationWarning
  -1   -> still ConfigurationError
  2.5  -> still ConfigurationError (not a whole number of attempts)
  "3"  -> still ConfigurationError
  True -> still ConfigurationError (a typo hazard; it would mean 1)

The bug #104 fixed does not come back: 0 resolves to ONE attempt, never 3, and
`test_zero_max_retries_does_not_go_back_to_three` counts what reaches the
transport.

Version bumped 1.13.0 -> 1.14.0 in `version.py` and `pyproject.toml`, with a
CHANGELOG entry covering both, including an "Upgrading" note: nothing that
worked in 1.13.0 raises in 1.14.0, but `timeout=0` and `max_retries=0` now mean
what they say instead of 30 and 3.

Two tests added in #115 are updated rather than deleted:
`test_invalid_max_retries_fails_loudly` drops `0` from its parametrize and
keeps the negatives; `test_async_invalid_max_retries_fails_loudly` uses -1. The
new contract for 0 is pinned in
tests/unit/test_constructor_config_validation.py (43 tests, every one run
against BOTH clients).

Proven red against `origin/main` source: 22 failed / 21 passed.

Suite: 112 failed / 673 passed / 63 skipped, against a measured baseline of
112 failed / 630 passed / 63 skipped on clean main (the 112 are pre-existing,
all missing pytest-asyncio and respx in the environment).


Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
karlwaldman added a commit that referenced this pull request Sep 13, 2026
…ocal refusal (#123) (#134)

* fix(url,retry,errors): typed errors, a real 60s cap, no status on a local refusal (#123)

Three promises the code did not keep.

1. `_url` documents ValidationError as the only thing it raises, but `_origin`
   and `urljoin` both leak a raw stdlib ValueError for an out-of-range port
   ("...:99999") and for a non-ASCII netloc whose NFKC normalisation introduces
   one of /?#@: ("https://℀evil.example"). `_origin` runs on base_url on
   EVERY request, so a misconfigured client raised a bare ValueError from the
   hot path. Both are now ConfigurationError naming base_url.

2. `calculate_wait_time` documents "capped at 60 seconds" and returned up to
   78s: min(2 ** attempt, 60) bounded the base, then up to 30% jitter was added
   on top. #115 added `bounded_wait` but wired it only into the 429/Retry-After
   path; the 5xx and transport-error paths in both clients call this raw.
   Clamping inside `calculate_wait_time` fixes every call site at once and
   cannot drift between the sync and async clients. Jitter is preserved below
   saturation; the docstring now states where it saturates.

3. `ValidationError` hard-coded status_code=422, so a purely local guard
   refusal -- raised by `_url._reject` before any socket is opened -- reported
   `.status_code == 422` and `.is_client_error is True`. Anything logging or
   aggregating by status recorded a server response that never happened.
   The default stays 422 for existing callers and for a real API 422; the local
   guard now passes status_code=None explicitly.

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

* test(retry): mock the transport the way this repo already does

respx is not in the [dev] extra, so the two 5xx wire tests failed CI with
ModuleNotFoundError on every Python version. Patch httpx.Client.request /
httpx.AsyncClient.request instead, matching tests/unit/test_diesel_envelope.py,
rather than adding a test dependency for two assertions.

Same assertions, same red: 18 failed, 13 passed against pre-fix sources.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

[P1][Review] Stop automatic replay of non-idempotent writes and honor explicit retry configuration

1 participant