Skip to content

feat(api): return 429 when the Claude usage limit is reached - #48

Open
hamsahmedansari wants to merge 4 commits into
codingworkflow:mainfrom
hamsahmedansari:feat/usage-limit-429
Open

hamsahmedansari wants to merge 4 commits into
codingworkflow:mainfrom
hamsahmedansari:feat/usage-limit-429

Conversation

@hamsahmedansari

Copy link
Copy Markdown

Problem

When the Claude subscription usage limit is reached, the gateway returns 503 with
error.code = claude_unavailable — the same response as a missing binary or a crashed
process. 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_error was only populated from stderr (_read_error) and from the
exit-code composition. _read_output never inspected stdout.

In --output-format stream-json, Claude Code reports failures as a result object on
stdout with is_error set (observed on CLI 2.1.281):

{"type":"result","subtype":"success","is_error":true,
 "result":"There's an issue with the selected model (...)"}

Note subtype is success while is_error is true. These errors were discarded.

Changes

  • _record_output_error captures result objects with is_error set, so failures
    reported on stdout reach last_error.
  • _is_usage_limit_error / _extract_usage_limit_reset classify usage limit failures and
    read a reset epoch when the CLI reports one.
  • ClaudeUsageLimitError maps to 429 with error.code = usage_limit_reached, distinct
    from the gateway's own rate_limit_exceeded.
  • Usage limit failures skip the model fallback loop, since the limit applies to the
    account rather than to a single model.
  • Retry-After is clamped to 24h and omitted when the reported reset has already passed,
    so a stale reset cannot produce a one-second retry loop.
  • The custom http_exception_handler now propagates exc.headers, which it previously
    dropped for every error response. Side effect: 405 responses now carry Allow, per
    RFC 9110.
  • 429 and reset_at added to the OpenAPI responses and ErrorDetail; error codes
    documented 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

  • Full suite green: 108 tests with -m "not e2e" on Windows / Python 3.12 (91 before
    this change). 17 new tests cover detection, reset parsing, the fallback-skip behaviour,
    Retry-After bounds, and the HTTP mapping for both streaming and non-streaming requests.
  • flake8, black and isort are clean.
  • The stdout is_error behaviour this fix relies on was confirmed against CLI 2.1.281 by
    capturing the CLI's real stream-json output on a failing run (the result object quoted
    above 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.

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.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Return 429 for Claude subscription usage limits

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Detect Claude subscription exhaustion from process output and bypass inappropriate model fallback.
• Return distinct 429 errors with reset metadata and bounded Retry-After guidance.
• Document and test usage-limit behavior across streaming and non-streaming requests.
Diagram

graph TD
  A["Chat Request"] --> B["Claude Manager"] --> C["Claude Process"] --> D{"Usage Limit?"}
  D -- "Yes" --> E["Usage Error"] --> F["HTTP Mapper"] --> G["429 Response"]
  D -- "No" --> H["Existing Fallback"]
Loading
High-Level Assessment

The typed domain-error approach is appropriate because classification must occur where raw process output and fallback decisions are available. Detecting usage limits only in the API layer was considered but would leak subprocess details upward and could not prevent unnecessary model fallback.

Files changed (7) +357 / -7

Enhancement (1) +3 / -0
openai.pyExpose usage-limit reset metadata in error schemas +3/-0

Expose usage-limit reset metadata in error schemas

• Adds the optional reset_at field to ErrorDetail so generated OpenAPI schemas describe usage-limit reset timestamps.

claude_code_api/models/openai.py

Bug fix (3) +103 / -7
chat.pyMap Claude usage limits to HTTP 429 +38/-3

Map Claude usage limits to HTTP 429

• Handles ClaudeUsageLimitError with the usage_limit_reached code, optional reset_at metadata, and a Retry-After header capped at 24 hours. Adds the 429 response to the chat completion OpenAPI declaration and extends the HTTP error helper to accept metadata and headers.

claude_code_api/api/chat.py

claude_manager.pyDetect and classify Claude usage-limit failures +57/-2

Detect and classify Claude usage-limit failures

• Captures error result objects emitted on stdout, combines them with stderr diagnostics, recognizes usage-limit messages, and parses supported reset epochs. Raises a dedicated exception before model fallback because subscription limits apply account-wide.

claude_code_api/core/claude_manager.py

main.pyPreserve HTTPException response headers +8/-2

