fix(proxy): avoid exception logs for expected auth denials

This commit is contained in:
YankhangSIT 2026-06-15 19:16:40 +08:00
parent 2655d1dd5e
commit b8efc2d9a5
4 changed files with 75 additions and 19 deletions

View file

@ -34,6 +34,14 @@ else:
class UserAPIKeyAuthExceptionHandler:
@staticmethod
def _is_expected_authorization_denial(e: Exception) -> bool:
if isinstance(e, HTTPException):
return e.status_code == status.HTTP_403_FORBIDDEN
if isinstance(e, ProxyException):
return str(e.code) == str(status.HTTP_403_FORBIDDEN)
return False
@staticmethod
async def _handle_authentication_error(
e: Exception,
@ -94,13 +102,20 @@ class UserAPIKeyAuthExceptionHandler:
request=request,
use_x_forwarded_for=general_settings.get("use_x_forwarded_for", False),
)
verbose_proxy_logger.exception(
"litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - {}\nRequester IP Address:{}".format(
str(e),
requester_ip,
),
extra={"requester_ip": requester_ip},
log_message = "litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - {}\nRequester IP Address:{}".format(
str(e),
requester_ip,
)
if UserAPIKeyAuthExceptionHandler._is_expected_authorization_denial(e):
verbose_proxy_logger.warning(
log_message,
extra={"requester_ip": requester_ip},
)
else:
verbose_proxy_logger.exception(
log_message,
extra={"requester_ip": requester_ip},
)
# Log this exception to OTEL, Datadog etc. Reuse the identity resolved
# before the failure (team alias/id, metadata, user) so the failed span

View file

@ -229,7 +229,7 @@ class RouteChecks:
route (str): The route being accessed
Raises:
Exception: With user role and masked user_id information
HTTPException: With user role and masked user_id information
"""
user_role = "unknown"
user_id = "unknown"
@ -238,8 +238,9 @@ class RouteChecks:
user_id = user_obj.user_id or "unknown"
masked_user_id = RouteChecks._mask_user_id(user_id)
raise Exception(
f"Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}"
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route={route}. Your role={user_role}. Your user_id={masked_user_id}",
)
@staticmethod

View file

@ -1,12 +1,10 @@
import asyncio
import json
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException, Request, status
from prisma import errors as prisma_errors
from fastapi import HTTPException, status
from prisma.errors import (
ClientNotConnectedError,
DataError,
@ -24,7 +22,6 @@ sys.path.insert(
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
from litellm.proxy.auth.auth_exception_handler import UserAPIKeyAuthExceptionHandler
@ -272,6 +269,48 @@ async def test_handle_authentication_error_genuine_auth_failure_stays_401(auth_e
assert int(exc_info.value.code) == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_handle_authentication_error_expected_403_does_not_log_exception():
handler = UserAPIKeyAuthExceptionHandler()
with (
patch(
"litellm.proxy.auth.auth_exception_handler.verbose_proxy_logger.exception"
) as mock_exception_log,
patch(
"litellm.proxy.auth.auth_exception_handler.verbose_proxy_logger.warning"
) as mock_warning_log,
patch(
"litellm.proxy.auth.auth_exception_handler.seed_request_identity",
),
patch(
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook",
new_callable=AsyncMock,
return_value=None,
),
patch(
"litellm.proxy.proxy_server.general_settings",
{"allow_requests_on_db_unavailable": False},
),
):
with pytest.raises(ProxyException) as exc_info:
await handler._handle_authentication_error(
HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/config/list. Your role=internal_user.",
),
MagicMock(),
{},
"/config/list",
None,
"sk-non-admin",
)
assert int(exc_info.value.code) == status.HTTP_403_FORBIDDEN
mock_exception_log.assert_not_called()
mock_warning_log.assert_called_once()
@pytest.mark.asyncio
async def test_handle_authentication_error_budget_exceeded():
handler = UserAPIKeyAuthExceptionHandler()
@ -336,7 +375,7 @@ async def test_route_passed_to_post_call_failure_hook():
mock_span,
mock_api_key,
)
except Exception as e:
except Exception:
pass
asyncio.sleep(1)
# Verify post_call_failure_hook was called with the correct route

View file

@ -7,7 +7,7 @@ sys.path.insert(
) # Adds the parent directory to the system path
import pytest
from fastapi import HTTPException, Request
from fastapi import HTTPException, Request, status
from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.route_checks import RouteChecks
@ -34,7 +34,7 @@ def test_non_admin_config_update_route_rejected():
request.query_params = {}
# Test that calling /config/update route raises HTTPException with 403 status
with pytest.raises(Exception) as exc_info:
with pytest.raises(HTTPException) as exc_info:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=LitellmUserRoles.INTERNAL_USER.value,
@ -44,13 +44,14 @@ def test_non_admin_config_update_route_rejected():
request_data={},
)
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
# Verify the exception is raised with the correct message
assert (
"Only proxy admin can be used to generate, delete, update info for new keys/users/teams"
in str(exc_info.value)
in str(exc_info.value.detail)
)
assert "Route=/config/update" in str(exc_info.value)
assert "Your role=internal_user" in str(exc_info.value)
assert "Route=/config/update" in str(exc_info.value.detail)
assert "Your role=internal_user" in str(exc_info.value.detail)
@pytest.mark.parametrize(