Skip to content

v2.10.0: quota visibility, correct rate-limit semantics, and the 401 misclassification fix - #41

Merged
ttlequals0 merged 13 commits into
mainfrom
feat/quota-tracking-and-429-semantics
Aug 30, 2026
Merged

v2.10.0: quota visibility, correct rate-limit semantics, and the 401 misclassification fix#41
ttlequals0 merged 13 commits into
mainfrom
feat/quota-tracking-and-429-semantics

Conversation

@ttlequals0

@ttlequals0 ttlequals0 commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Why

On 2026-08-30 the production wrapper returned HTTP 401 authentication_error to 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.

src.claude_cli - ERROR - Claude Agent SDK verification failed: Claude Code returned an error result: success
src.auth - WARNING - cli_auth_probe_failed kind=unknown reason=verify_cli_returned_false

The classifier said kind=unknown — explicitly not an auth failure. The gate keyed on cli_health.ok alone and threw that away.

The fix

_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. The "Run claude /login" remediation text stays where it is true.

Supporting changes: verify_cli() propagates SDK exceptions instead of collapsing them to a bare False, so callers have something to classify, and 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). Classification prefers ResultError.api_error_status over string matching.

What else is in here

Quota visibility. The SDK already emits RateLimitEvent with status, utilization, resets_at and rate_limit_type for five_hour / seven_day / seven_day_opus / seven_day_sonnet / overage. 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 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/messages and /healthz/deep at once.

Rate-limit semantics. Retry-After now 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 a seven_day window cannot tell a client to sleep for days; the true reset is in the body. /v1/messages stops collapsing 429 to 502. The streaming SSE error frame carries the reset, since the status line is already flushed by then. retry_delay finally receives the retry_after argument calculate_delay has always accepted.

Enforcement is opt-in (WRAPPER_QUOTA_ENFORCEMENT_ENABLED, default false) — refusing requests is a behaviour change, while the accurate Retry-After is 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), 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.

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 under rate_limit_info. It has been unreachable. Rewritten against the real shape, with a test.

Dependencies and image

Ported from upstream

Applied by hand rather than cherry-picked; this fork's docker-compose.yml has diverged and both picks conflict.

Verification

  • 730 passed, 31 skipped (up from 687), black clean
  • 29 new quota-tracker unit tests, plus coverage for the nested rate-limit shape, Retry-After derivation and its cap, /v1/usage, the enforcement gate's default-off behaviour, and /v1/messages returning 429
  • Regression test asserting a probe failure with the exact outage string does not produce a 401
  • Docker image built for linux/amd64, trivy scanned, container smoke tested: /health, /version, /v1/usage all respond
  • README audited against the code, not just appended to: the model tables had been left behind by two releases (claude-fable-5, claude-opus-5, claude-sonnet-5 missing; claude-sonnet-4-6 still marked default despite DEFAULT_MODEL_FALLBACK moving to claude-sonnet-5). Endpoint and env-var tables were diffed against every route and every os.getenv and were already complete. The /v1/usage sample response was verified field by field against a live snapshot.

Reviewer notes

  • Quota state is per-process. With UVICORN_WORKERS=2 each worker learns only from the traffic it serves. Documented in the module; same caveat the circuit breaker already carries.
  • Upstream v2.10.1: report every rate-limit window, not just the one the CLI names #42's concurrency work (options.env instead of os.environ mutation, asyncio.Lock in session_manager) is a real remaining limitation but is deliberately out of scope here — 40 files and an unwanted Gemini proxy.

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.
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.
@ttlequals0
ttlequals0 merged commit ca41f61 into main Aug 30, 2026
6 checks passed
@ttlequals0
ttlequals0 deleted the feat/quota-tracking-and-429-semantics branch August 30, 2026 23:41
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