Preserve HTTPException response headers

• Passes HTTPException headers through the custom JSON handler. This enables Retry-After for usage limits and preserves standard headers such as Allow on 405 responses.

claude_code_api/main.py

Tests (2) +224 / -0
test_end_to_end.pyVerify usage-limit HTTP responses +90/-0

Verify usage-limit HTTP responses

• Adds API tests for streaming and non-streaming 429 responses, reset metadata, stale resets, and the 24-hour Retry-After cap.

tests/test_end_to_end.py

test_usage_limit.pyTest process-level usage-limit detection +134/-0

Test process-level usage-limit detection

• Adds focused tests for message classification, reset epoch parsing, stdout error capture, typed exception propagation, and skipping model fallback after account-level exhaustion.

tests/test_usage_limit.py

Documentation (1) +27 / -0
README.mdDocument OpenAI-compatible error responses +27/-0

Document OpenAI-compatible error responses

• Adds an error-code reference distinguishing subscription exhaustion from gateway throttling and Claude availability failures. Documents the optional reset timestamp and Retry-After behavior.

README.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Delayed quota errors return success ✓ Resolved 🐞 Bug ≡ Correctness
Description
_record_output_error only stores the terminal error in _result_error, while
_start_with_fallback_models classifies that value only when start() returns false during its
fixed startup check. When the result arrives after the 1.5-second deadline or accompanies a zero
exit code, both response paths consume it as a normal final result and return HTTP 200 with an empty
completion instead of the new 429 response.
Code

claude_code_api/core/claude_manager.py[R190-192]

+        result_text = data.get("result")
+        if isinstance(result_text, str) and result_text.strip():
+            self._result_error = result_text.strip()
Evidence
The added method records the error only in _result_error; _verify_startup stops checking after
1.5 seconds and explicitly accepts any zero exit code, while _compose_process_error is reached
only for an early nonzero exit. After start() succeeds, the manager immediately registers and
returns the process, and downstream parsers treat every result object as a final message without
checking is_error, yielding an ordinary empty completion.

claude_code_api/core/claude_manager.py[104-118]
claude_code_api/core/claude_manager.py[185-245]
claude_code_api/core/claude_manager.py[495-530]
claude_code_api/api/chat.py[169-188]
claude_code_api/utils/parser.py[190-192]
claude_code_api/utils/streaming.py[117-148]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Usage-limit results are only propagated when the Claude process fails during the fixed startup window. Terminal error results arriving later are treated as successful output, so callers receive HTTP 200 instead of the intended 429.
## Fix Focus Areas
- claude_code_api/core/claude_manager.py[104-118]
- claude_code_api/core/claude_manager.py[185-245]
- claude_code_api/core/claude_manager.py[495-530]
## Recommended Fix
Coordinate startup with parsed stdout so an `is_error` result is classified before the process is accepted as successful. Preserve buffered normal output for consumers, but make a terminal usage-limit result set `last_error` and cause `start()` to fail regardless of whether it arrives after the current startup deadline or the subprocess exits with code zero; add tests covering both cases.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Quota failures return generic errors ✓ Resolved 🐞 Bug ☼ Reliability
Description
_verify_startup() composes last_error immediately after observing a nonzero return code, while
_record_output_error() runs only in an independent stdout reader task that is neither awaited nor
drained first. A Claude process that exits quickly can therefore be stopped before its buffered
stdout result is recorded, so _start_with_fallback_models() misses the usage-limit text and raises
the generic startup failure instead.
Code

claude_code_api/core/claude_manager.py[R241-243]

+        if self._result_error:
+            details.append(self._result_error)
+        details.extend(self._stderr_tail)
Evidence
The reader tasks are created separately before startup verification, but verification composes the
error as soon as returncode is nonzero. The newly added stdout result is only stored by the reader
task, and the startup failure path calls stop(), which cancels reader tasks; classification
subsequently relies exclusively on the resulting last_error.

