fix: do not retry exhausted durable quotas or shorten Retry-After (#16) - #19
Conversation
isRetryable() looked only at the status code, so a 429 reporting a spent monthly, trial or demo quota was retried three more times against a limit that cannot refill inside any backoff. backoffDelay() clamped Retry-After to the 30-second budget, so a 120-second instruction produced a request after 30 seconds - a second refusal by construction. Verified on origin/main: a MONTHLY_QUOTA_EXCEEDED 429 with Retry-After 120 made 4 requests and slept [30, 30, 30]. Classify durable quota codes from error_code, error.code and code, and skip retries for them. Let the server's Retry-After win: when it exceeds the budget, stop and raise RateLimitException carrying the real delay instead of sleeping less. Treat a negative delta-seconds Retry-After as malformed and fall back to backoff rather than sleeping zero; an HTTP-date already in the past still means "retry now" and becomes 0. maxRetries=0 was already correct in this client (one request, no sleep) and is now covered by a regression test. Refs #16 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 |
The packaged-claims validator flags a numeral, a request noun and a cadence word inside one sentence. Restate the durable-quota entry without them. Refs #16 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
…licy #17 inserted its path-normalization helpers immediately before isRetryable(), and this branch changed that method's signature from int $statusCode to HttpResponse $response, so the two hunks overlapped. Resolution keeps every helper #17 added (normalizeApiPath, assertSameOrigin, originOf, offOriginPath) and this branch's new isRetryable/isDurableQuotaExhausted/retryDelay/parseRetryAfter, with both call sites in request() intact. The CHANGELOG Unreleased block keeps both entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
Updated for main at
|
src/Client.php auto-merged as predicted: priceOrFail/priceListOrFail from #18, normalizeApiPath/assertSameOrigin from #17 and isRetryable/isDurableQuotaExhausted/retryDelay from this branch all coexist, with every call site in request() intact. Only the CHANGELOG Unreleased bullets conflicted; all three entries are kept, #18's ahead of this branch's two since it landed first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo
Updated for main at
|
|
PHP expert review of this branch as proposed ( The policy is right and, unusually for a retry change, it is verifiable against production. I hit the live demo endpoint today and got the real refusal: That is exactly the nested Four notes, none blocking: 1. Two zero-delay hot retries survive the new guard. It rejects a negative 2. 3. 4. Rebase. This branches from pre-#17 Also noted while here: the live 429 body carries its own Full review notes in |
Closes #16.
Confirmed live on
origin/main(6879501) — and one part already safeMock transport with a request counter and an injected sleeper:
origin/mainMONTHLY_QUOTA_EXCEEDED,Retry-After: 120[30, 30, 30]— confirmed liveRetry-After: 120Retry-After: -300— already non-negative (parseRetryAfterclamps withmax(0, ...)); does not reproduce the Python negative-sleep bugmaxRetries = 0Both Python-specific defects named in the issue were checked rather than assumed and neither ports to PHP. The two that are real here are the durable-quota retry and the shortened
Retry-After.Quota classification, from the API's own error contract
Codes taken from
oilpriceapi-apiorigin/main(107f140ec),app/controllers/v1/base_controller.rbandapp/controllers/v1/demo_controller.rb— not guessed:Durable (never retried):
MONTHLY_QUOTA_EXCEEDED,DAILY_QUOTA_EXCEEDED,QUOTA_EXCEEDED,TRIAL_LIMIT_EXCEEDED,TRIAL_EXPIRED,EMAIL_CONFIRMATION_REQUIRED,DEMO_RATE_LIMIT_EXCEEDED.Recoverable (still retried):
RATE_LIMIT_EXCEEDED,HOURLY_CIRCUIT_BREAKER_EXCEEDED, and every 5xx.MONTHLY_QUOTA_EXCEEDEDis a known misnomer for daily-window free users (api#5788) and is deliberately stable as a client contract, so matching on it is correct for both windows. Codes are read fromerror_code(top level, used by the quota path),error.code(nested, used by the demo path) andcode, compared case-insensitively.Retry-After
RateLimitExceptioncarrying the server's real delay and the upgrade URL, so the caller schedules it properlyretryAfterasnullrather than00Every sleep remains non-negative and
<= 30s; a test asserts that across a mixed sequence.Red / green
Red, proven against pre-fix code (
git checkout origin/main -- src/Client.php, rerun, restore):Green, with the fix:
Baseline on clean
origin/mainbefore any change:OK (26 tests, 323 assertions). PHP 8.5.8, Composer 2.10.2, PHPUnit 13.3.3.The
maxRetries = 0and negative-Retry-Aftertests pass onorigin/maintoo — they are regression cover for behaviour that was already correct, kept so the Python failure mode cannot appear here later.Compatibility
Public API unchanged; no new dependency and no scheduler.
isRetryable()andbackoffDelay()were private. The only behaviour change is fewer requests and no early retry.Not merged, no auto-merge, no release tagged.
🤖 Generated with Claude Code
https://claude.ai/code/session_015ao5paex73xXvuM424Libo