Merge pull request #41911 from BerriAI/litellm_rate_limit_reset_time_utc

fix(rate_limiter): render the 429 reset time in UTC as labelled
This commit is contained in:
Yassin Kortam 2026-09-18 18:05:45 -07:00 committed by GitHub
commit 15f63c33bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 101 additions and 8 deletions

View file

@ -19,7 +19,7 @@ Quick summary:
import json
from collections.abc import Callable, Iterable, Mapping, Sequence
from datetime import datetime
from datetime import datetime, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, TypeAlias
@ -661,7 +661,9 @@ class _PROXY_BatchRateLimiter(CustomLogger):
) or self.parallel_request_limiter.window_size
reset_time: Final = now + window_size if window_start is None else window_start + window_size
retry_after: Final = max(0, int(reset_time - now))
reset_time_formatted: Final = datetime.fromtimestamp(reset_time).strftime("%Y-%m-%d %H:%M:%S UTC")
reset_time_formatted: Final = datetime.fromtimestamp(reset_time, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
remaining_display: Final = max(0, status["limit_remaining"])
current_limit: Final = status["current_limit"]

View file

@ -13,7 +13,7 @@ from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequen
from contextlib import asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime
from datetime import datetime, timezone
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@ -3124,7 +3124,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
now = self._get_current_time().timestamp()
reset_time = now + self.window_size
reset_time_formatted = datetime.fromtimestamp(reset_time).strftime("%Y-%m-%d %H:%M:%S UTC")
reset_time_formatted = datetime.fromtimestamp(reset_time, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
remaining_display = max(0, status["limit_remaining"])
rate_limit_type = status["rate_limit_type"]

View file

@ -6,7 +6,10 @@ batch under a per-minute RPM/TPM budget. Scopes that configure `tpd_limit`
are charged against a 24h token window instead of their minute counters.
"""
from datetime import datetime
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from typing import Final
import pytest
from fastapi import HTTPException
@ -257,3 +260,39 @@ def test_online_descriptors_ignore_tpd_limit():
model_has_failures=False,
)
assert [(d["key"], d["rate_limit"]["window_size"]) for d in descriptors] == [("api_key", rate_limiter.window_size)]
@pytest.fixture(params=["Europe/Paris", "Asia/Kolkata", "America/Los_Angeles"])
def process_timezone(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
monkeypatch.setenv("TZ", request.param)
time.tzset()
yield request.param
monkeypatch.undo()
time.tzset()
@pytest.mark.skipif(not hasattr(time, "tzset"), reason="switching the process timezone needs time.tzset()")
@pytest.mark.asyncio
async def test_batch_rate_limit_error_reports_reset_time_in_utc_on_a_non_utc_proxy(process_timezone: str) -> None:
window_start: Final = datetime(2026, 9, 13, 8, 0, 0, tzinfo=timezone.utc)
clock: Final = _Clock(window_start)
_internal_usage_cache, _rate_limiter, batch_limiter = _make_limiters(clock)
user_api_key_dict: Final = UserAPIKeyAuth(api_key=hash_token("tpd-key-utc"), rpm_limit=1, tpd_limit=1000)
await batch_limiter._check_and_increment_batch_counters(
user_api_key_dict=user_api_key_dict,
data={},
batch_usage=BatchFileUsage(total_tokens=600, request_count=6),
)
clock.now = datetime(2026, 9, 13, 11, 0, 0, tzinfo=timezone.utc)
with pytest.raises(HTTPException) as exc:
await batch_limiter._check_and_increment_batch_counters(
user_api_key_dict=user_api_key_dict,
data={},
batch_usage=BatchFileUsage(total_tokens=600, request_count=6),
)
assert exc.value.status_code == 429
assert exc.value.headers["retry-after"] == str(BATCH_TPD_WINDOW_SECONDS - 3 * 3600)
assert exc.value.headers["reset_at"] == "2026-09-14 08:00:00 UTC"
assert str(exc.value.detail).endswith("Limit resets at: 2026-09-14 08:00:00 UTC")

View file

@ -7,10 +7,10 @@ import logging
import os
import sys
import time
from collections.abc import Sequence
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Final, List, Optional
import pytest
from fastapi import HTTPException
@ -21,10 +21,12 @@ from litellm.caching.caching import DualCache
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
from litellm.proxy.hooks.parallel_request_limiter_v3 import (
PARALLEL_REQUEST_SLOT_TTL_SECONDS,
ParallelSlotAcquisition,
RateLimitDescriptor,
RateLimitResponse,
RequestRateLimiterStash,
_request_stash,
get_or_create_request_stash,
@ -6911,3 +6913,51 @@ def test_success_tpm_accounting_skips_team_model_pool_when_key_owns_model_tpm_li
assert handler.create_rate_limit_keys("model_per_key", f"{hash_token('sk-pool')}:test-model", "tokens") in charged_keys
team_pool_key = handler.create_rate_limit_keys("model_per_team", "t:test-model", "tokens")
assert (team_pool_key in charged_keys) is charges_team_model_pool
@pytest.fixture(params=["Europe/Paris", "Asia/Kolkata", "America/Los_Angeles"])
def process_timezone(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> Iterator[str]:
monkeypatch.setenv("TZ", request.param)
time.tzset()
yield request.param
monkeypatch.undo()
time.tzset()
@pytest.mark.skipif(not hasattr(time, "tzset"), reason="switching the process timezone needs time.tzset()")
def test_rate_limit_error_reports_reset_time_in_utc_on_a_non_utc_proxy(process_timezone: str) -> None:
now: Final = datetime(2026, 9, 4, 21, 53, 21, tzinfo=timezone.utc)
handler: Final = _PROXY_MaxParallelRequestsHandler(
internal_usage_cache=InternalUsageCache(DualCache()), time_provider=lambda: now
)
expected_reset: Final = (now + timedelta(seconds=handler.window_size)).strftime("%Y-%m-%d %H:%M:%S UTC")
over_limit: Final[RateLimitResponse] = {
"overall_code": "OVER_LIMIT",
"statuses": [
{
"code": "OVER_LIMIT",
"descriptor_key": "api_key",
"limit_remaining": 0,
"rate_limit_type": "requests",
"current_limit": 2,
}
],
}
with pytest.raises(ProxyRateLimitError) as exc_info:
handler._handle_rate_limit_error(
response=over_limit,
descriptors=[{"key": "api_key", "value": "sk-test", "rate_limit": None}],
requested_model="gpt-4o-mini",
)
assert exc_info.value.status_code == 429
assert exc_info.value.headers == {
"retry-after": str(handler.window_size),
"rate_limit_type": "requests",
"reset_at": expected_reset,
}
assert exc_info.value.detail == (
"Rate limit exceeded for api_key: sk-test. Limit type: requests. "
f"Current limit: 2, Remaining: 0. Limit resets at: {expected_reset}"
)