claude_code_api/core/claude_manager.py[104-114]
claude_code_api/core/claude_manager.py[157-161]
claude_code_api/core/claude_manager.py[185-192]
claude_code_api/core/claude_manager.py[215-246]
claude_code_api/core/claude_manager.py[264-284]
claude_code_api/core/claude_manager.py[524-532]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
Issue description
`_compose_process_error()` now depends on `_result_error`, but startup failure handling can compose the error before the stdout reader has consumed the CLI's final `result` record. This makes the new quota classification timing-dependent and can preserve the old generic 503 response.
Fix Focus Areas
- claude_code_api/core/claude_manager.py[97-114]
- claude_code_api/core/claude_manager.py[215-246]
- claude_code_api/core/claude_manager.py[264-284]
Recommended Fix
When startup observes process termination, wait for stdout and stderr consumption to finish before composing `last_error`; do not cancel those reader tasks until their remaining buffered output has been drained. Then compose and classify the fully collected error text.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Quota errors leave hidden sessions active 🐞 Bug ☼ Reliability
Description
_resolve_session() creates and persists a session before create_session() is attempted, but the
new ClaudeUsageLimitError handler raises its 429 response without calling
session_manager.end_session(). When callers omit session_id, they receive only the error payload
rather than the generated identifier, leaving an active database and in-memory session that they
cannot subsequently stop.
Code

claude_code_api/api/chat.py[R371-393]

+        except ClaudeUsageLimitError as e:
+            logger.warning(
+                "Claude usage limit reached",
+                session_id=session_id,
+                reset_at=e.reset_at.isoformat() if e.reset_at else None,
+            )
+            extra: Dict[str, Any] = {}
+            headers: Dict[str, str] = {}
+            if e.reset_at:
+                extra["reset_at"] = e.reset_at.isoformat()
+                retry_after = (e.reset_at - datetime.now(timezone.utc)).total_seconds()
+                if retry_after > 0:
+                    headers["Retry-After"] = str(
+                        min(int(retry_after) + 1, MAX_RETRY_AFTER_SECONDS)
+                    )
+            raise _http_error(
+                status.HTTP_429_TOO_MANY_REQUESTS,
+                "Claude subscription usage limit reached.",
+                "rate_limit_error",
+                "usage_limit_reached",
+                extra=extra or None,
+                headers=headers or None,
+            ) from e
Evidence
A request without a session ID persists a new active session before launching Claude. The added
usage-limit branch immediately raises an HTTP exception, whereas the available session cleanup
operation deactivates and removes the active session; the 429 response does not expose the generated
session ID.

claude_code_api/api/chat.py[128-150]
claude_code_api/api/chat.py[328-355]
claude_code_api/api/chat.py[371-393]
claude_code_api/core/session_manager.py[145-181]
claude_code_api/core/session_manager.py[267-284]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
Issue description
The usage-limit handler returns before the request's automatically created session is ended. Since no session ID is returned with this error, callers cannot clean up that active session themselves.
Fix Focus Areas
- claude_code_api/api/chat.py[128-150]
- claude_code_api/api/chat.py[371-393]
- claude_code_api/core/session_manager.py[267-284]
Recommended Fix
Track whether this request created a session rather than receiving a caller-supplied `session_id`. In the `ClaudeUsageLimitError` branch, end that newly created session before raising the HTTP exception; preserve existing sessions supplied by clients for possible later retry.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can choose which labels appear on a finding, and whether they show icons or text

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread claude_code_api/core/claude_manager.py
Comment thread claude_code_api/core/claude_manager.py
Comment thread claude_code_api/api/chat.py Outdated
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.
@hamsahmedansari

Copy link
Copy Markdown
Author

Thanks — findings 1 and 2 were real and are fixed in 874211c.

Both came down to the same thing: _verify_startup composed the failure text as soon as
it saw a non-zero exit code, while _read_output ran as an independent task that was
never drained. So a fast-exiting process could be stopped before its result object was
recorded, and a terminal error arriving with a zero exit code — or after the 1.5s
deadline — was consumed as ordinary output.

  • _drain_readers() now waits for buffered stdout and stderr to be consumed before the
    error text is composed.
  • A recorded terminal error fails startup independently of the exit code.

Two tests cover these directly: a zero exit carrying an is_error result, and an error
recorded by a reader that has not finished when the process exits.

On finding 3 (sessions left active): this looks pre-existing rather than introduced here —
end_session() is only called from the stop endpoint, and the existing session_busy,
model_not_supported and generic startup-failure handlers all return without ending an
auto-created session either. I have kept the new handler consistent with those rather than
touch four error paths in this PR, but happy to follow up separately if you would like it
fixed across the board.

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.

This branch has not been deployed

No deployments
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.

1 participant