Hold a 402, report a discarded api_url, and sweep gateway drift - #21
Open
anassg-lago wants to merge 1 commit into
Open
Hold a 402, report a discarded api_url, and sweep gateway drift#21anassg-lago wants to merge 1 commit into
anassg-lago wants to merge 1 commit into
Conversation
A 402 from anything in front of Lago silently discarded every billable event for the whole outage. It was classified permanent, which routes a batch to _send_individually; every isolated send then 402s too and is logged and dropped for good. Measured against a server returning 402: 5 events in, 6 HTTP calls out, 0 recoverable, with a single on_error for the lot. The test for the permanent set is "is what makes this fail a property of the BATCH?". True for 413 (size) and 415 (media type), which are constant across retries. False for 402: payment required is a property of the ACCOUNT and stops being true the moment someone pays — the same shape as the 429 that was deliberately carved out, recoverable by an out-of-band change rather than by sending something different. 402 is now transient, so a lapsed account holds and retries: bounded, oldest-first, reported, and recoverable inside the buffer window. Probing a real Lago also showed none of 402/413/415 is reachable from Lago itself — its surface is 400/401/403/404/405/422/429. An oversized batch answers 422 too_many_events, which already routes to the split path, and a duplicate transaction_id answers 422 value_already_exist, not the 409 the comment claimed as its exemplar. 413/415 stay for an intermediary, since nginx's client_max_body_size really does answer 413 before the request reaches Rails, and the comments now say which reader they are for. An explicitly-passed falsy api_url silently resolved to PRODUCTION. Preferring the config value over "" is right, because requests raises MissingSchema, which is not a LagoApiError, so the queue retried forever and all billing stopped. But LagoConfig's default is the production URL, so api_url=os.environ.get( "LAGO_API_URL", "") with the var unset resolved to production with no on_error and no log. For a CI job or a developer holding a real production key that writes live billing data, and ingested events cannot be un-ingested. The fallback is unchanged; it is now reported under config.api_url through the same log-plus-callback floor as every other drop path. usage_metadata from the Cloudflare gateway got no drift sweep. extras was a fixed three-key dict, so any counter the adapter does not map vanished with no error and no on_error — the one place violating the contract test_drift.py enforces for the native adapters. Not hypothetical: a live Logs API pull found neurons (Cloudflare's Workers AI billing unit) and units (a cost quantity) dropped on every row, and units appears in none of the 14 captured fixtures, so the hand-maintained enumeration in the module docstring had already drifted past reality. Unmapped keys now sweep into extras["usage_metadata"] against an explicit _MAPPED_USAGE_KEYS set, so a ninth spelling surfaces on its own instead of needing another 14-fixture audit. Nested rather than merged flat, because the poller reads extras["cached"] to decide whether to skip billing a request Cloudflare served for free, and a future usage_metadata key called cached or step must not be able to shadow it. prime() re-downloaded the ~400-model OpenRouter catalogue regardless of the TTL. It set _openrouter_stale unconditionally, and it is called from _auto_prime_pricing_for on a matching wrap() and from warm_pricing(), both of which a server can run per request, so pricing_ttl_seconds never applied on that path at all. Measured against the live catalogue with the shipped 1-hour TTL: 4 prime()+maybe_refresh() cycles produced 4 full downloads where 1 was correct; now 1. Gated on the same "no table, or past the TTL" test lookup() already uses, so priming and looking up cannot disagree. A failed pricing fetch retried every tick, forever, with no backoff, ahead of the drain. Only the success path cleared a source's stale flag, so a bad credential re-attempted on every tick, each attempt costing up to the 10s _get_json timeout. Measured with a bad Cloudflare token: 5 ticks produced 5 real requests and 5 on_error reports; now 1, and it still recovers once the window expires. Per-source 1-2-4-...-60s backoff, matching the queue's own send backoff, so one bad credential cannot delay the three healthy tables. The four fetches deliberately stay sequential here: this refresh runs on a blocking daemon thread, where a thread pool for four fetches would be a larger change than the problem warrants. JS additionally parallelises them under Promise.allSettled, which its event loop makes free — a language-inherent divergence, like os.register_at_fork against AsyncLocalStorage. Every fix has a test that fails when the fix is reverted. Green at this commit: ruff, ruff format, mypy strict, 552 unit tests. Each finding was also confirmed against live infrastructure before and after the change — a real local Lago, the real OpenRouter catalogue, the real Cloudflare model and Logs APIs.
This was referenced Aug 20, 2026
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.
Follow-up to @ancorcruz's review on the JS side (#33, #34). His findings were against the JS port, but four of the five are in this repo too and its stack is already merged, so they need their own PR here rather than an edit to an open branch. Every finding was verified live against real infrastructure before writing any code, and re-verified after.
The JS half is on
fix/gateway-and-gemini-attributionand will follow.The one that loses money
A
402from anything in front of Lago silently discarded every billable event for the whole outage.402was in_PERMANENT_STATUSES, which routes a batch to_send_individually; every isolated send then402s too and is logged and dropped for good.Measured against a server returning 402 — 5 events in, 6 HTTP calls out, 0 recoverable, one
on_errorfor the lot:The test for the permanent set is "is what makes this fail a property of the batch?" — true for 413 (size) and 415 (media type), constant across retries; false for 402, where payment required is a property of the account and stops being true the moment someone pays. That is the same shape as the 429 which was deliberately carved out.
Ancor argued this from the semantics of the status. Probing a real Lago gives a harder argument: Lago cannot emit 402 at all.
transaction_id(replay)value_already_existtoo_many_eventsContent-Type: text/plainIts surface is 400/401/403/404/405/422/429. So the only possible sender of a 402 is an intermediary, and every plausible intermediary emits it for account reasons that resolve out-of-band. Two side effects worth recording: an oversized batch answers 422
too_many_events, which already routes to the split path (the batch-splitting capability 413 was added for was already reachable), and a duplicatetransaction_idanswers 422, not the 409 the comment claimed as its own exemplar. 413/415 stay for an intermediary — nginx'sclient_max_body_sizereally does answer 413 before the request reaches Rails — and the comments now say which reader they are for.The one the review found by accident, which had already happened
usage_metadatafrom the Cloudflare gateway got no drift sweep.extraswas a fixed three-key dict, so any counter the adapter does not map vanished with no error and noon_error— the one place violating the contracttest_drift.pyenforces for the native adapters.A live Logs API pull over 50 real entries:
neuronsis Cloudflare's Workers AI billing unit andunitsis a cost quantity — andunitsappears in none of the 14 captured fixtures, so the hand-maintained enumeration in the module docstring had already drifted past reality. That is the money-relevant drift the review only hypothesised, present today. (Ancor namedinput_text_tokens;neuronswas unmapped too and matters more.)Unmapped keys now sweep into
extras["usage_metadata"]against an explicit_MAPPED_USAGE_KEYSset, so a ninth spelling surfaces on its own instead of needing another 14-fixture audit. The docstring now says the enumeration is illustrative, not exhaustive.Closes #16, which tracked exactly this. One deviation from that issue's suggestion: it proposed a dotted prefix (
extras["usage_metadata.neurons"]), and this nests the whole dict instead (extras["usage_metadata"]["neurons"]). Nesting matches the convention already pinned bytest_drift.py::test_invoke_openai_compat_prompt_tokens_details_lands_in_extras, which assertsextras["prompt_tokens_details"] == {...}— the nested dict under its own key. It also removes a shadowing hazard: the poller readsextras["cached"]to decide whether to skip billing a request Cloudflare served for free, so a futureusage_metadatakey calledcachedorstepmust not be able to overwrite it.Also in here
api_urlsilently resolved to PRODUCTION. Preferring the config value over""is right —requestsraisesMissingSchema, not aLagoApiError, so the queue retried forever and all billing stopped. ButLagoConfig's default is the production URL, soapi_url=os.environ.get("LAGO_API_URL", "")with the var unset resolved to production with noon_errorand no log (verified live). For a CI job or a developer holding a real production key that writes live billing data, and ingested events cannot be un-ingested. The fallback is unchanged; it is now reported underconfig.api_url.prime()re-downloaded the ~400-model OpenRouter catalogue regardless of the TTL. It set_openrouter_staleunconditionally, and it is called from_auto_prime_pricing_foron a matchingwrap()and fromwarm_pricing()— both of which a server can run per request — sopricing_ttl_secondsnever applied there at all. Against the live catalogue with the shipped 1-hour TTL: 4 cycles → 4 full downloads where 1 was correct; now 1.on_errorreports; now 1, and it still recovers once the window expires (checked explicitly — this is a backoff, not a permanent give-up). Per-source 1→2→4→…→60s, matching the queue's own send backoff, so one bad credential cannot delay the three healthy tables.A deliberate divergence from JS
The four pricing fetches stay sequential here. This refresh runs on a blocking daemon thread, where a thread pool for four fetches is a larger change than the problem warrants; the per-source backoff already removes the harm. JS additionally parallelises them under
Promise.allSettled, which its event loop makes free. Recorded in the changelog as language-inherent, likeos.register_at_forkagainstAsyncLocalStorage.Verification
Every fix has a test that fails when the fix is reverted. Green at this commit: ruff, ruff format, mypy
strict, 552 unit tests (was 545).Each finding was driven against live infrastructure before and after — a real local Lago, the real OpenRouter catalogue, the real Cloudflare model and Logs APIs — rather than only against mocks:
api_urlfalsyprime()TTLneurons+unitsdropped