Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/auth/auth_exception_handler.py: 72%

74 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1""" 

2Handles Authentication Errors 

3""" 

4 

5import logging 

6from collections.abc import Mapping 

7from typing import TYPE_CHECKING, Any, Final 

8 

9from fastapi import HTTPException, Request, status 

10 

11import litellm 

12from litellm._logging import verbose_proxy_logger, verbose_proxy_stdout_logger 

13from litellm.constants import EMPTY_MAPPING 

14from litellm.integrations.otel.runtime import seed_request_identity 

15from litellm.litellm_core_utils.core_helpers import is_expected_client_error 

16from litellm.proxy._types import ( 

17 LitellmUserRoles, 

18 ModelAccessDeniedProxyException, 

19 ProxyErrorTypes, 

20 ProxyException, 

21 UserAPIKeyAuth, 

22) 

23from litellm.proxy.auth.auth_utils import ( 

24 _get_request_ip_address, 

25 is_invalid_virtual_key_error, 

26 mark_invalid_virtual_key_error, 

27 normalize_request_route, 

28) 

29from litellm.proxy.auth.model_access_denied import ModelAccessDeniedHTTPException 

30from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler 

31from litellm.types.services import ServiceTypes 

32 

33# Sentinel user_id for the synthetic UserAPIKeyAuth issued during a DB 

34# outage when allow_requests_on_db_unavailable is True. Downstream 

35# enforcement can key off this value; it must never collide with a real 

36# user_id. 

37DB_UNAVAILABLE_FALLBACK_USER_ID: Final = "__db_unavailable_fallback__" 

38 

39if TYPE_CHECKING: 39 ↛ 40line 39 didn't jump to line 40 because the condition on line 39 was never true

40 from opentelemetry.trace import Span as _Span 

41 

42 Span = _Span | Any 

43else: 

44 Span = Any 

45 

46 

47def _as_proxy_exception(e: Exception) -> ProxyException: 

48 """Convert an authentication failure into the ProxyException the client receives.""" 

49 if isinstance(e, litellm.BudgetExceededError): 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true

50 return ProxyException( 

51 message=e.message, 

52 type=ProxyErrorTypes.budget_exceeded, 

53 param=None, 

54 code=getattr(e, "status_code", status.HTTP_429_TOO_MANY_REQUESTS), 

55 ) 

56 if isinstance(e, ModelAccessDeniedHTTPException): 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 return ModelAccessDeniedProxyException( 

58 message=str(e.detail), 

59 internal_message=e.internal_message, 

60 type=ProxyErrorTypes.auth_error, 

61 param="None", 

62 code=e.status_code, 

63 ) 

64 if isinstance(e, HTTPException): 

65 return ProxyException( 

66 message=getattr(e, "detail", f"Authentication Error({e})"), 

67 type=ProxyErrorTypes.auth_error, 

68 param=getattr(e, "param", "None"), 

69 code=getattr(e, "status_code", status.HTTP_401_UNAUTHORIZED), 

70 ) 

71 if isinstance(e, ProxyException): 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true

72 return e 

73 if PrismaDBExceptionHandler.is_database_service_unavailable_error(e): 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true

74 return PrismaDBExceptionHandler.service_unavailable_proxy_exception(e) 

75 return ProxyException( 

76 message="Authentication Error, " + str(e), 

77 type=ProxyErrorTypes.auth_error, 

78 param=getattr(e, "param", "None"), 

79 code=status.HTTP_401_UNAUTHORIZED, 

80 ) 

81 

82 

83def _get_user_agent(request: Request) -> str | None: 

84 if "headers" not in request.scope: 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true

85 return None 

86 return request.headers.get("user-agent") 

87 

88 

89def _with_client_context( 

90 request_data: dict[str, object], requester_ip: str | None, user_agent: str | None 

91) -> dict[str, object]: 

92 """Auth gate rejections are raised before `add_litellm_data_to_request` records the 

93 caller IP and User-Agent, so their failure logs would otherwise carry neither.""" 

94 key: Final = "litellm_metadata" if "litellm_metadata" in request_data else "metadata" 

95 metadata: Final = request_data.get(key) 

96 base: Final[Mapping[str, object]] = metadata if isinstance(metadata, Mapping) else EMPTY_MAPPING 

97 stamped: Final = { 

98 name: value 

99 for name, value in (("requester_ip_address", requester_ip), ("user_agent", user_agent)) 

100 if value and not base.get(name) 

101 } 

102 if not stamped: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 return request_data 

104 return {**request_data, key: {**base, **stamped}} # mutable-ok: logging needs dicts 

105 

106 

107class UserAPIKeyAuthExceptionHandler: 

108 @staticmethod 

109 async def _handle_authentication_error( 

110 e: Exception, 

111 request: Request, 

112 request_data: dict[str, object], 

113 route: str, 

114 parent_otel_span: Span | None, 

115 api_key: str, 

116 resolved_identity: UserAPIKeyAuth | None = None, 

117 ) -> UserAPIKeyAuth: 

118 """ 

119 Handles Connection Errors when reading a Virtual Key from LiteLLM DB 

120 Use this if you don't want failed DB queries to block LLM API reqiests 

121 

122 Reliability scenarios this covers: 

123 - DB is down and having an outage 

124 - Unable to read / recover a key from the DB 

125 

126 Returns: 

127 - UserAPIKeyAuth: If general_settings.allow_requests_on_db_unavailable is True 

128 

129 Raises: 

130 - Original Exception in all other cases 

131 """ 

