Skip to content

fix(proxy): isolate poison spend-log rows so one bad record can't drop the whole batch - #31705

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_spend_log_poison_row_isolation
Jun 30, 2026
Merged

fix(proxy): isolate poison spend-log rows so one bad record can't drop the whole batch#31705
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_spend_log_poison_row_isolation

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-4103

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

update_spend_logs flushes the queue with a single create_many per batch, which Postgres executes as one atomic statement. If a single row carries bytes Postgres refuses, the whole insert fails and every good row in that batch is dropped with it. PR #29515 strips NUL bytes from the JSON columns (messages, response, request_tags, proxy_server_request, metadata), but the scalar string columns are still written unsanitized, so a NUL byte in the request user field reaches the end_user column and reproduces the original 22xxx encoding rejection.

Live proxy against real Postgres and a real Anthropic model. A NUL byte cannot travel in a shell argv, so the three requests are sent from a tiny client that injects chr(0) into the user field of one of them; everything else is observed with psql. Two requests carry a clean user, the third carries a NUL byte, and all three land in the same flush batch (proxy_batch_write_at: 5).

# fire.py — sends 2 clean + 1 NUL-byte request, then waits one flush interval
import os, sys, time, httpx
KEY = os.environ["LITELLM_MASTER_KEY"]; tag = sys.argv[1]; NUL = chr(0)
users = [("good-A-"+tag, "good-A-"+tag), ("good-B-"+tag, "good-B-"+tag),
         ("poison-"+tag, "poison-"+tag+NUL+"-x")]
with httpx.Client(timeout=60) as c:
    for label, user in users:
        r = c.post("http://127.0.0.1:4103/v1/chat/completions",
            headers={"Authorization": "Bearer "+KEY},
            json={"model": "haiku", "user": user,
                  "messages": [{"role": "user", "content": "say hi in one word"}], "max_tokens": 10})
        print(f"{label} nul_in_user={NUL in user} -> HTTP {r.status_code}")
time.sleep(12)

Before the fix, the whole batch is lost. All three responses are 200, but none of the spend logs reach the DB:

good-A-unfixed   nul_in_user=False -> HTTP 200
good-B-unfixed   nul_in_user=False -> HTTP 200
poison-unfixed   nul_in_user=True  -> HTTP 200

$ psql -tAc "SELECT count(*) FROM \"LiteLLM_SpendLogs\" WHERE end_user LIKE '%unfixed%';"
0

proxy log:

prisma.errors.DataError: Error occurred during query execution:
ConnectorError(... QueryError(PostgresError { code: "22021",
message: "invalid byte sequence for encoding \"UTF8\": 0x00", ... }), transient: false )

After the fix, the two good rows persist and only the poisoned row is dropped and dead-lettered with its request_id:

good-A-fixed   nul_in_user=False -> HTTP 200
good-B-fixed   nul_in_user=False -> HTTP 200
poison-fixed   nul_in_user=True  -> HTTP 200

$ psql -tAc "SELECT request_id, end_user FROM \"LiteLLM_SpendLogs\" WHERE end_user LIKE '%fixed%' ORDER BY end_user;"
chatcmpl-6d46f9ce-...|good-A-fixed
chatcmpl-dbaf647e-...|good-B-fixed
-- good=2  poison=0

proxy log:

LiteLLM Proxy:ERROR: Spend tracking - dropping spend log row Postgres rejected.
request_id=chatcmpl-7685f593-... error=Error occurred during query execution: ...

The "Error in spend logs queue monitor" line that fires on the unfixed batch is gone after the fix, since the batch no longer crashes the flush job.

A transient outage must never be treated as a poison row. The "can't reach database server" failure that prisma mislabels as a DataError is re-raised unchanged through is_database_service_unavailable_error, so it keeps flowing into the existing connection-retry path instead of being bisected into silent per-row drops. This is pinned by test_update_spend_logs_reraises_connection_masquerade_dataerror, and the salvage behavior by test_update_spend_logs_isolates_poison_row_and_persists_good_rows; both fail on the pre-fix code.

Type

🐛 Bug Fix

Changes

update_spend_logs now routes each batch through _create_spend_logs_with_poison_isolation. On a genuine data-layer rejection it bisects the batch so the good rows still persist and only the offending row is dropped and logged with its request_id; the disjoint-halves recursion writes each row at most once and skip_duplicates keeps any cross-retry re-attempt idempotent against the request_id primary key. Transport failures, including the connection outage prisma surfaces as a DataError, are re-raised so the caller's retry/backoff path is unchanged and a blip never becomes data loss.

