diff --git a/litellm/proxy/auth/auth_exception_handler.py b/litellm/proxy/auth/auth_exception_handler.py index 83f18173182..63da11b5da1 100644 --- a/litellm/proxy/auth/auth_exception_handler.py +++ b/litellm/proxy/auth/auth_exception_handler.py @@ -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 diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 4be2a185ce4..6588d2803ba 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -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 diff --git a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py index 11e6f483e35..993dbcd30a2 100644 --- a/tests/test_litellm/proxy/auth/test_auth_exception_handler.py +++ b/tests/test_litellm/proxy/auth/test_auth_exception_handler.py @@ -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 diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 63b61954cf6..20fb9700d65 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -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(