fix: raise litellm.RateLimitError instead of HTTPException in parallel request limiter (#22007)

_handle_rate_limit_error in parallel_request_limiter_v3.py was raising a
plain HTTPException which meant the error message lacked the normalized
"litellm.RateLimitError:" prefix. Changed to raise litellm.RateLimitError
consistent with how other parts of the codebase (e.g. lowest_tpm_rpm_v2.py)
handle rate limit errors. The error still has status_code 429 and carries
the same detail information and response headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Anandesh Sharma 2026-02-27 04:05:31 +05:30
parent 3639800892
commit 9a8dab36ad
2 changed files with 115 additions and 37 deletions

View file

@ -20,8 +20,10 @@ from typing import (
cast,
)
import httpx
from fastapi import HTTPException
import litellm
from litellm import DualCache
from litellm._logging import verbose_proxy_logger
from litellm.constants import DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE
@ -1079,7 +1081,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
response: RateLimitResponse,
descriptors: List[RateLimitDescriptor],
) -> None:
"""Handle rate limit exceeded error by raising HTTPException."""
"""Handle rate limit exceeded error by raising litellm.RateLimitError."""
for status in response["statuses"]:
if status["code"] == "OVER_LIMIT":
descriptor_key = status["descriptor_key"]
@ -1110,14 +1112,22 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
f"Limit resets at: {reset_time_formatted}"
)
raise HTTPException(
status_code=429,
detail=detail,
headers={
"retry-after": str(self.window_size),
"rate_limit_type": str(status["rate_limit_type"]),
"reset_at": reset_time_formatted,
},
raise litellm.RateLimitError(
message=detail,
llm_provider="",
model="",
response=httpx.Response(
status_code=429,
headers={
"retry-after": str(self.window_size),
"rate_limit_type": str(status["rate_limit_type"]),
"reset_at": reset_time_formatted,
},
request=httpx.Request(
method="POST",
url="https://litellm.ai",
),
),
)
async def async_pre_call_hook(

View file

@ -10,8 +10,6 @@ from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import pytest
from fastapi import HTTPException
import litellm
from litellm import Router
from litellm.caching.caching import DualCache
@ -112,7 +110,7 @@ async def test_sliding_window_rate_limit_v3(monkeypatch, time_controller):
)
# Fourth request should fail (counter would be 4, limit is 3, so 4 > 3)
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(litellm.RateLimitError) as exc_info:
await parallel_request_handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -120,7 +118,7 @@ async def test_sliding_window_rate_limit_v3(monkeypatch, time_controller):
call_type="",
)
assert exc_info.value.status_code == 429
assert "Rate limit exceeded" in str(exc_info.value.detail)
assert "Rate limit exceeded" in str(exc_info.value.message)
# Wait for window to expire (2 seconds)
time_controller.advance(3)
@ -401,7 +399,7 @@ async def test_normal_router_call_tpm_v3(
) # Use up most of our 10 token limit
# Make another request to test rate limiting - this should fail as we've consumed tokens
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(litellm.RateLimitError) as exc_info:
await parallel_request_handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -807,7 +805,7 @@ async def test_tpm_api_key_rate_limits_v3():
async def mock_should_rate_limit(descriptors, **kwargs):
nonlocal captured_descriptors
captured_descriptors = descriptors
# Return Error response to ensure HTTPException
# Return Error response to ensure litellm.RateLimitError
return {
"overall_code": "OVER_LIMIT",
"statuses": [
@ -839,12 +837,12 @@ async def test_tpm_api_key_rate_limits_v3():
data={"model": model},
call_type="",
)
except HTTPException as e:
except litellm.RateLimitError as e:
error = e
assert e.status_code == 429
assert "rate_limit_type" in e.headers
assert e.headers.get("rate_limit_type") == "tokens"
assert "retry-after" in e.headers
assert "rate_limit_type" in e.response.headers
assert e.response.headers.get("rate_limit_type") == "tokens"
assert "retry-after" in e.response.headers
assert error is not None, "An Exception must be thrown"
assert captured_descriptors is not None, "Rate limit descriptors should be captured"
@ -902,7 +900,7 @@ async def test_rpm_api_key_rate_limits_v3():
async def mock_should_rate_limit(descriptors, **kwargs):
nonlocal captured_descriptors
captured_descriptors = descriptors
# Return Error response to ensure HTTPException
# Return Error response to ensure litellm.RateLimitError
return {
"overall_code": "OVER_LIMIT",
"statuses": [
@ -934,12 +932,12 @@ async def test_rpm_api_key_rate_limits_v3():
data={"model": model},
call_type="",
)
except HTTPException as e:
except litellm.RateLimitError as e:
error = e
assert e.status_code == 429
assert "rate_limit_type" in e.headers
assert e.headers.get("rate_limit_type") == "requests"
assert "retry-after" in e.headers
assert "rate_limit_type" in e.response.headers
assert e.response.headers.get("rate_limit_type") == "requests"
assert "retry-after" in e.response.headers
assert error is not None, "An Exception must be thrown"
assert captured_descriptors is not None, "Rate limit descriptors should be captured"
@ -1596,8 +1594,8 @@ async def test_multiple_rate_limits_per_descriptor():
parallel_request_handler.should_rate_limit = mock_should_rate_limit
# Test the pre-call hook - this should raise HTTPException but NOT IndexError
with pytest.raises(HTTPException) as exc_info:
# Test the pre-call hook - this should raise litellm.RateLimitError but NOT IndexError
with pytest.raises(litellm.RateLimitError) as exc_info:
await parallel_request_handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -1607,15 +1605,15 @@ async def test_multiple_rate_limits_per_descriptor():
# Verify the exception details are correct and use the descriptor_key approach
assert exc_info.value.status_code == 429
assert "Rate limit exceeded for api_key:" in exc_info.value.detail
assert "max_parallel_requests" in exc_info.value.detail
assert "Current limit: 1" in exc_info.value.detail
assert "Remaining: 0" in exc_info.value.detail # max(0, -1) = 0
assert "Rate limit exceeded for api_key:" in exc_info.value.message
assert "max_parallel_requests" in exc_info.value.message
assert "Current limit: 1" in exc_info.value.message
assert "Remaining: 0" in exc_info.value.message # max(0, -1) = 0
# Verify headers are set correctly
assert exc_info.value.headers.get("rate_limit_type") == "max_parallel_requests"
assert "retry-after" in exc_info.value.headers
assert "reset_at" in exc_info.value.headers
assert exc_info.value.response.headers.get("rate_limit_type") == "max_parallel_requests"
assert "retry-after" in exc_info.value.response.headers
assert "reset_at" in exc_info.value.response.headers
@pytest.mark.asyncio
@ -1658,7 +1656,7 @@ async def test_missing_descriptor_fallback():
parallel_request_handler.should_rate_limit = mock_should_rate_limit
# Test the pre-call hook - should handle missing descriptor gracefully
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(litellm.RateLimitError) as exc_info:
await parallel_request_handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
@ -1668,9 +1666,79 @@ async def test_missing_descriptor_fallback():
# Verify the exception uses fallback values
assert exc_info.value.status_code == 429
assert "Rate limit exceeded for nonexistent_key: unknown" in exc_info.value.detail
assert "requests" in exc_info.value.detail
assert "Current limit: 2" in exc_info.value.detail
assert "Rate limit exceeded for nonexistent_key: unknown" in exc_info.value.message
assert "requests" in exc_info.value.message
assert "Current limit: 2" in exc_info.value.message
@pytest.mark.asyncio
async def test_handle_rate_limit_error_raises_litellm_rate_limit_error():
"""
Test that _handle_rate_limit_error raises litellm.RateLimitError (not a plain
HTTPException), ensuring the error message carries the normalized
'litellm.RateLimitError:' prefix.
Fixes https://github.com/BerriAI/litellm/issues/22007
"""
_api_key = "sk-test-ratelimit-prefix"
_api_key_hash = hash_token(_api_key)
user_api_key_dict = UserAPIKeyAuth(
api_key=_api_key_hash,
rpm_limit=5,
)
local_cache = DualCache()
parallel_request_handler = _PROXY_MaxParallelRequestsHandler(
internal_usage_cache=InternalUsageCache(local_cache)
)
# Mock should_rate_limit to return an OVER_LIMIT response
async def mock_should_rate_limit(descriptors, **kwargs):
return {
"overall_code": "OVER_LIMIT",
"statuses": [
{
"code": "OVER_LIMIT",
"current_limit": 5,
"limit_remaining": -1,
"rate_limit_type": "requests",
"descriptor_key": "api_key",
}
],
}
parallel_request_handler.should_rate_limit = mock_should_rate_limit
with pytest.raises(litellm.RateLimitError) as exc_info:
await parallel_request_handler.async_pre_call_hook(
user_api_key_dict=user_api_key_dict,
cache=local_cache,
data={"model": "gpt-4"},
call_type="",
)
error = exc_info.value
# Verify the error is a litellm.RateLimitError, not a plain HTTPException
assert isinstance(error, litellm.RateLimitError)
# Verify the message carries the normalized prefix
assert error.message.startswith("litellm.RateLimitError:")
# Verify the core details are still present
assert error.status_code == 429
assert "Rate limit exceeded for api_key:" in error.message
assert "Limit type: requests" in error.message
assert "Current limit: 5" in error.message
assert "Remaining: 0" in error.message # max(0, -1) = 0
# Verify the response headers are preserved
assert "retry-after" in error.response.headers
assert "rate_limit_type" in error.response.headers
assert "reset_at" in error.response.headers
@pytest.mark.asyncio