The classification lives in a new PrismaDBExceptionHandler.is_prisma_data_error helper that matches the base DataError by exact type, so the specific subclasses (UniqueViolationError, TableNotFoundError, MissingRequiredValueError, ...) are excluded and a systemic failure like a missing table surfaces loudly instead of being bisected into silent per-row drops. Keeping the check there preserves the in-function prisma import that module already uses, so litellm.proxy.utils stays importable without the proxy extra.

The bisection carries a per-batch attempt budget (MAX_SPEND_LOG_ISOLATION_ATTEMPTS_PER_BATCH) that hard-caps how many create_many calls the isolation may issue, checked before any insert and decremented per call, so an authenticated caller flooding poisoned rows cannot amplify one failed bulk insert into unbounded failed inserts and log lines. Once the budget is spent the still-failing remainder is dropped wholesale under one log line, which is the pre-existing drop-the-batch behavior, so the isolation salvages the common sparse-poison case while the DB work under abuse stays bounded by the budget regardless of how many rows are poisoned.

This is scoped to the batch-isolation gap that PR #29515 left open; it does not change the NUL-byte stripping or add sanitization for the scalar columns, so whatever still slips through to the write is now contained to its own row instead of taking the batch down.

The isolation covers the in-process Prisma write only. When SPEND_LOGS_URL is set the batch is POSTed to an external writer that owns its own atomicity, and the separate LiteLLM_SpendLogToolIndex write is already fault-isolated behind its own non-fatal try/except, so neither is in scope here.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes spend-log batch writes tolerate one bad row without dropping the full batch. The main changes are:

  • Adds exact-type detection for base Prisma DataError values.
  • Routes Prisma spend-log inserts through bounded poison-row isolation.
  • Re-raises database transport and service-unavailable errors so retry behavior stays unchanged.
  • Adds tests for row isolation, connection-error propagation, and the isolation attempt cap.

Confidence Score: 4/5

The change is narrowly scoped to spend-log persistence and preserves the existing retry path for database availability failures.

The implementation includes targeted tests for poison-row isolation, connection-error propagation, and the isolation attempt budget, with no remaining review comments.

T-Rex T-Rex Logs

What T-Rex did

  • Ran spend-poison-isolation test and captured before state with a bubbled DataError and no recorded writes, then captured after state with bisection create_many calls, successful writes for r0, r2, r3, and a log entry for the dropped poison singleton.
  • Evaluated prisma-data-error-helper behavior by comparing base and head contexts; observed head classification flags as is_prisma_data_error: True and is_database_service_unavailable_error: True, with re-raise unchanged and no split/drop.
  • Ran spend-isolation-budget with budgeted isolation to verify behavior under a cap; observed base and after states showing subtotal 768 input rows, cap 256, repeated bisection, and remainder dropping without a raised error.
  • Executed prisma-data-error-helper across base and head checkouts and confirmed head run produced a full classification table with exit code 0.
  • Compared litellm_overhead_guardrails_latency_metric presence between base and head; observed target_attr_exists True in base and False in head, with zero as the export for the head run and exit code 0.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (5): Last reviewed commit: "fix(proxy): isolate poison spend-log row..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes spend-log batch writes survive one bad row. The main changes are:

  • Adds recursive isolation around create_many spend-log inserts.
  • Drops and logs only the row rejected by Postgres data validation.
  • Re-raises database-unavailable DataError cases so retry handling stays unchanged.
  • Adds tests for poison-row isolation and connection-error propagation.

Confidence Score: 5/5

The change is narrowly scoped to spend-log batch insertion and preserves the existing retry path for database connectivity failures.

The implementation is covered by targeted tests for both poison-row isolation and connection-error propagation, and no code issues were identified.

T-Rex T-Rex Logs

What T-Rex did

  • Ran the initial poison test by attempting a create_many with four request IDs and observed a DataError bubble and an empty result persisted.
  • Ran the follow-up test with a head processing multiple batches, isolated the poison r1, persisted ['r0', 'r2', 'r3'], did not persist poison, and completed without bubbling.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "fix(proxy): isolate poison spend-log row..." | Re-trigger Greptile

