fix: honor environment login throttle settings

This commit is contained in:
Yucheng Zhu 2026-09-01 14:35:05 -07:00
parent e9166019a5
commit 429a4f2135
3 changed files with 67 additions and 9 deletions

View file

@ -91,12 +91,19 @@ class FailureCounts(NamedTuple):
def _int_setting(name: str, value: object, default: int, minimum: int) -> int:
if value is None:
return default
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
if isinstance(value, str):
try:
parsed: Final = int(value.strip())
except ValueError:
parsed = value
else:
parsed = value
if isinstance(parsed, bool) or not isinstance(parsed, int) or parsed < minimum:
verbose_proxy_logger.warning(
"general_settings.%s=%r is not an integer >= %s; using the default of %s", name, value, minimum, default
)
return default
return value
return parsed
def _as_count(cached: object) -> int:

View file

@ -737,7 +737,7 @@ async def test_a_correct_admin_password_is_accepted_while_blocked(monkeypatch):
await _guess(throttle)
assert still_blocked.value.code == "429", "a wrong password is still refused"
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch(
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch( # test-quality-ok: success mints a UI key and persists the user; faked so no DB is needed
"litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"})
):
result = await _guess(throttle, password="right")
@ -780,7 +780,7 @@ async def test_a_successful_sign_in_clears_the_bucket(monkeypatch):
with pytest.raises(ProxyException):
await _guess(throttle)
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch(
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch( # test-quality-ok: success mints a UI key and persists the user; faked so no DB is needed
"litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"})
):
await _guess(throttle, password="right")
@ -859,7 +859,7 @@ async def test_both_credential_rejections_are_indistinguishable(monkeypatch):
fake_user.password = "scrypt:fake"
repo = MagicMock()
repo.return_value.table.find_first = AsyncMock(return_value=fake_user)
with patch("litellm.proxy.auth.login_utils.UserRepository", repo), patch(
with patch("litellm.proxy.auth.login_utils.UserRepository", repo), patch( # test-quality-ok: reaches the known-DB-user branch without a database
"litellm.proxy.auth.login_utils.verify_password", return_value=False
):
with pytest.raises(ProxyException) as known:
@ -893,7 +893,7 @@ async def test_a_user_with_no_password_set_does_not_consume_the_budget(monkeypat
repo = MagicMock()
repo.return_value.table.find_first = AsyncMock(return_value=passwordless)
with patch("litellm.proxy.auth.login_utils.UserRepository", repo):
with patch("litellm.proxy.auth.login_utils.UserRepository", repo): # test-quality-ok: reaches the passwordless-DB-user branch without a database
for _ in range(5):
with pytest.raises(ProxyException) as exc:
await authenticate_user(
@ -934,7 +934,7 @@ async def test_a_wrong_password_for_a_known_user_also_counts(monkeypatch):
throttle=throttle,
)
with patch("litellm.proxy.auth.login_utils.UserRepository", repo), patch(
with patch("litellm.proxy.auth.login_utils.UserRepository", repo), patch( # test-quality-ok: reaches the known-DB-user branch without a database
"litellm.proxy.auth.login_utils.verify_password", return_value=False
):
for _ in range(3):
@ -1035,7 +1035,7 @@ async def test_a_successful_sign_in_leaves_the_source_counter_alone(monkeypatch)
with pytest.raises(ProxyException):
await _guess(throttle)
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch(
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch( # test-quality-ok: success mints a UI key and persists the user; faked so no DB is needed
"litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"})
):
await _guess(throttle, password="right")
@ -1227,6 +1227,57 @@ async def test_counters_do_not_share_the_key_authentication_cache(monkeypatch):
)
def test_settings_that_arrive_as_environment_strings_are_honored(monkeypatch):
"""An `os.environ/VAR` reference in general_settings resolves to a string, not an int.
Regression: a digit string fell back to the default with only a log line, so an operator
tightening the limits through environment substitution silently kept the stock ceilings.
"""
from litellm.proxy import proxy_server as ps
from litellm.proxy.auth.login_throttle import LoginThrottle
monkeypatch.setattr(
ps,
"general_settings",
{
"max_failed_login_attempts": "7",
"max_failed_login_attempts_per_source": " 70 ",
"failed_login_window_seconds": "not-a-number",
},
)
request = MagicMock()
request.headers = {}
request.client = MagicMock()
request.client.host = "1.2.3.4"
throttle = LoginThrottle.from_request(request)
assert throttle.max_attempts == 7
assert throttle.max_attempts_per_source == 70
assert throttle.window_seconds == 900, "garbage still falls back to the default"
def test_a_negative_or_boolean_setting_falls_back_to_the_default(monkeypatch):
"""A limit below one would refuse everyone; a bool is a typo, not a count."""
from litellm.proxy import proxy_server as ps
from litellm.proxy.auth.login_throttle import LoginThrottle
monkeypatch.setattr(
ps,
"general_settings",
{"max_failed_login_attempts": "-7", "max_failed_login_attempts_per_source": True},
)
request = MagicMock()
request.headers = {}
request.client = MagicMock()
request.client.host = "1.2.3.4"
throttle = LoginThrottle.from_request(request)
assert throttle.max_attempts == 50
assert throttle.max_attempts_per_source == 250
@pytest.mark.asyncio
async def test_a_refused_username_cannot_forge_log_lines(monkeypatch):
"""The username reaches a warning log, so it must not carry newlines or control bytes."""

View file

@ -574,7 +574,7 @@ def test_the_configured_admin_password_still_signs_in_while_refused(client, monk
assert [_json_login(client, "/v2/login") for _ in range(2)] == [401, 401]
assert _json_login(client, "/v2/login") == 429
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch(
with patch("litellm.proxy.auth.login_utils.user_update", new=AsyncMock()), patch( # test-quality-ok: success mints a UI key and persists the user; faked so no DB is needed
"litellm.proxy.auth.login_utils.generate_key_helper_fn", new=AsyncMock(return_value={"token": "sk-ui"})
):
assert _json_login(client, "/v2/login", password="right-password") == 200