Skip to content

fix(batches): bound the batch rate limiter's input-file read - #35408

Open
mubashir1osmani wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
mubashir1osmani:litellm_batch_file_read_timeout
Open

fix(batches): bound the batch rate limiter's input-file read#35408
mubashir1osmani wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
mubashir1osmani:litellm_batch_file_read_timeout

Conversation

@mubashir1osmani

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • POST /v1/batches could hang indefinitely
  • The batch rate limiter read the input file with no deadline
  • A stalled Files API outlived every client timeout

How it solves it:

  • Bound the read; default 10s, configurable
  • Restricted keys fail closed, unrestricted keys admitted unmetered
  • Unskips the e2e test this hang was blocking

Relevant issues

Linear ticket

Resolves LIT-5027

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

Pending; I will attach the live-proxy runs against a proxy built from this branch. The reproduction needs a Files API that stalls on read, so the before/after is captured by pointing a deployment at a file host that holds the connection open, then re-running the same batch create with the fix in place

Type

🐛 Bug Fix

Changes

BatchRateLimiter.async_pre_call_hook runs synchronously in the request path. When the calling key has applicable rpm/tpm limits, it read the batch input file to count tokens and awaited that read with no deadline, so the OpenAI SDK default applied: 600s with max_retries=2. A slow or stalled Files API therefore held POST /v1/batches open far past any reasonable client timeout. On stage a read took 63.6s against a client whose read timeout was 60s, so the client gave up first and the eventual 404 (the client's teardown had already deleted the file) read as the cause rather than the consequence

The read is now bounded. Both fetch paths go through one asyncio.wait_for, since the managed-files hook accepts no timeout argument of its own

The failure policy splits, because the read does double duty. It counts tokens for rate limiting, and it validates every body.model in the JSONL against the caller's model allowlist. Those two have opposite safe defaults on timeout. A key restricted to a subset of models is rejected with a 504: admitting its batch without reading the file would grant exactly the bypass that _should_skip_batch_input_file_processing already refuses to allow through operator config, letting a caller run models outside its allowlist under the proxy's shared credentials by making the read slow. A key with unrestricted model access has only rate-limit accuracy at stake, so its batch is admitted unmetered with a warning, matching the fail-open the generic handler already applied; a degraded Files API does not turn batch creation into an outage

The deadline is also passed to afile_content, not just to wait_for. afile_content runs the sync client via run_in_executor, and cancelling that await does not interrupt a thread already in the pool, so bounding only the await would free the request handler while leaking the worker until the SDK's own timeout fired. The managed-files path takes no timeout argument, so there wait_for is the only bound and an abandoned read may still hold its thread; that is called out in a comment rather than papered over

Two smaller things fall out. The hang was invisible in logs because the hook emitted nothing between entry and failure, and the eventual error was swallowed by the "don't block the request if rate limiting fails" handler, so neither the caller nor the operator learned the limiter had failed. Both timeout branches now log. The rejection raises ProxyException rather than HTTPException so it maps to the same OpenAI error shape as the proxy's other refusals

I checked the rest of the pre-call hooks for the same unbounded-await shape, as the ticket asked. batch_rate_limiter.py is the only hook that reads files

New setting: general_settings.batch_input_file_read_timeout, default 10s, also settable per-deployment via BATCH_INPUT_FILE_READ_TIMEOUT_SECONDS. Non-positive and non-numeric values fall back to the default, since a 0s budget would expire instantly and reject every restricted key's batch

QA runbook

  • tests/e2e/batches/test_batches_e2e.py::test_rate_limited_batch_create_leaves_no_unattributed_spend_row - creating a batch on a rate-limited key runs the input-file read and leaves no spend row the proxy could not attribute to a key. This test is unskipped here; it was skipped in test(e2e): skip the batch rate-limiter spend-row test pending LIT-5027 #35301 because the path under test hung
    • Generate a key with limits but no model restriction, so the file-read path fires while the batch itself is not blocked: curl -X POST http://localhost:4000/key/generate -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"models": [], "tpm_limit": 1000000, "rpm_limit": 1000, "user_id": "e2e-batch-rl-manual"}'
    • Note the current unattributed rows so the assertion is a delta, not an absolute: curl -s "http://localhost:4000/spend/logs/v2?start_date=$(date -u -v-1H +%Y-%m-%dT%H:%M:%SZ)&end_date=$(date -u -v+1H +%Y-%m-%dT%H:%M:%SZ)" -H "Authorization: Bearer sk-1234" | jq '[.data[] | select(.api_key == "" or .api_key == null)] | length'
    • Upload a batch JSONL with purpose=batch using the key from step 1, then POST /v1/batches with the returned input_file_id and expect 200 within a few seconds rather than a client-side read timeout
    • Re-run the step 2 query and expect the count unchanged: every row the batch produced carries the calling key
    • Sanity check: this test makes sense to add and is not hand-wavey (e.g., assert actual expected spend instead of just spend > 0) or potentially flaky

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

@mubashir1osmani
mubashir1osmani requested a review from a team July 31, 2026 20:11
Comment thread .github/workflows/publish-ghcr.yml Outdated
@veria-ai

veria-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request bounds how long the batch rate limiter spends reading an uploaded input file while calculating batch usage. It updates the batch-processing rate-limit path to handle file reads that exceed the deadline.

One issue remains open: a caller with unrestricted model access can intentionally trigger the file-read timeout and execute a batch without incrementing configured RPM or TPM counters. This permits a targeted rate-limit bypass but does not directly grant broader access; one other issue has already been addressed.

Open issues (1)

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

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a manually dispatched workflow for building selected LiteLLM container variants and optionally publishing them to the fork owner's GHCR namespace.

Confidence Score: 4/5

The workflow should not be merged until requested variants are normalized or invalid input is rejected instead of silently omitting images.

Exact variant matching drops names surrounded by common comma-list whitespace, while the unvalidated image tag can also produce malformed Docker references.

Files Needing Attention: .github/workflows/publish-ghcr.yml

Important Files Changed

Filename Overview
.github/workflows/publish-ghcr.yml Adds the GHCR build-and-publish matrix, but input handling can silently omit requested variants or fail on malformed image tags.

Reviews (1): Last reviewed commit: "Merge branch 'BerriAI:litellm_internal_s..." | Re-trigger Greptile

Comment thread .github/workflows/publish-ghcr.yml Outdated
Comment on lines +60 to +62
wanted="${{ github.event.inputs.variants }}"
name="${{ matrix.name }}"
if [[ ",${wanted}," == *",${name},"* ]] || [[ "${wanted}" == "${name}" ]]; then

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 Whitespace silently drops variants

When variants contains conventional spaces such as litellm, database, non_root, the exact substring match does not recognize the latter names, causing the workflow to complete without building or publishing all requested images.

Comment thread .github/workflows/publish-ghcr.yml Outdated
Comment on lines +94 to +99
tag="${{ github.event.inputs.image_tag }}"
sha="$(git rev-parse --short HEAD)"
image="ghcr.io/${owner}/${{ matrix.image_suffix }}"
{
echo "image=${image}"
echo "tags=${image}:${tag},${image}:${sha}"

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 Invalid tags fail the build

image_tag is copied directly into the image references without validation, so values containing spaces or commas produce malformed tags and make the Docker build step fail after the workflow has already performed setup and registry login.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

POST /v1/batches could hang indefinitely. BatchRateLimiter.async_pre_call_hook
runs inline in the request path and, for keys with applicable rpm/tpm limits,
read the input file to count tokens with no deadline. With none set the OpenAI
SDK default applied (600s, max_retries=2), so a slow or stalled Files API held
the request open far past any client timeout; 63.6s was observed on stage
against a 60s client read timeout.

The read does double duty: it counts tokens for rate limiting, and it validates
every body.model in the JSONL against the caller's allowlist. Those have
opposite safe defaults, so the timeout policy splits on whether the key needs
that check. A key restricted to a subset of models is rejected, because
admitting it unchecked grants exactly the bypass
_should_skip_batch_input_file_processing refuses to allow via operator config.
A key with unrestricted access is admitted unmetered, matching the existing
fail-open, so a degraded Files API does not become an outage.

The deadline is passed to afile_content as well as to wait_for. afile_content
runs the sync client via run_in_executor, and cancelling that await does not
interrupt a thread already in the pool, so bounding only the await would leak
the worker until the SDK's own timeout fired.

Also unskips the e2e test that guards the LIT-3266 unattributed-spend-row
regression, which was blocked on this hang.

Defaults to 10s; override with general_settings.batch_input_file_read_timeout.
Passing `timeout` as its own keyword alongside `**fetch_kwargs` raised
TypeError when the deployment's credentials already carried one. `timeout` is
one of `_extract_file_access_credentials`' credential keys, so any deployment
whose litellm_params set it hit "got multiple values for keyword argument
'timeout'", turning POST /v1/batches into a 500 rather than fixing its hang.

Set it on the resolved kwargs instead, after credentials are merged, so the
limiter's budget still wins: a deployment timeout is sized for serving traffic,
not for a read that blocks the request path.

The regression test drives the real resolver with a deployment that configures
its own timeout, so it covers the merge rather than a stubbed return value.
@mubashir1osmani
mubashir1osmani force-pushed the litellm_batch_file_read_timeout branch from 405b228 to 971df2a Compare July 31, 2026 20:34
e.file_id,
e.timeout_seconds,
)
return data

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.

Low: Rate-limit bypass on file-read timeout

A caller with unrestricted model access can upload a sufficiently large batch file to make the content read exceed this deadline, after which this return allows the batch to execute without incrementing its RPM or TPM counters. Fail closed on timeout whenever applicable rate-limit descriptors caused the file to be processed; unrestricted model access removes the allowlist requirement, not the configured usage limits.

@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing mubashir1osmani:litellm_batch_file_read_timeout (d033ceb) with litellm_internal_staging (fa56283)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (5466402) during the generation of this report, so fa56283 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

BATCH_INPUT_FILE_READ_TIMEOUT_SECONDS failed the documentation gate, which
requires every env var to be documented in the environment-settings reference
(that lives in the litellm-docs repo, not here).

The env var was redundant anyway: general_settings.batch_input_file_read_timeout
already makes the deadline configurable per deployment, which is what was asked
for. Keeping only the general_settings key leaves one documented way to set it.
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.

1 participant