Repository navigation
feat(api): return 429 when the Claude usage limit is reached - #48
hamsahmedansari wants to merge 4 commits into
Conversation
Claude Code reports a spent subscription through the result object on stdout and through stderr, but neither was inspected for it, so an exhausted subscription surfaced as a generic 503 claude_unavailable. Callers could not tell a spent quota apart from a missing binary or a crashed process, which makes falling back to another provider unreliable. Capture errors Claude reports on stdout (result objects with is_error set), classify usage limit failures, and map them to 429 with error.code = usage_limit_reached. When the CLI reports a reset time the response carries reset_at and a Retry-After header, clamped to a day and omitted when the reported reset has already passed. Usage limit failures skip the model fallback loop, since the limit applies to the account rather than to a single model. Also propagate HTTPException headers in the custom exception handler, which previously dropped them for every error response.
PR Summary by QodoReturn 429 for Claude subscription usage limits
AI Description
Diagram
High-Level Assessment
Files changed (7)
|
Code Review by Qodo
1.
|
Startup verification composed the failure text as soon as it saw a non-zero exit code, while the stdout reader ran as an independent task that was never drained. A process that exited quickly could be stopped before its result object was recorded, so the usage limit text was missed and the generic startup failure was raised instead. A result carrying is_error was also ignored when the process exited zero or stayed alive past the startup deadline, so a terminal error was consumed as ordinary output and the request completed successfully with no content. Drain the reader tasks before composing the error, and treat a recorded terminal error as a startup failure independently of the exit code.
|
Thanks — findings 1 and 2 were real and are fixed in 874211c. Both came down to the same thing:
Two tests cover these directly: a zero exit carrying an On finding 3 (sessions left active): this looks pre-existing rather than introduced here — |
Startup verification gives Claude 1.5 seconds to fail. A subscription limit is often reported later than that, so the run was accepted as successful and the terminal error text was returned to the caller as assistant content with HTTP 200 instead of the 429. Expose the error Claude reported on stdout and classify it once the run has finished, using the same helper as the start-time path so the two cannot drift.
The limit raised while collecting output was caught by the generic handler and reported as 500 unexpected_error, so callers still could not tell an exhausted subscription from a server fault. Share one helper between the start-time and end-of-run paths and handle the error where the collection runs.
Problem
When the Claude subscription usage limit is reached, the gateway returns
503witherror.code = claude_unavailable— the same response as a missing binary or a crashedprocess. Callers cannot tell an exhausted quota (temporary, resets on its own) from a
broken install (needs intervention), which makes automatic fallback to another provider
unreliable.
Cause
ClaudeProcess.last_errorwas only populated from stderr (_read_error) and from theexit-code composition.
_read_outputnever inspected stdout.In
--output-format stream-json, Claude Code reports failures as aresultobject onstdout with
is_errorset (observed on CLI 2.1.281):{"type":"result","subtype":"success","is_error":true, "result":"There's an issue with the selected model (...)"}Note
subtypeissuccesswhileis_erroristrue. These errors were discarded.Changes
_record_output_errorcapturesresultobjects withis_errorset, so failuresreported on stdout reach
last_error._is_usage_limit_error/_extract_usage_limit_resetclassify usage limit failures andread a reset epoch when the CLI reports one.
ClaudeUsageLimitErrormaps to429witherror.code = usage_limit_reached, distinctfrom the gateway's own
rate_limit_exceeded.account rather than to a single model.
Retry-Afteris clamped to 24h and omitted when the reported reset has already passed,so a stale reset cannot produce a one-second retry loop.
http_exception_handlernow propagatesexc.headers, which it previouslydropped for every error response. Side effect:
405responses now carryAllow, perRFC 9110.
429andreset_atadded to the OpenAPI responses andErrorDetail; error codesdocumented in the README.
Response shape
{ "error": { "message": "Claude subscription usage limit reached.", "type": "rate_limit_error", "code": "usage_limit_reached", "reset_at": "2027-03-20T09:46:40+00:00" } }Verification
-m "not e2e"on Windows / Python 3.12 (91 beforethis change). 17 new tests cover detection, reset parsing, the fallback-skip behaviour,
Retry-Afterbounds, and the HTTP mapping for both streaming and non-streaming requests.is_errorbehaviour this fix relies on was confirmed against CLI 2.1.281 bycapturing the CLI's real
stream-jsonoutput on a failing run (theresultobject quotedabove is verbatim from that capture).
The matcher targets the usage-limit strings the CLI ships. If you have a recorded fixture
from a live limit event, I'm happy to add it and tighten the match.