Skip to content

fix(exceptions): don't map upstream 400 to RateLimitError on substring match - #27915

Closed
mateo-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_fix_400_misclassified_as_429-ceb3
Closed

fix(exceptions): don't map upstream 400 to RateLimitError on substring match#27915
mateo-berri wants to merge 1 commit into
litellm_internal_stagingfrom
litellm_fix_400_misclassified_as_429-ceb3

Conversation

@mateo-berri

Copy link
Copy Markdown
Contributor

Problem

OpenAI's Unknown parameter: '<field>' / Unrecognized request arguments supplied: <field1>, <field2> 400s, which echo the rejected field name back in the message, get misclassified as 429 RateLimitError whenever the field name contains rate_limit (or any rate[\s_\-]*limit substring) — e.g. _litellm_rate_limit_descriptors.

Concretely: while reproducing the v3 limiter leak (PR #27001 → PR #27913), OpenAI returned

{ "error": { "message": "Unknown parameter: '_litellm_rate_limit_descriptors'.", "code": "400" } }

and LiteLLM surfaced it to the client as

HTTP/1.1 429 Too Many Requests
{ "error": { "message": "litellm.RateLimitError: OpenAIException - Unknown parameter: '_litellm_rate_limit_descriptors'.", "type": "throttling_error", "code": "429" } }

That breaks two contracts:

  1. Retry semantics. Clients see 429 throttling_error and assume "back off and retry" — but the upstream rejected the body, retrying won't help. Loops forever on a malformed request.
  2. Debuggability. It's the misclassification that hid the v3 leak: the 429 made it look like rate-limiting working as intended. The bug only surfaced after end-to-end provider testing.

Root cause

In litellm/litellm_core_utils/exception_mapping_utils.py:

Cause 1 — unanchored regex: ExceptionCheckers.is_error_str_rate_limit uses re.search(r"rate[\s_\-]*limit", _error_str_lower). No word boundary, so it matches rate_limit as a substring of any identifier. Real OpenAI 400 body fields named with rate_limit (or LiteLLM-internal field names like _litellm_rate_limit_descriptors leaked into the body) all trip the heuristic.

Cause 2 — heuristic runs before status code: Inside the OpenAI / OpenAI-compatible / Mistral branch of exception_type, is_error_str_rate_limit is called unconditionally — before the status-code branch that handles 400 → BadRequestError. So an explicit upstream 400 never gets a chance to map correctly when the string matches.

Anthropic's branch was already correct: it routes 400/413 to BadRequestError via status code, no string heuristic up front.

(Companion observation in PR #16482, which tightened \b429\b against 429 substrings — same shape of bug, different token.)

Fix

Anchor the regex with a leading word boundary: \brate[\s_\-]*limit. Embedded identifiers like _litellm_rate_limit_descriptors no longer match (the preceding _ is a word char so \b fails). Standalone phrases all still match: Rate limit exceeded, rate_limit_exceeded, rate-limit-exceeded, You hit a rate limit.

Status-code gate at the mapping site: when getattr(original_exception, "status_code", None) == 400, skip the string heuristic entirely. A 400 is unambiguous per RFC 9110 — the upstream rejected the body, not the rate. Don't second-guess it.

Both fixes are independent and complementary: the regex fix protects unknown-status-code paths (some upstream errors don't carry status_code), and the gate protects against any future heuristic that could mistake a 400 message for a 429.

Tests

tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py:

  • test_is_error_str_rate_limit_ignores_field_name_substring — three forms of the OpenAI 400 wire message all return False.
  • test_is_error_str_rate_limit_still_matches_standalone_token — proves the tightening didn't regress legitimate detections.
  • TestExceptionTypeStatusCodeGate — end-to-end mapper tests. Build a real openai.BadRequestError with status 400 and the wire messages we saw, run exception_type, assert the result is litellm.BadRequestError (not RateLimitError).
  • test_openai_429_still_maps_to_rate_limit — confirms the genuine 429 path is untouched.
$ uv run pytest tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py -q
43 passed in 0.21s

Related

Companion to #27913 (which fixes the underlying v3 limiter leak that this misclassification was hiding).

Slack Thread

Open in Web Open in Cursor 

…ng match

OpenAI rejects unknown body fields with a 400 whose message echoes the field name back (Unknown parameter / Unrecognized request arguments supplied). The exception mapper was misclassifying those as 429 RateLimitError whenever the field name contained a rate plus limit substring (eg the literal name of an internal proxy stash key leaked into the body by another bug).

Two compounding causes in is_error_str_rate_limit. The substring regex had no word boundary, so it matched embedded identifiers. The mapper called the heuristic unconditionally even when original_exception.status_code was 400.

Fix one: anchor the substring regex with a leading word boundary. Embedded identifiers no longer match. All standalone phrases still do (Rate limit exceeded, rate_limit_exceeded, rate-limit-exceeded, You hit a rate limit). Fix two: skip the heuristic entirely on a known 400 at the OpenAI / OpenAI-compatible / Mistral mapping site. A 400 is unambiguous per RFC 9110 -- the upstream rejected the body, not the rate. Don't second-guess it. Anthropic's mapper was already correct.

Tests: regex anchor tests for substring vs standalone matching. End-to-end mapper test that builds a real openai.BadRequestError with status 400 and the wire messages we saw, asserts the result is litellm.BadRequestError (not RateLimitError). Plus a 429-path regression guard to prove the gate didn't break legitimate throttling detection.

Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com>
@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.

@codecov

codecov Bot commented May 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mateo-berri
mateo-berri marked this pull request as ready for review May 15, 2026 03:02
@greptile-apps

greptile-apps Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a misclassification bug where upstream OpenAI 400 responses whose body contained rate_limit as a substring of a field name (e.g. _litellm_rate_limit_descriptors) were mapped to 429 RateLimitError instead of 400 BadRequestError, causing clients to retry forever on a malformed request.

  • Regex fix: Adds a leading \\b word boundary to rate[\\s_\\-]*limit, preventing embedded identifiers prefixed with _ (a word char) from matching the heuristic. Standalone phrases (\"Rate limit exceeded\", \"rate_limit_exceeded\") continue to match correctly.
  • Status-code gate: Inside the OpenAI/openai-compatible/Mistral branch of exception_type, an explicit status_code == 400 now skips the string heuristic entirely, since a 400 is unambiguous per RFC 9110. Tests cover the regression, the preserved 429 path, and the field-name false-positive, all using mock HTTP objects without real network calls.

Confidence Score: 4/5

Safe to merge; the two changes are minimal and targeted, and the tests confirm the core regression is fixed without breaking the genuine 429 path.

Both changes are narrow and well-tested. The only gap is that the leading \b anchor doesn't protect field names that start without an underscore (e.g. rate_limit_descriptors) when status_code is absent — the status-code gate covers that in the common case, but the heuristic alone still has a residual false-positive surface. This is a minor hardening gap rather than a present defect for the reported bug.

No files require special attention beyond the noted regex boundary edge case in exception_mapping_utils.py.

Important Files Changed

Filename Overview
litellm/litellm_core_utils/exception_mapping_utils.py Adds a \b word-boundary anchor to the rate-limit regex and a status-code-400 gate before the string heuristic; correctly prevents misclassification of upstream 400s as RateLimitError when field names embed "rate_limit"
tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py New tests cover: field-name substring false-positive regression, standalone rate-limit true-positive preservation, end-to-end exception_type 400→BadRequestError mapping, and 429 regression guard; no real network calls, all mock-based

Reviews (1): Last reviewed commit: "fix(exceptions): do not map upstream 400..." | Re-trigger Greptile

# (e.g. "Unknown parameter: _litellm_rate_limit_descriptors") don't
# get misclassified as throttling. Without the boundary, the field
# name's embedded ``rate_limit`` matched and turned a 400 into a 429.
if re.search(r"\brate[\s_\-]*limit", _error_str_lower):

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 Partial anchor — trailing word boundary missing

\brate[\s_\-]*limit anchors the start of rate but not the end of limit, so a field name like "rate_limit_descriptors" (no leading underscore, e.g. "Unknown parameter: 'rate_limit_descriptors'.") still matches when there is no preceding underscore. The status-code gate at line 414 saves the case where status_code == 400 is known, but if status_code is None (some SDKs don't expose it), the string heuristic still fires on those identifiers and returns True. Adding a trailing \b (\brate[\s_\-]*limit\b) would fully close this, but note it would also stop matching rate_limit_exceeded (where _ follows limit) — so the right fix is probably a trailing (?:[\s\-]|$) boundary or a more explicit positive-lookahead rather than \b.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.

@github-actions github-actions Bot added the stale label Aug 14, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it?

I read the description against our contribution rubric. Here's how it lined up:

What you got right:

  • ✅ Clear problem description
  • ✅ Expected vs. actual behavior

What's still missing:

  • End-to-end QA proof of the fix: the real 400/429 wire output shown is the bug reproducing (great context), but the only evidence of the fixed behavior is uv run pytest tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py, which builds an openai.BadRequestError in-process and never calls a provider. Re-running the same real request that produced the 429 throttling_error and pasting the now-400 response (or a screenshot/recording of it) would close this gap.

Context is strong: the misclassified upstream 400, the two root causes in exception_mapping_utils.py, and the expected-vs-actual mapping are all clear, and the before-half is a real provider response. The rubric still needs the after-half against the real system; repo unit tests mock the provider, so they don't count as end-to-end proof.

If the description isn't updated in the next 2 hours, I'll auto-close this PR. That's not us saying we don't care about the change; we want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later," not a rejection. Take your time; everything below still works after the close.

During the grace period: just update the PR description with the missing pieces. No need to ping me; I'll re-check on the next sweep and skip the auto-close if it now passes. See what counts as QA proof for the full rubric (a linked issue alone isn't enough; it covers context, not proof).

If the PR does get auto-closed in 2 hours, you still have easy recovery paths:

  • Comment @agent-shin reconsider after updating the description. I'll re-evaluate and reopen the PR if it now passes.
  • Comment @greptileai to request a fresh Greptile review; that still works even after the PR is closed, and a stronger score is one of the signals that lifts the PR back into the queue. So a low Greptile score isn't a blocker either.

Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.

(I'm an LLM, so I'm not infallible. If you think I got this wrong, ping a maintainer; they'll override me.)

@github-actions github-actions Bot removed the stale label Aug 15, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🚅 Hi, thanks for the PR! I'm Agent Shin, the automated triage bot for this repository. What's this and why am I getting it?

I read the description against our contribution rubric. Here's how it lined up:

What you got right:

  • ✅ Clear problem description
  • ✅ Expected vs. actual behavior

What's still missing:

  • End-to-end QA proof of the fix: the real 400/429 wire output is the bug reproducing (good context), but the only evidence of the fixed behavior is uv run pytest tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py, which constructs an openai.BadRequestError in-process and never calls a provider. Re-running the request that produced the 429 throttling_error and pasting the now-400 response would close this gap.

The diagnosis is excellent: the misclassified upstream 400, both root causes in exception_mapping_utils.py, and expected vs. actual are all clear, and the before-half is a real provider response. The after-half against the real system never landed in the 24h since the warning.

Closing this PR isn't a rejection of the change. We want the open-PR list to mirror what a maintainer can act on right now, so contributors don't get lost in a backlog. A closed PR is a soft "park this for later"; your work is still here, the diff is still here, and getting it reopened is one comment away. Take your time.

To bring this PR back:

  • Update the description with the missing pieces, then comment @agent-shin reconsider on this PR. I'll re-evaluate and reopen if it now passes.
  • Or Open a new PR with the same fix and the updated description. GitHub doesn't always let external contributors reopen a bot-closed PR, so a fresh PR is the most reliable path back into the review queue.
  • If Greptile's most recent score on this PR was below 4/5, comment @greptileai to request a fresh review; that still works even after the PR is closed, and a stronger score is one of the signals that lifts the PR back into the queue. A low Greptile score isn't a blocker.

What "end-to-end QA proof" means, since it's the most common gap: at least one of a short before/after screen recording / video (the bug reproducing, then the fix working; for a brand-new feature, a recording of it working end-to-end), a screenshot (or before/after screenshots) of it working, or the exact commands you ran paired with their real output against the real system. Running pytest on the repo's unit tests doesn't count; those mock the LLM provider, DB, and network, so they aren't end-to-end. Output from a real, no-mocks integration run is what we look for. A linked issue alone isn't enough either: it covers context, not proof. See the full rubric.

Internal BerriAI contributors: this rubric doesn't apply to you; ping a maintainer.

(I'm an LLM, so I'm not infallible. If you think I got this wrong, comment @agent-shin reconsider or ping a maintainer; they'll override me.)

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