Skip to content

fix(check_batch_cost): retire permanently-unroutable and not-found batches - #36656

Closed
anneheartrecord wants to merge 1 commit into
BerriAI:litellm_internal_stagingfrom
anneheartrecord:work-36640
Closed

anneheartrecord wants to merge 1 commit into
BerriAI:litellm_internal_stagingfrom
anneheartrecord:work-36640

Conversation

@anneheartrecord

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • A batch row that can never be routed is retried every poll cycle, forever
  • A batch whose provider record 404s is retried every poll cycle, forever
  • Either one permanently occupies the oldest-N poll page and starves every batch behind it

How it solves it:

  • Retire (mark processed, without costing) only the two failures that are provably permanent: a unified id that decodes but has no model id, and a provider 404
  • Every other failure (flag off, deployment missing, transient error) still retries exactly as before

User Flow

Before: a team lead who runs nightly batch jobs sees $0.00 spend for every batch, forever, because one old batch sits at the head of the reconciliation queue and never clears

  1. They submit and create a batch via the proxy; it runs overnight and the provider bills for it
  2. Next morning they download the output via GET /v1/files/{output_file_id}/content — the work was really done
  3. They open /ui/?page=logs filtered to their key — the batch is $0.00
  4. GET /key/info shows unchanged spend
  5. Every subsequent batch is also $0.00, indefinitely — the first un-costable or provider-expired batch blocks all of them

After: the un-costable/expired batch is retired once and stops blocking newer batches

  1. They submit and create a batch as before
  2. The reconciliation poller retires the one permanently-unroutable or provider-404 row it can never bill, and moves on to the next row in the same cycle
  3. Newer batches reach the front of the queue and reconcile normally
  4. /ui/?page=logs and /key/info show real spend for those batches

Relevant issues

Fixes #36640

Linear ticket

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 received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

This is a background poller bug (fixed-page-size, oldest-first DB poll), so proof is the unit-test regression suite rather than a live proxy curl — same as the sibling fixes to this poller (#35360, #34785).

Before the fix (source at parent commit 0e9cd9893e, new tests present):

$ git checkout 0e9cd9893e -- enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
$ PYTHONPATH=enterprise LITELLM_LOCAL_MODEL_COST_MAP=True uv run --no-sync pytest \
    tests/proxy_unit_tests/test_check_batch_cost.py -q \
    -k "test_decoded_id_missing_model_id_is_permanently_retired or test_provider_not_found_error_retires_job or test_primary_and_fallback_queries_exclude_permanently_retired_statuses"
FAILED ...test_decoded_id_missing_model_id_is_permanently_retired
FAILED ...test_primary_and_fallback_queries_exclude_permanently_retired_statuses
FAILED ...test_provider_not_found_error_retires_job
3 failed, 43 deselected

After the fix (this branch, f5113f4237):

$ PYTHONPATH=enterprise LITELLM_LOCAL_MODEL_COST_MAP=True uv run --no-sync pytest \
    tests/proxy_unit_tests/test_check_batch_cost.py -q
46 passed

The new tests assert:

  • a decoded unified id with no model id is retired (status=unroutable, batch_processed=True) instead of retried forever
  • an id that isn't recognized at all (config-dependent — e.g. unmanaged tracking off) is still left for retry, not retired, so enabling the flag later still fixes it
  • a provider 404 on aretrieve_batch is retired (status=not_found, batch_processed=True)
  • a generic/transient provider error (timeout, 5xx) is still left unprocessed for retry, unchanged from today
  • both the primary and fallback poll queries exclude the two new terminal statuses, so a retired row can't be re-selected even on a schema without the batch_processed column

Type

🐛 Bug Fix

Caveats (if any)

  • The synthetic unroutable/not_found statuses are new values for the LiteLLM_ManagedObjectTable.status column (a free-form String?, no enum), following the existing stale_expired precedent for rows the poller gives up on.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR retires permanently unroutable and missing-provider batch reconciliation rows so they no longer starve later work.

  • Adds synthetic unroutable and not_found terminal statuses and excludes them from polling and stale cleanup.
  • Retires decoded IDs without a model identifier and LiteLLMNotFoundError retrieval failures.
  • Adds regression tests for permanent and retryable routing/provider failures.

Confidence Score: 4/5

The recoverable deployment-not-found path must be distinguished from a genuinely absent provider batch before this PR is safe to merge.

The new broad NotFoundError handler can permanently remove a batch from reconciliation when Azure reports a missing deployment, preventing spend recovery after configuration is restored.

Files Needing Attention: enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py

Important Files Changed

Filename Overview
enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py Adds permanent retirement paths, but the NotFoundError catch also retires recoverable Azure deployment-not-found failures and the new helper conflicts with repository Python conventions.
tests/proxy_unit_tests/test_check_batch_cost.py Adds focused regression coverage, though its generic NotFoundError fixture does not distinguish a missing batch from other 404 mappings.

Reviews (1): Last reviewed commit: "fix(check_batch_cost): retire permanentl..." | Re-trigger Greptile

Comment on lines 721 to +729
"batch_ignore_default_logging": True,
},
)
except LiteLLMNotFoundError as e:
verbose_proxy_logger.info(
f"Retiring job {job.unified_object_id}: provider reports batch "
f"{batch_id} not found — this is permanent, no retry will recover it: {e}"
)
self._record_error(prom_logger, "provider_batch_not_found")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Deployment errors become terminal

