v2.10.0: quota visibility, correct rate-limit semantics, and the 401 misclassification fix - #41
Merged
Merged
Conversation
Supersedes the three superseded bot bump PRs (#38 -> 0.2.134, #39 -> 0.2.139, #40 -> 0.2.144); 0.2.148 is the latest published. Two upstream fixes in this range matter to the wrapper: - ResultError (subclasses ProcessError) now carries structured subtype, errors, result, api_error_status, terminal_reason and the raw payload, so a failed run can be classified without string matching. - _error_result_text prefers errors[] -> result -> non-success subtype -> HTTP status. A run that ends on an API failure arrives as subtype "success" with is_error true and empty errors[]; the old fallback to subtype produced the self-contradictory "Claude Code returned an error result: success" seen in production on 2026-08-30. RateLimitEvent and RateLimitInfo field names are unchanged across the range (status, resets_at, rate_limit_type, utilization, overage_*), and both are top-level exports. cryptography floor raised to >=50.0.0 for GHSA-g6cj-pr64-35w5 (Dependabot #29, high), resolving to 50.0.1. 679 passed, 31 skipped; black clean.
On 2026-08-30 the wrapper returned HTTP 401 authentication_error to every caller for 13h26m (08:35:45 to 22:02:12 UTC) while CLI auth was healthy. The cause was a Claude subscription usage limit. A downstream consumer treated the 401 as non-retryable, opened its own breaker, and dropped work instead of deferring it. Chain: the CLI emitted a result frame with is_error=true, empty errors[] and subtype "success", then exited non-zero. verify_cli() caught the exception and returned a bare False. probe_cli_auth marked cli_health failed with kind=unknown - correctly identifying it as not an auth problem. But _check_cli_auth_or_401 gated on cli_health.ok alone and ignored error_kind, so every request got a 401 telling clients their credentials were bad. - _check_cli_auth_or_401 now gates only on error_kind == "auth_failure". Every other failure kind falls through to the SDK, which reports an accurate status. - verify_cli propagates SDK exceptions instead of collapsing them to False, so the caller has something to classify. It also drains the stream rather than breaking at the first assistant turn: that break tested getattr(message, "type"), which no SDK dataclass has, so it never fired, and draining is required to observe the terminating result frame. - New _classify_probe_exception prefers ResultError.api_error_status (429 -> quota_exhausted, 401/403 -> auth_failure) over string matching, falling back to text. Quota markers are checked before auth markers. 687 passed, 31 skipped; black clean.
The Agent SDK emits RateLimitEvent whenever the CLI's rate-limit state
changes, carrying status, utilization, resets_at and rate_limit_type for the
binding window plus the overage pool. That is the same data
claude-quota-proxy scrapes from anthropic-ratelimit-unified-* headers,
delivered in-band, so no proxy is needed. The wrapper was discarding it.
- New src/quota_tracker.py: per-window state, threading.Lock, snapshot() for
the endpoint, blocked_until() for enforcement. Windows are kept
independently because the CLI reports whichever one is binding, so a
five_hour rejection must not clear a known seven_day utilization. Overage
suppresses blocking only when observed no earlier than the rejection and
not itself rejected.
- Capture in run_completion before the reflection-based dict conversion, so
the typed dataclass is read rather than a flattened copy. That single point
covers streaming, non-streaming, /v1/messages and /healthz/deep, which all
reach the SDK through this loop. verify_cli records too, tagged source=probe.
- parse_claude_message's rate-limit branch matched a flat
{status, resets_at, rate_limit_type} dict, but the event nests those under
rate_limit_info, so it has been unreachable. Rewritten against the real
shape; ClaudeResultError now carries resets_at and rate_limit_type so the
HTTP layer can emit a real Retry-After.
State is per-process; with UVICORN_WORKERS > 1 each worker learns from its
own traffic. Documented in the module, same caveat as the circuit breaker.
687 passed, 31 skipped; black clean.
/v1/usage reports the last quota state the CLI told us about, per window:
status, utilization, resets_at as unix and ISO, seconds_until_reset, the
source of the reading (passive or probe), and whether it has gone stale.
Windows appear only once reported, so an idle wrapper returns an empty set
rather than a guess.
Follows the */stats convention: bare dict, verify_api_key in the body,
@rate_limit_endpoint("general"), no response_model. /v1/auth/status gains a
"quota" key beside "cli_health", matching how that endpoint nests one key
per subsystem.
687 passed, 31 skipped; black clean.
Retry-After was hardcoded to "30" with a comment saying to use the upstream reset "once the SDK exposes them". It does, and parse_claude_message now carries resets_at through, so the header reflects the real window. Capped at 3600s: a rejected seven_day window can reset days out, and a client honouring the header verbatim would sleep through it. The true reset time goes in the body as resets_at, resets_at_iso and seconds_until_reset. With no reset reported the old conservative 30s default stands. - /v1/messages raised HTTPException(502) for every subtype except error_max_turns, so a rate limit reached the Anthropic SDK as a broken upstream rather than a busy one. It now returns 429 with an Anthropic-shaped rate_limit_error. JSONResponse rather than HTTPException, because the global handler rewrites detail bodies to error.type=api_error. - The streaming SSE error frame was a generic upstream_sdk_error regardless of subtype. The status line is already flushed by then, so the body is the only channel left for the reset time; rate limits now carry it. - retry_delay never received the retry_after argument calculate_delay already accepted, so a known reset had nowhere to go. Wired through and bounded at 60s, since this runs inside a live request. Retry status detection also falls back to ResultError.api_error_status. 687 passed, 31 skipped; black clean.
_check_quota_or_429 runs after the auth gate on /v1/chat/completions and /v1/messages. Off by default via WRAPPER_QUOTA_ENFORCEMENT_ENABLED: refusing requests is a behaviour change for existing callers, and the accurate Retry-After on real upstream rejections ships either way. When on, a doomed round-trip is skipped and the caller gets 429 with the reset time. The gate counts every request for probe cadence whether or not enforcement is on, so /v1/usage stays fresh regardless. Probe cadence is request-count driven with a time floor (WRAPPER_QUOTA_PROBE_EVERY_N_REQUESTS=100, WRAPPER_QUOTA_PROBE_MIN_INTERVAL_SECONDS=300, 0 disables). The bundled CLI has no usage subcommand, so a probe is a real inference call that spends the quota it measures; a plain interval would burn quota while idle and still lag under load. The loop reuses probe_cli_auth, whose verify_cli call records the rate-limit event, so one probe refreshes both auth and quota state. It complements rather than replaces the fixed-cadence auth probe, which already keeps quota fresh when traffic is light. 687 passed, 31 skipped; black clean.
New tests/test_quota_tracker_unit.py (29 cases): recording from both the SDK dataclass and an equivalent dict, per-window independence, overage handling including the stale-overage case, blocking and expiry, the rejection with no reported reset, snapshot shape and staleness, probe cadence, and env config with defaults / overrides / invalid values. Also covered: - parse_claude_message against the nested rate_limit_info shape, the branch that has been unreachable until now, asserting resets_at and rate_limit_type reach ClaudeResultError. - Retry-After derivation: reported reset, the 3600s cap on a week-long window, and the 30s fallback when no reset is reported. - /v1/usage populated and idle. - _check_quota_or_429 off by default, blocking with reset detail when on, passing on a healthy quota, and counting requests regardless. - /v1/messages returning 429 with reset detail instead of 502. 730 passed, 31 skipped; black clean.
pip vendors its own copies of msgpack and setuptools, which have tripped the trivy HIGH/CRITICAL gate on every build since 2.9.13 even though nothing imports them. The container never installs packages at runtime, so pip is removed from the app virtualenv, system site-packages, Poetry's own installer venv, and the virtualenv wheel cache. Runs last, because poetry needs pip to install, and asserts the app still imports afterwards. Trivy on the resulting image: 28 HIGH/CRITICAL, all Debian 13 base-image CVEs with no fix available, and zero language-package findings. Previously 31, of which 2 were the pip-vendored pair. Smoke tested: container starts, /health healthy, /version and /v1/usage respond.
Without it the mount fails with permission denied on SELinux-enforcing hosts (Fedora, RHEL) and under rootless Podman. The label is ignored where SELinux is not enforcing, so plain Docker is unaffected. Ported from RichardAtCT#49 by safrano9999. Applied by hand rather than cherry-picked: this fork's docker-compose.yml has diverged and the pick conflicts on the surrounding comment.
Subscription users who authenticate with `claude auth` had no way to reach the containerised CLI without also setting an API key. The token is read by the bundled CLI, not the wrapper, so passing it through is the whole change: _detect_auth_method falls through to claude_cli when no ANTHROPIC_API_KEY or Bedrock/Vertex flag is set, which is the correct path for OAuth. Defaulted to empty so the variable stays unset when the host does not define it, rather than being injected as an empty override. Ported from RichardAtCT#50 by safrano9999. Applied by hand rather than cherry-picked: this fork's docker-compose.yml and .env.example have diverged and the pick conflicts.
Version bumped in src/__init__.py and pyproject.toml. CHANGELOG entry covers the 401 misclassification fix and its production impact, the /v1/usage endpoint, the Retry-After and 429 routing corrections, the SDK and cryptography bumps, the pip strip, and the two upstream ports. README: new env-var rows for the WRAPPER_QUOTA_* settings, /v1/usage in the endpoint table, and a correction to the CLI_AUTH_PROBE_INTERVAL_SECONDS row, which still claimed any probe failure returns 401. The version block was also stale at 2.9.12, having been missed by both 2.9.13 and 2.9.14; test counts updated from 673 to 730. .env.example documents the four quota settings, commented out with defaults shown, per existing convention. 730 passed, 31 skipped; black clean.
This was referenced Aug 30, 2026
An audit of README against src/constants.py found the model tables had been left behind by two releases. claude-fable-5, claude-opus-5 and claude-sonnet-5 were added in 2.9.11 and 2.9.12 but never documented, and claude-sonnet-4-6 was still labelled the default even though DEFAULT_MODEL_FALLBACK moved to claude-sonnet-5. Deprecated-model replacements pointed at superseded targets, and the Docker pin example still read 2.9.6. Adds a Quota and Rate Limits section covering GET /v1/usage: the response shape, window keys, what source and stale mean, and the 429 contract with its capped Retry-After. The sample response was checked field by field against a live snapshot rather than written by hand. Endpoint and env-var tables were also diffed against every route in src/main.py and every os.getenv in src/; both were already complete.
Trivy reported openssl CVE-2026-14456 (HIGH) as affecting the image, and unlike the rest of the Debian findings it had a patched version available: 3.5.7-1~deb13u2 against the 3.5.6-1~deb13u2 the published python:3.12-slim tag carries. apt-get upgrade picks up anything Debian has patched since the base tag was cut. Trivy on the result: 25 HIGH/CRITICAL, down from 28, none with a fix available and none in language packages. Remaining findings are curl, perl-base, ncurses, sqlite3, gzip, libacl1 and libssh2 with no Debian fix published.
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.
Why
On 2026-08-30 the production wrapper returned HTTP 401
authentication_errorto every caller for 13h26m (08:35:45 to 22:02:12 UTC). CLI authentication was healthy the entire time. The real cause was a Claude subscription usage limit.MinusPod, downstream, treated the 401 as non-retryable, opened its own circuit breaker, and skipped transcript windows outright rather than deferring them. Picking 401 instead of 429 turned a recoverable delay into silent data loss.
The classifier said
kind=unknown— explicitly not an auth failure. The gate keyed oncli_health.okalone and threw that away.The fix
_check_cli_auth_or_401now gates only onerror_kind == "auth_failure". Every other failure kind falls through to the SDK, which reports an accurate status. The "Runclaude /login" remediation text stays where it is true.Supporting changes:
verify_cli()propagates SDK exceptions instead of collapsing them to a bareFalse, so callers have something to classify, and drains the stream rather than breaking at the first assistant turn (that break testedgetattr(message, "type"), which no SDK dataclass has, so it never fired). Classification prefersResultError.api_error_statusover string matching.What else is in here
Quota visibility. The SDK already emits
RateLimitEventwith status, utilization,resets_atandrate_limit_typeforfive_hour/seven_day/seven_day_opus/seven_day_sonnet/overage. That is the same data claude-quota-proxy scrapes fromanthropic-ratelimit-unified-*headers, delivered in-band, so no proxy is needed. The wrapper was discarding it.New
GET /v1/usage:{ "blocked": false, "windows": { "five_hour": { "status": "allowed_warning", "utilization": 0.93, "resets_at": 1788135731, "resets_at_iso": "2026-08-31T00:22:11+00:00", "seconds_until_reset": 1799, "source": "passive", "stale": false } }, "observed_windows": 1 }Captured at a single point in
run_completion, before the reflection-based dict conversion, which covers streaming, non-streaming,/v1/messagesand/healthz/deepat once.Rate-limit semantics.
Retry-Afternow comes from the upstream reset instead of a hardcoded"30"— the comment there already said to do this "once the SDK exposes them", and it does. Capped at 3600s so aseven_daywindow cannot tell a client to sleep for days; the true reset is in the body./v1/messagesstops collapsing 429 to 502. The streaming SSE error frame carries the reset, since the status line is already flushed by then.retry_delayfinally receives theretry_afterargumentcalculate_delayhas always accepted.Enforcement is opt-in (
WRAPPER_QUOTA_ENFORCEMENT_ENABLED, defaultfalse) — refusing requests is a behaviour change, while the accurateRetry-Afteris a correction and ships unconditionally.Probes are request-count driven (
WRAPPER_QUOTA_PROBE_EVERY_N_REQUESTS=100) with a time floor (..._MIN_INTERVAL_SECONDS=300),0disables. The bundled CLI has nousagesubcommand, so a probe is a real inference call that spends the quota it measures; a plain interval would burn quota while idle and still lag under load.Dead code removed.
parse_claude_message's rate-limit branch matched a flat{status, resets_at, rate_limit_type}dict, but the event nests those underrate_limit_info. It has been unreachable. Rewritten against the real shape, with a test.Dependencies and image
claude-agent-sdk0.2.128 -> 0.2.148. Supersedes bot PRs chore(deps): bump claude-agent-sdk 0.2.128 -> 0.2.134 #38, chore(deps): bump claude-agent-sdk 0.2.128 -> 0.2.139 #39 and chore(deps): bump claude-agent-sdk 0.2.128 -> 0.2.144 #40, which are all stale. Two upstream fixes matter here:ResultErrorcarries structured failure fields, and_error_result_textfixes the self-contradictory"returned an error result: success"string this outage produced.RateLimitInfofield names verified unchanged across the range.cryptographyfloor>=50.0.0, closing Dependabot chore(deps): bump claude-agent-sdk 0.2.93 -> 0.2.106 #29 (GHSA-g6cj-pr64-35w5, high).msgpackandsetuptools, which have tripped the trivy HIGH/CRITICAL gate on every build since 2.9.13. Trivy on this image: 25 HIGH/CRITICAL, none with a fix available, zero language-package findings (2.9.14 reported 31 including the pip pair). The base image is also upgraded during build, which cleared openssl CVE-2026-14456 (patched in Debian as 3.5.7-1~deb13u2 but not yet in the publishedpython:3.12-slimtag).Ported from upstream
CLAUDE_CODE_OAUTH_TOKENpassthrough — feat: add CLAUDE_CODE_OAUTH_TOKEN support for subscription auth RichardAtCT/claude-code-openai-wrapper#50 by safrano9999:Zvolume label — 🦭 fix: add SELinux :Z label to .claude volume mount (Docker & Podman) RichardAtCT/claude-code-openai-wrapper#49 by safrano9999Applied by hand rather than cherry-picked; this fork's
docker-compose.ymlhas diverged and both picks conflict.Verification
Retry-Afterderivation and its cap,/v1/usage, the enforcement gate's default-off behaviour, and/v1/messagesreturning 429linux/amd64, trivy scanned, container smoke tested:/health,/version,/v1/usageall respondclaude-fable-5,claude-opus-5,claude-sonnet-5missing;claude-sonnet-4-6still marked default despiteDEFAULT_MODEL_FALLBACKmoving toclaude-sonnet-5). Endpoint and env-var tables were diffed against every route and everyos.getenvand were already complete. The/v1/usagesample response was verified field by field against a live snapshot.Reviewer notes
UVICORN_WORKERS=2each worker learns only from the traffic it serves. Documented in the module; same caveat the circuit breaker already carries.options.envinstead ofos.environmutation,asyncio.Lockinsession_manager) is a real remaining limitation but is deliberately out of scope here — 40 files and an unwanted Gemini proxy.