fix(stats): stop rejected upstream turns from feeding the savings funnel - #3010
fix(stats): stop rejected upstream turns from feeding the savings funnel#3010nangsontay wants to merge 2 commits into
Conversation
`emit_request_outcome` short-circuited only on `status_code >= 500`, with the
rationale that "4xx stay on the normal funnel: they are client errors the proxy
still served". For accounting that is the wrong cut. A 4xx is an error the PROXY
served but the PROVIDER did not: nothing was generated, nothing was billed, so
compression on that turn saved nothing.
Measured on a real Claude Code session (300 turns, 143 of them 429 "Usage
credits are required for fast mode"):
* 46.5% of the headline `total_saved` (2,659,531 of 5,718,095 tokens) was
compression on turns Anthropic rejected
* `tokens.input` accumulated 60.1M tokens that were never sent to a model
* `cost.savings_usd` read $8.90 against a measured spend of $2.26, i.e. a
79.7% "savings" rate derived from turns that produced nothing
* `requests.rate_limited` read 0 throughout, because only Headroom's own
limiter (`handlers/anthropic.py`) ever incremented it — an upstream 429
never did
Extend the guard to `>= 400`. 429 books `record_rate_limited` rather than
`record_failed`: an upstream rate limit is the one 4xx a user is expected to act
on, and folding it into a generic failure count hides exactly that. Both
counters are already exported and neither feeds savings.
Only outcome sites that pass a real `status_code` change behaviour; sites that
leave the field at its 200 default are untouched.
Also adds coverage for the Anthropic buffered-CCR path (`stream:true` +
`headroom_retrieve` -> buffered `stream:false` upstream), which is the dominant
Claude Code route and had no test asserting its provider usage reaches the
outcome. It does; these tests pin it so a regression there cannot silently
empty `metrics.cache_by_provider`.
PR governanceThis PR follows the template and is marked ready for human review. |
The merge of headroomlabs-ai#3010 moved the guard to >=400 but left three dev-only comments describing a >=500 cut: the RequestOutcome.status_code docs and the two CCR side-channel notes explaining why a short-circuit must not book a proactive retrieval or continuation usage. The reasoning is unchanged and now applies to 4xx too -- a 429 bills nothing, so it must not book a retrieval drawback either. Comments only; no executable line changes.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
JerrettDavis
left a comment
There was a problem hiding this comment.
Reviewed the latest head after updating it to current main. The early rejection boundary is correctly placed after operational outcome telemetry but before every success-only savings/cost/request-log side effect. Provider 429s are separated into the existing rate-limit counter; other 4xx/5xx remain failed, and Headroom’s own pre-upstream limiter still exits before this funnel, so this does not double-count its 429s. I also traced the buffered-CCR usage path to confirm the added coverage is relevant rather than incidental.
The exact-head focused suite passes locally (13/13). No blocking correctness findings.
Description
emit_request_outcomeshort-circuits failed turns before the savings/cost/log funnel, but only atstatus_code >= 500. The comment on that guard justified the cut as "4xx stay on the normal funnel: they are client errors the proxy still served."For accounting that is the wrong cut. A 4xx is an error the proxy served but the provider did not: nothing was generated, nothing was billed, so compression on that turn saved nothing. Counting it lets rejected turns inflate every savings surface.
This was found on a live Claude Code session against Anthropic where fast mode was out of credits, so 143 of 300 turns came back
429 "Usage credits are required for fast mode". All 143 ran the full success funnel.Type of Change
Changes Made
headroom/proxy/outcome.py: move the funnel short-circuit fromstatus_code >= 500to>= 400, so a turn the provider never billed cannot reachmetrics.record_request,cost_tracker.record_tokens, theRequestLogfeed, or the PERF line.headroom/proxy/outcome.py: route 429 torecord_rate_limitedinstead ofrecord_failed. An upstream rate limit is the one 4xx a user is expected to act on (back off, raise a cap); folding it into a generic failure count hides exactly that. Both counters are already exported and neither feeds savings.tests/test_outcome_records_5xx_as_failed.py: cover 429 (rate-limited), a parametrized sweep of other 4xx (400/401/403/404/413/422, all failed), and a 200 control asserting a served turn still reaches the funnel with itstokens_savedintact.tests/test_anthropic_buffered_ccr_usage.py(new): cover the Anthropic buffered-CCR route (stream:true+headroom_retrieverewritten to a bufferedstream:falseupstream call). This is the dominant Claude Code path and had no test asserting its provider usage reaches the outcome. It does; these pin it so a regression cannot silently emptymetrics.cache_by_provider.Scope note: only outcome sites that pass a real
status_codechange behaviour. Sites that leave the field at its200default are untouched, including the response-cache-hit site inhandlers/anthropic.py, which is correctly a served turn.Testing
pytest)ruff check .)mypy headroom)Test Output
RED proof — the new assertions fail against the pre-fix guard. Reverting only
headroom/proxy/outcome.pyto>= 500:The 3 that pass either way are the pre-existing 529/503 cases and the 200 control, so the existing contract is unchanged.
Real Behavior Proof
headroom proxy --port 8787 --mode token --backend anthropic, client Claude Code againstclaude-opus-5, Anthropic fast mode out of credits.curl -s http://127.0.0.1:8787/statsand cross-checked against~/.headroom/logs/proxy.logPERF lines; correlated request ids withgrep 'UPSTREAM_ERROR status=429'and summedtotal_savedper id with awk.tokens.inputaccumulated 60.1M tokens never sent to a model;cost.savings_usdread $8.90 against a measured spend of $2.26 (a 79.7% "savings" rate);requests.rate_limitedread 0 throughout because only Headroom's own limiter ever incremented it.proxy_inbound.by_statusindependently confirmed 145 x 429 onPOST /v1/messages. With this patch those turns short-circuit: they land inrequests_rate_limitedand contribute nothing to savings, cost, or the request log.pytestsuite was not run end to end (unrelated pre-existing hangs incontent_routernative-detector and a serena subprocess block it on this machine); the 158 test files above were run instead. No re-run of the live 429 session against a patched proxy build — the production evidence is from the pre-fix build, and the post-fix behaviour is pinned by unit tests rather than re-observed live. Non-Anthropic providers were exercised only through their existing test suites, not live.Runtime Rollout Safety
requests_total/tokens_saved_total/cost entries, and 429 now incrementsrequests_rate_limited. Dashboards will show lower request and savings totals on deployments with upstream 4xx traffic. That is the correction, but it is a visible metric discontinuity worth calling out in release notes.500.git revertthis commit; the guard returns to>= 500and no persisted state needs migrating (the change only affects in-memory counters and forward-going log rows).Review Readiness
Checklist
CHANGELOG.md— it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)Screenshots (if applicable)
N/A — no UI change. The dashboard renders the same fields; only the values stop counting rejected turns.
Additional Notes
Documentation checklist item is N/A: no documented behaviour or configuration surface changes, only the correctness of counters already described in
docs/.The comment the guard carried was a deliberate decision, not an oversight, so this PR is arguing the cut should move rather than that a line was forgotten. If maintainers prefer to keep 4xx visible in the per-request log feed while still excluding them from savings and cost, that is a reasonable alternative shape — it costs a partial-funnel branch instead of an early return, and I am happy to rework it that way.
Two related reporting problems found in the same investigation are deliberately not in this PR, to keep it to one logical change:
SavingsRecorderpersists to~/.headroom/output_savings.jsonandestimate_from_baselinesums the whole file, so/statsreportedtokens.output_saved = 30,338,830overn_requests = 40,575next to a sessiontokens.outputof 951. Scoping the estimator per-process would break it (it needs history to have a baseline stratum at all), so the fix is a labelling/rendering decision I did not want to make unilaterally.SavingsTrackerare internally impossible on a long-lived install:cache_read_tokens4,944,608,741 exceedstotal_input_tokens3,978,346,063, andcache_savings_usd$20,544 exceedstotal_input_cost_usd$5,273. The per-token cache-savings estimate deliberately diverges fromproxy/cost.py's_CACHE_ECONOMICSmultipliers (documented in_estimate_cache_savings_usd), which also yields three different cache-savings figures for one session.Happy to open issues for either if useful.