When Azure batch retrieval reports DeploymentNotFound, LiteLLM maps it to LiteLLMNotFoundError, and this catch permanently retires the batch as not_found, causing its cost never to reconcile after the deployment configuration is restored.

Comment on lines +208 to +210
provably permanent (see PERMANENTLY_UNPROCESSABLE_STATUSES). Leaving
batch_processed=False here would let the row re-occupy the fixed-size,
oldest-first poll window on every cycle and starve every batch behind it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mutable broadly typed update payload

The new helper constructs update_data as Dict[str, Any] and then mutates it conditionally, discarding useful static guarantees about the database payload and conflicting with the repository's immutable, fully typed Python conventions.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +27 to +39
# Statuses a row is written back with when a failure is provably permanent — no retry
# or config change can ever resolve it — so it must stop occupying the fixed-size,
# oldest-first poll window instead of being retried forever. Both are excluded from
# every find_many query the same way the provider-native terminal statuses are.
PERMANENTLY_UNPROCESSABLE_STATUSES = ("unroutable", "not_found")


class _UnroutableBatchError(Exception):
"""Raised by _resolve_job_routing for the one routing failure that is permanent:
a unified id that decodes successfully but embeds no model id. Every other
routing failure is config-dependent (enabling unmanaged tracking, restoring a
deployment) and must keep being retried, so it returns None instead of raising.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Verbose comments duplicate implementation

The new constant and exception add lengthy comments and docstrings that repeat the nearby control flow rather than limiting commentary to essential complex business logic, increasing maintenance cost when the retirement behavior changes.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f5113f4237

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"batch_ignore_default_logging": True,
},
)
except LiteLLMNotFoundError as e:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retire only confirmed missing batch records

When Azure returns DeploymentNotFound or another configuration-related 404, this catch permanently retires the batch, so restoring the deployment can never reconcile its cost

Useful? React with 👍 / 👎.

PERMANENTLY_UNPROCESSABLE_STATUSES = ("unroutable", "not_found")


class _UnroutableBatchError(Exception):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return the unroutable state instead of throwing

Return a tagged routing outcome instead of introducing an exception solely for internal control flow, as the repository explicitly requires CLAUDE.mdL80-L89

Useful? React with 👍 / 👎.

Comment on lines +27 to +30
# Statuses a row is written back with when a failure is provably permanent — no retry
# or config change can ever resolve it — so it must stop occupying the fixed-size,
# oldest-first poll window instead of being retried forever. Both are excluded from
# every find_many query the same way the provider-native terminal statuses are.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the redundant status narration

Remove this narration because the constant is self-explanatory; repository guidance permits comments only when complex business logic genuinely requires them CLAUDE.mdL1-L9

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…tches

_resolve_job_routing() returned None for every routing failure and the
retrieval except-block continued on every provider error, so a row that
can never be costed (a unified id with no model_id, or a batch the
provider reports 404 for) stayed batch_processed=False forever. Since
the poll query orders oldest-first with a fixed page size, one such row
permanently occupies a slot and starves every batch behind it.

Only these two failures are provably permanent; every other routing/
retrieval failure is config-dependent (flag off, deployment missing,
transient error) and must keep retrying. Retire just the permanent ones
by marking batch_processed=True with a synthetic terminal status
(unroutable/not_found), excluded from both the primary and fallback
poll queries the same way the provider-native terminal statuses are.
@anneheartrecord
anneheartrecord changed the base branch from main to litellm_internal_staging August 12, 2026 12:51
@codspeed

codspeed Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing anneheartrecord:work-36640 (1a5b941) with litellm_internal_staging (f64479e)

Open in CodSpeed

@mateo-berri

Copy link
Copy Markdown
Contributor

Closing as superseded by #36714, which merged with the same page-slot retirement plus a staleness sweep for terminal rows. Thanks for the PR!

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.

[Bug]: CheckBatchCost starves on rows it can never route, and latches its schema probe off on unrelated errors

2 participants