fix(session-state): circuit-break imaging after repeated 429s - #237
fix(session-state): circuit-break imaging after repeated 429s#237parziva-1 wants to merge 1 commit into
Conversation
A session retrying an unchanged imaged request that gets 429 on every attempt (teamchong#234: same image_count/image_bytes each time) never makes progress by retrying the same way: a 429 never populates a fresh prefix cache, so the retry re-sends the same doomed imaged bytes and fails the same way again. Track consecutive 429s per session (noteRateLimitOutcome, alongside the existing noteCacheOutcome bookkeeping) and skip imaging once a session hits 3 in a row (isRateLimitCircuitOpen), falling back to plain text. Any non-429 response clears the counter. This does not fix whatever is rate-limiting the account — it stops a session from re-imaging the same bytes into the same wall, so the request has a chance to complete, which is also the only way that session's own prefix cache gets a chance to come back.
|
Thanks for the detailed writeup. Concern with the mechanism: Text fallback costs more. For the #234 payload, 5 images ≈ 8k tokens; the same 112k chars as text ≈ 28k tokens, all uncached on the first send. Any non-429 resets the counter, so it flaps: Every turn pays 3 failed retries plus a full uncached text write, and the alternation busts both prefixes, so once the limit clears the imaged cache has to be rebuilt from zero. Likely root cause is upstream of this. I diagnosed the same symptom before: slab drift (any 1-char change in system/tools) re-renders every page, the next request needs a full cache write, the account's window can't cover it, and 429s write no cache, so every retry bills the same. #234's own log line hints at it: |
|
Thanks for the follow-up — the text-fallback-cost concern is valid and matches what we're seeing locally. Sharing our full local dataset (2026-08-16 → 2026-08-18, pxpipe-proxy 0.13.1) since it now covers three full days under real Claude Code + GSD-skill traffic, including the flapping pattern you described. Setup
What we tried (chronological)
Evidence (journal,
|
| Metric | Count |
|---|---|
429 responses |
484 |
200 responses |
1034 |
rate_limit_circuit_open activations (breaker fired) |
183 |
Representative lines (paths/usernames redacted):
[...17:53:09Z] POST /v1/messages → 429 (9371ms tx=24ms up=9347ms fb=9370ms) compressed 36108ch → 2img/187964B
[...17:53:49Z] POST /v1/messages → 200 (46783ms tx=551ms up=46232ms fb=12669ms) compressed 112099ch → 42img/10188853B (tr+1) tokens=2+2019 cache_read=99205
[...17:53:59Z] POST /v1/messages → 200 (47447ms tx=2ms up=47445ms fb=47445ms) skip(compress=false (rate_limit_circuit_open)) tokens=90+9 cache_read=72786
183 breaker activations against 484 total 429s (~38%) is a lot of flapping in 2 days — consistent with the img-429/text-200/img-429 cycle you described, not isolated incidents. We didn't instrument enough to separately count tokens burned per fallback, but given the ratio you quoted for #234 (5 images ≈ 8k tokens vs 112k chars as text ≈ 28k tokens uncached), 183 flips is a real cost, not a rounding error.
What did NOT help
- Adding unrecognized GSD tags to
KNOWN_STATIC_TAGS— silenced the warning entirely but did not move the 429 rate. It's a correctness/noise fix for churn tracking, not a mitigation.
What helped
- The circuit breaker (fix(session-state): circuit-break imaging after repeated 429s #237) — confirmed live: after 3 consecutive 429s on a compressed slab it flips to
compress=false (rate_limit_circuit_open), next call succeeds. Without it, every 429 loop stalled the session at 35–50s first-byte. It's the only thing keeping the proxy usable today, even with the cost concern you raised.
Open questions for the maintainer
- Given the counter-reset flapping you described, should the breaker use a decaying/backoff counter (e.g. don't fully reset on one 200) instead of a hard reset-on-success, to avoid the img-429/text-200/img-429 cycle?
- Should the breaker shrink the slab (drop older/larger static content) instead of just dropping compression outright, to avoid paying the full uncached-text cost every time it trips?
- Should we split this into two PRs — (a) tag classification, mechanical/safe, fixes warning noise + churn signal accuracy, no behavior change; (b) the breaker itself, so threshold/backoff/shrink-vs-drop can be reviewed and tuned independently of (a)?
- Long-term default policy for unrecognized top-level tags: invert the current allowlist (treat unknown-as-static-by-default with churn-based promotion to dynamic) vs. keeping an explicit allowlist that has to be updated per prompt-generator (GSD, cc_automode, etc.)?
- Would hashing per-block instead of per-page avoid the "any 1-char change re-renders every page" drift you flagged in many 429 errors #234, without the false-cache-hit risk of coarser granularity?
- Is there a way to distinguish an account-level rate-limit 429 from a cache-invalidation-triggered 429 from the response alone (headers, error body)? That would let the breaker/backoff behave differently for each case instead of treating all 429s the same.
- Should the docs/CONTRIBUTING call out that
dist/node.jsis the real runtime bundle (notdist/core/transform.js) so local patches land in the right file? - Should
pxpipe warpauto-start the main daemon, or should the docs explicitly require a systemd (or equivalent) unit to keep it alive?
Proposal
Split as noted in (3) above: land the tag-classification fix (mechanical, no behavior change) independently, and treat the breaker's tuning (backoff shape, shrink-vs-drop, distinguishing 429 causes) as its own follow-up now that we have three days of real flapping data showing it's not a corner case. Happy to prototype the decaying-counter or per-block-hash approach if you'd rather review a concrete diff than discuss in the abstract.
Observed in production on #234: a session got 429 on 15/15 consecutive requests,
each carrying the exact same
image_count/image_bytesas the last (a clientretry of unchanged content). Imaging never got that session past the rate
limit — a 429 never populates a fresh prefix cache, so every retry re-sends
the same imaged bytes and fails the same way. Meanwhile 5 other concurrent
sessions on the same account, with different content, succeeded throughout —
so this wasn't a blanket outage, it was one session stuck resending a payload
that could not get through.
This PR does not fix whatever is rate-limiting the account (I could not
determine that from the proxy side — it's consistent with the account's
shared per-model ITPM budget documented at
platform.claude.com/docs/api/rate-limits,
where only uncached input tokens count, and imaging invalidates the
session's own cached prefix on the first request after any compression
config change). What it fixes: a session stuck behind a limit stops
re-imaging the same doomed bytes once it has failed 3 times in a row, and
falls back to plain text — smaller and more likely to get through, and the
only way that session's own prefix cache gets a chance to come back at all.
Mechanism:
noteRateLimitOutcome(sessionKey, status)tracks consecutive 429sper session, alongside the existing
noteCacheOutcomebookkeeping in the samemodule.
isRateLimitCircuitOpen(sessionKey)reports true at 3 in a row; anynon-429 response clears the counter.
transformRequestchecks it right aftercomputing
firstUserSha(the same session keynoteHistoryRequest/noteCacheOutcomealready use) and falls back to{ body, info }unchanged,same shape as the existing
!o.compressearly-return just above it.Verify
I don't have a way to force a real account-level 429 on demand to validate the
full request→429→429→429→fallback→200 loop end-to-end against the live API
without deliberately exhausting a real rate limit, so this is validated at the
level
pnpm testcan check: the session-state bookkeeping (unit) andtransformRequest's actual compression pipeline falling back to text once thethreshold trips (integration, no mocks on the render path). Flagging that gap
rather than claiming more than the tests show.
mainpnpm testandpnpm typecheckpass (the 2 pre-existingimage-byte-budget.test.tsfailures are unrelated to this change and reproduce on a clean
maincheckout — see Verify)