@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yassin-berriai
yassin-berriai force-pushed the litellm_spend_log_poison_row_isolation branch from eed97c8 to 3a28122 Compare June 30, 2026 11:16
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread litellm/proxy/utils.py Outdated
@veria-ai

veria-ai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@yassin-berriai
yassin-berriai force-pushed the litellm_spend_log_poison_row_isolation branch from 3a28122 to cb614be Compare June 30, 2026 11:50
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py Outdated
@yassin-berriai
yassin-berriai enabled auto-merge (squash) June 30, 2026 12:00
…p the whole batch

update_spend_logs flushes the queue with a single create_many per batch, so one
row carrying bytes Postgres refuses (a residual NUL byte is the canonical case)
fails the entire insert and drops every good spend log alongside it. PR #29515
strips NUL bytes from the JSON columns, but the scalar string columns (end_user,
model, session_id, ...) still flow through unsanitized, so a poisoned row can
still reach the write and take a batch of up to 1000 good rows down with it.

On a genuine data-layer rejection the batch is now bisected so the good rows
still persist and only the offending row is dropped and logged with its
request_id. The classification lives in PrismaDBExceptionHandler.is_prisma_data_error
(matched by exact type so systemic subclasses like a missing table are not
mistaken for a single poison row), which keeps prisma an in-function import and
litellm.proxy.utils importable without the proxy extra. Transport failures,
including the "can't reach database server" outage that prisma mislabels as a
DataError, are re-raised unchanged so the existing connection-retry path still
runs and a transient outage never turns into silent per-row data loss.

The bisection carries a per-batch attempt budget that hard-caps how many
create_many calls the isolation may issue (checked before any insert,
decremented per call, threaded through the recursion), so an authenticated
caller flooding poisoned rows cannot amplify one failed bulk insert into
unbounded failed inserts and log lines; once the budget is spent the
still-failing remainder is dropped wholesale under a single log line.

Resolves LIT-4103
@yassin-berriai
yassin-berriai force-pushed the litellm_spend_log_poison_row_isolation branch from cb614be to bc3cdd4 Compare June 30, 2026 12:12
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

Addressed both review points in the latest push.

The isolation budget now caps create_many attempts directly rather than single-row drops. MAX_SPEND_LOG_ISOLATION_ATTEMPTS_PER_BATCH is a hard ceiling on the number of inserts the isolation may issue for one batch; it is checked before any insert (so an exhausted budget never even attempts a write, which also fixes the ordering point about the single-row branch), decremented once per create_many call, and threaded through the recursion, so the total inserts are bounded by the budget regardless of how many rows are poisoned. The new test asserts the attempt count stays at or below the cap and below the input row count for a single-batch poison flood, and it is a mutation-killer (removing the budget branch makes it fail). Also removed the unused real_spend_log_error local the inline comment flagged.

@greptileai

@yassin-berriai
yassin-berriai merged commit 52dc15a into litellm_internal_staging Jun 30, 2026
124 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_spend_log_poison_row_isolation branch June 30, 2026 19:21
tiannianzhu pushed a commit to tiannianzhu/litellm that referenced this pull request Jul 3, 2026
…p the whole batch (BerriAI#31705)

update_spend_logs flushes the queue with a single create_many per batch, so one
row carrying bytes Postgres refuses (a residual NUL byte is the canonical case)
fails the entire insert and drops every good spend log alongside it. PR BerriAI#29515
strips NUL bytes from the JSON columns, but the scalar string columns (end_user,
model, session_id, ...) still flow through unsanitized, so a poisoned row can
still reach the write and take a batch of up to 1000 good rows down with it.

On a genuine data-layer rejection the batch is now bisected so the good rows
still persist and only the offending row is dropped and logged with its
request_id. The classification lives in PrismaDBExceptionHandler.is_prisma_data_error
(matched by exact type so systemic subclasses like a missing table are not
mistaken for a single poison row), which keeps prisma an in-function import and
litellm.proxy.utils importable without the proxy extra. Transport failures,
including the "can't reach database server" outage that prisma mislabels as a
DataError, are re-raised unchanged so the existing connection-retry path still
runs and a transient outage never turns into silent per-row data loss.

The bisection carries a per-batch isolation budget so an authenticated caller
flooding poisoned rows cannot amplify one failed bulk insert into ~2N failed
inserts and N log lines; once the budget is spent the still-failing remainder
is dropped wholesale under a single log line.

Resolves LIT-4103
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.

3 participants