132 from litellm.proxy.proxy_server import ( 

133 general_settings, 

134 proxy_logging_obj, 

135 ) 

136 

137 if ( 137 ↛ 142line 137 didn't jump to line 142 because the condition on line 137 was never true

138 PrismaDBExceptionHandler.should_allow_request_on_db_unavailable() 

139 and PrismaDBExceptionHandler.is_database_connection_error(e) 

140 ): 

141 # log this as a DB failure on prometheus 

142 proxy_logging_obj.service_logging_obj.service_failure_hook( 

143 service=ServiceTypes.DB, 

144 call_type="get_key_object", 

145 error=e, 

146 duration=0.0, 

147 ) 

148 

149 # Non-admin restricted token so a DB outage cannot escalate 

150 # an anonymous caller to proxy-admin privileges. 

151 verbose_proxy_logger.warning( 

152 "Auth: DB unavailable — issuing restricted INTERNAL_USER " 

153 "fallback token (allow_requests_on_db_unavailable=True)" 

154 ) 

155 return UserAPIKeyAuth( 

156 key_name="failed-to-connect-to-db", 

157 token="failed-to-connect-to-db", 

158 user_id=DB_UNAVAILABLE_FALLBACK_USER_ID, 

159 user_role=LitellmUserRoles.INTERNAL_USER, 

160 request_route=route, 

161 ) 

162 else: 

163 # raise the exception to the caller 

164 requester_ip: Final = _get_request_ip_address( 

165 request=request, 

166 use_x_forwarded_for=general_settings.get("use_x_forwarded_for") is True, 

167 ) 

168 user_agent: Final = _get_user_agent(request) 

169 

170 # Log authentication failures before identity seeding and callbacks, so the log 

171 # survives a raising callback pipeline. Classify and route malformed virtual-key 

172 # rejections to WARNING on stdout (suppressible via LITELLM_LOG=ERROR). 

173 log_extra: Final = {"requester_ip": requester_ip} 

174 is_invalid_virtual_key: Final = is_invalid_virtual_key_error(e) 

175 is_quiet_log: Final = is_invalid_virtual_key and not litellm.log_client_error_tracebacks 

176 logger: Final = verbose_proxy_stdout_logger if is_quiet_log else verbose_proxy_logger 

177 logger.log( 

178 logging.WARNING if is_quiet_log else logging.ERROR, 

179 "litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - %s\nRequester IP Address:%s", 

180 e, 

181 requester_ip, 

182 exc_info=True if litellm.log_client_error_tracebacks or not is_expected_client_error(e) else None, 

183 extra=log_extra, 

184 ) 

185 

186 # Log this exception to OTEL, Datadog etc. Reuse the identity resolved 

187 # before the failure (team alias/id, metadata, user) so the failed span 

188 # is labeled — a fresh UserAPIKeyAuth here would drop everything auth had 

189 # already looked up (e.g. an expired key whose team/user is known). Copy 

190 # so the handler is side-effect-free for the caller's identity object. 

191 user_api_key_dict = resolved_identity.model_copy() if resolved_identity is not None else UserAPIKeyAuth() 

192 user_api_key_dict.parent_otel_span = parent_otel_span 

193 user_api_key_dict.request_route = normalize_request_route(route) 

194 user_api_key_dict.api_key = user_api_key_dict.api_key or UserAPIKeyAuth(api_key=api_key).api_key 

195 

196 # Stamp identity onto the request's server span now, before the request 

197 # is rejected; the OTEL failure hooks don't touch the server span, so 

198 # without this the failed trace would carry no team/key attributes. 

199 seed_request_identity( 

200 user_api_key_dict, 

201 model=request_data.get("model"), 

202 ) 

203 

204 # Budget checks live in tenant-scoped helpers (key / team / org / tag) 

205 # that don't see the request model, so the BudgetExceededError they 

206 # raise carries `llm_provider=""`. Resolve it here off `request_data` 

207 # so custom-callback consumers reading StandardLoggingPayload get 

208 # the same `llm_provider` attribution as for RPM/TPM 429s. 

209 if isinstance(e, litellm.BudgetExceededError) and not e.llm_provider: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 from litellm.proxy.hooks.rate_limiter_utils import ( 

211 resolve_llm_provider_for_rate_limit, 

212 ) 

213 

214 budget_model: Final = request_data.get("model") 

215 _, e.llm_provider = resolve_llm_provider_for_rate_limit( 

216 budget_model if isinstance(budget_model, str) else None 

217 ) 

218 

219 # Allow callbacks to transform the error response 

220 transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook( 

221 request_data=_with_client_context(request_data, requester_ip, user_agent), 

222 original_exception=e, 

223 user_api_key_dict=user_api_key_dict, 

224 error_type=ProxyErrorTypes.auth_error, 

225 route=route, 

226 ) 

227 # Use transformed exception if callback returned one, otherwise use original 

228 if transformed_exception is not None: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 e = transformed_exception 

230 

231 final_exception: Final = mark_invalid_virtual_key_error(_as_proxy_exception(e), is_invalid_virtual_key) 

232 # If a quiet-logged malformed-key transform yields non-401, escalate to ERROR 

233 if is_quiet_log and str(final_exception.code) != str(status.HTTP_401_UNAUTHORIZED): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 verbose_proxy_logger.error( 

235 "litellm.proxy.proxy_server.user_api_key_auth(): Exception occured - %s\nRequester IP Address:%s", 

236 final_exception, 

237 requester_ip, 

238 extra=log_extra, 

239 ) 

240 raise final_exception