fix(url,retry,errors): typed errors, a real 60s cap, no status on a local refusal (#123) - #134
Merged
Merged
Conversation
…ocal 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
|
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 |
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
This was referenced Sep 13, 2026
Merged
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
…t ValueError (#99) A builtin ValueError escaped the documented `except OilPriceAPIError` catch-all (#123). Local refusals now go through one helper matching _url._reject: ValidationError(message, field, value, status_code=None), since no request was sent (#134). format_date's ValueError is re-raised as ValidationError with the right field. All methods are new, so no dual-base subclass is needed. Tests assert the exact type, status_code None and field; proven red by temporarily restoring ValueError (35 failed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
karlwaldman
added a commit
that referenced
this pull request
Sep 13, 2026
…ources (#99) (#146) * feat(spreads,indicators): typed sync+async resources for /v1/spreads and /v1/indicators (#99) Adds client.spreads (15 methods) and client.indicators (10 methods) on both clients, typed from production responses captured 2026-09-13. Request building, argument validation and envelope parsing live once in resources/_calculated_metrics.py so sync and async cannot drift. A key the server always emits is required; a malformed 200 raises OilPriceAPIError(code="MALFORMED_RESPONSE") with the raw body. Blank selectors, invalid dates, start>end and >20 batch codes are refused before any request. congressional-trades is not exposed (never returned data). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo * fix(spreads,indicators): raise ValidationError for local refusals, not ValueError (#99) A builtin ValueError escaped the documented `except OilPriceAPIError` catch-all (#123). Local refusals now go through one helper matching _url._reject: ValidationError(message, field, value, status_code=None), since no request was sent (#134). format_date's ValueError is re-raised as ValidationError with the right field. All methods are new, so no dual-base subclass is needed. Tests assert the exact type, status_code None and field; proven red by temporarily restoring ValueError (35 failed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo --------- Co-authored-by: Claude Opus 5 <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.
The remaining three findings from #123. The fourth — the scheme-less
base_urltautology — is split out into #131 because its failure mode is different in kind.1. A raw
ValueErrorescapes a contract that says it cannot_url's module docstring andresolve_api_url'sRaises:both promiseValidationError. Two inputs leak a bare stdlibValueErrorinstead, measured onmainat7982b0b, CPython 3.14.7:The second is the unicode case an ASCII probe cannot reach: CPython's
urlsplitrefuses a non-ASCII netloc whose NFKC normalisation introduces one of/?#@:.This matters more than a tidy-up because
_originruns onbase_urlon every single request, so the leak is on the hot path, not at construction.urljoinraises the same errors and runs first, so both had to be wrapped — wrapping only_origin, as the issue suggests, left the NFKC case still escaping. Caught here during the fix.Both now raise
ConfigurationErrornamingbase_url.2. The documented 60-second cap was not a cap
min(2 ** attempt, 60)bounds the base, then up to 30% jitter is added on top — so from attempt 6 onward the documented cap is exceeded, up to 78s at the ceiling. Over 2000 draws at attempt 10 I measured a worst case of 77.97s against aMAX_WAIT_SECONDSof 60.#115 added
bounded_waitand wired it only into the 429/Retry-Afterpath. The 5xx and transport-error paths callcalculate_wait_timeraw — 8 call sites inclient.py, 4 inasync_client.py.Clamping inside
calculate_wait_timefixes all 12 at once and, more importantly, cannot drift between the two clients the way 12 separate call-site clamps could. Jitter is preserved below saturation (pinned by a test that asserts the values are still spread, so the fix does not re-create the thundering herd the jitter exists to prevent), and the docstring now says where it saturates instead of stating a cap it did not enforce.3. A local refusal reported an HTTP status for a request never sent
ValidationError.__init__hard-codedstatus_code=422._url._rejectfires before any socket is opened, yet:Anything logging or aggregating by status recorded a 422 the API never returned.
status_codeis now a real parameter defaulting to 422 — so existing callers and a genuine API 422 are unchanged — and the local guard passesstatus_code=None.is_client_erroralready returnedFalseforNone, so no change was needed there.Red
Tests written first, against unmodified
main:Re-proved red-capable after the fix by restoring the pre-fix sources (
git checkout origin/main -- oilpriceapi/_url.py oilpriceapi/retry.py oilpriceapi/exceptions.py) — that is the run pasted above.Note
test_calculate_wait_time_is_bounded_at_every_attemptfails at 6 and up and passes at 0–5. That is the bug's actual shape: below saturation the jitter has room under the cap.Green
Sync and async parity
The two 5xx tests are the load-bearing ones, and they are behavioural, not structural — they drive the real transport with
respx, force a 503 retry storm atmax_retries=14, intercepttime.sleep/asyncio.sleep, and assert no sleep exceedsMAX_WAIT_SECONDS. Both assertsleepsis non-empty first, so a test that silently stopped retrying cannot pass by doing nothing. This is exactly the 5xx path #115 left unbounded, checked identically on both clients.A third test pins structurally that neither client computes its own backoff (
2 **) or adds its own jitter (random.uniform) — both must keep routing every wait through the one shared, now-boundedcalculate_wait_time.A note on test mocking
The first push used
respx, which is not in this repo's[dev]extra, so CI failed withModuleNotFoundErroron all five Python versions. A follow-up commit switches to patchinghttpx.Client.request/httpx.AsyncClient.request, which is this repo's existing convention (tests/unit/test_diesel_envelope.py). Same assertions, same red counts — no test dependency added for a handful of assertions.Full suite
origin/main)+31 is exactly this PR's new tests. The 3 failures are
tests/integration/test_demo_contract.pymaking live calls and getting HTTP 429 — environmental, identical before and after.git diff --stat:3 files changed, 66 insertions(+), 12 deletions(-). No CRLF normalisation ofresources/alerts.pyorresources/diesel.py.Left out on purpose
base_urltautology is fix(config): refuse a scheme-less base_url so the origin guard stays real (#123) #131.diesel.pystate_codecasing note at the end of [P3][bug] URL guard and retry hardening: raw ValueError escapes, scheme-less base_url no-ops the guard, jitter exceeds the 60s cap #123 is a separate PR — while checking it I found sync and async diverge on more than casing:resources/diesel.py:116-118has astate_codefallback block thatasync_resources.py:40-45does not have at all. That is a bigger finding than the casing note and does not belong in a URL/retry PR.Version deliberately left at 1.14.0; release decision is separate.
Closes parts 1, 3 and 4 of #123.
🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo