fix(retry): never replay a non-idempotent write; bound server retry signals (#104) - #115
Merged
Merged
Conversation
…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
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 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. Comment |
This was referenced Sep 13, 2026
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>
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.
Closes #104.
Confirmed live, not already fixed
Reproduced on
origin/main(6e1a66c) with a mock transport and a fixture key: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 429Retry-Afteralready 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.
POSTandPATCHare now sent exactly once on timeout, transport error, and 5xx.GET,HEAD,OPTIONS,TRACE,PUT,DELETEretry exactly as before — a transient GET still recovers.idempotent=Truetorequest(). That is a caller's assertion; no server-side idempotency-key contract is claimed, because none was verified..ambiguous_write = True, so the caller checks whether it landed rather than blindly resending.Applies to
OilPriceAPI.request,OilPriceAPI.request_with_headersandAsyncOilPriceAPI.requestalike.(b) Explicit configuration survives the constructor
max_retries or DEFAULTandretry_on or DEFAULTdiscarded a caller's explicit0and[]. Now explicitNonechecks.max_retriescounts total attempts — that is what the docstring always said and whatfor attempt in range(self.max_retries)does. It must be ≥ 1.0, a negative, or a non-int now raisesConfigurationErrornaming the fix, instead of silently becoming 3:retry_on=[]is preserved and really does disable status-code retries (RetryStrategy.__init__had the sameorbug 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: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 stopstime.sleep()raisingValueErroron a negative header.Red
Test written first, against unmodified
origin/main:Every assertion counts what reached the transport, not an internal flag.
Green
And the original repro script, re-run after the change:
Pre-existing failures unchanged
tests/uniton cleanorigin/main: 109 failed, 453 passed (mostlytest_streaming.py).tests/uniton 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.pyandtests/test_retry_remedy.py, untouched and still green.Compatibility
RetryStrategy.should_retry()andshould_retry_on_exception()keep their existing signatures —methodandidempotentare 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:
max_retries=0now gets a loudConfigurationErrorinstead of silently getting 3 attempts. There is no value ofmax_retriesfor 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