fix(proxy)!: label shared-handler errors the way the route catch-alls do (#39555)

* fix(proxy): name shared-handler errors by the status they answer

handle_exception_on_proxy pinned type=internal_server_error on every
exception it wrapped, so a management route rejecting a caller's own
argument answered {"type":"internal_server_error","code":"400"} and an
SDK branching on the type read its own bad request as a gateway outage.

Derive the type from the status the response actually carries, keeping
a type the exception already names.

* test(proxy): pin a 500 for exceptions whose status is None

* fix(proxy): keep internal_server_error for timeouts and 5xx, redact hosts in shared handler errors

Provider 5xx and timeouts keep internal_server_error so retry loops still retry them,
the shared handler redacts internal hosts from raw exception messages, and /key/list
routes its HTTPException through the shared handler instead of its own copy of the
old label.

* fix(proxy): label shared-handler errors like the chat route, pick the status before the type, redact only gateway failures

* fix(proxy): pass shared-handler error messages through as on main

Redacting timeout and 5xx messages changed what every shared-handler route
returns, which relabeling the error type does not need, so messages go back
to the verbatim text main sends.

* fix(proxy): redact only the status-less failure main never sent

* fix(proxy): answer a generic 500 for a status-less failure and expect auth_error on the /user/new 403

A failure with no status now answers "Internal server error" instead of its own text, since that text can
name a guardrail's private host and pattern redaction misses cluster DNS names and IPv6 addresses. The
/user/new integration test now expects the auth_error type main's route catch-alls give a 403 the proxy raises

* fix(proxy): answer only a 4xx or 5xx carried status and fall back to the route default otherwise

A numeric-string status outside 400 to 599 made /v1/messages, images, rerank and the OpenAI pass-through answer HTTP 200 with an error body or drop the connection, and non-ASCII digits or a 5000-digit string slipped through isdecimal. error_status_code now accepts an int or a three-digit ASCII string in 400 to 599, the range the chat route already enforces

* refactor(proxy): parse a three-digit string status without recursing
This commit is contained in:
Mateo Wang 2026-10-30 06:58:05 -07:00 • committed by GitHub
parent b71491c6c9
commit cbfb1abdb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 311 additions and 53 deletions

View file

@ -28,6 +28,8 @@ _OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
_AUTH_FAILURE_STATUS_CODES: Final = frozenset({status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN})
_HTTP_ERROR_STATUSES: Final = range(status.HTTP_400_BAD_REQUEST, 600)
_ERROR_BODY: Final = TypeAdapter(dict[str, object])
_FAILOVER_SQLSTATES: Final = frozenset({"25006", "57P01", "57P02", "57P03"})
@ -47,13 +49,26 @@ def attribute_of(value: object, name: str, default: object = None) -> object:
def error_status_code(exc: object, default: int) -> int:
"""The HTTP status an exception carries as ``status_code`` or, the way ``ProxyException``
stores it, as a stringified ``code``; ``default`` when it carries neither."""
carried: Final = attribute_of(exc, "status_code")
if isinstance(carried, int) and not isinstance(carried, bool):
"""The 4xx or 5xx HTTP status an exception carries as ``status_code`` (an int or a decimal string) or,
the way ``ProxyException`` stores it, as a stringified ``code``; ``default`` when it carries neither."""
carried: Final = _http_error_status(attribute_of(exc, "status_code"))
if carried is not None:
return carried
stringified: Final = attribute_of(exc, "code")
return int(stringified) if isinstance(stringified, str) and stringified.isdecimal() else default
from_code: Final = _http_error_status(stringified) if isinstance(stringified, str) else None
return default if from_code is None else from_code
def _http_error_status(value: object) -> int | None:
match value:
case bool():
return None
case int() if value in _HTTP_ERROR_STATUSES:
return value
case str() if len(value) == 3 and value.isascii() and value.isdigit() and int(value) in _HTTP_ERROR_STATUSES:
return int(value)
case _:
return None
def openai_error_type(exc: object, status_code: int) -> str:
@ -105,9 +120,9 @@ def proxy_exception_for(exc: Exception, default_status_code: int) -> ProxyExcept
code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
case HTTPException(status_code=status_code, detail=detail):
return _openai_shaped_proxy_exception(exc, str(detail), status_code)
return openai_shaped_proxy_exception(exc, str(detail), status_code)
case _:
return _openai_shaped_proxy_exception(
return openai_shaped_proxy_exception(
exc, str(exc) or type(exc).__name__, error_status_code(exc, default_status_code)
)
@ -176,14 +191,19 @@ def _route_failure_error_type(exc: Exception, raised_status: int, answered_statu
return openai_error_type(exc, answered_status)
def _openai_shaped_proxy_exception(exc: Exception, message: str, status_code: int) -> ProxyException:
def openai_shaped_proxy_exception(exc: Exception, message: str, status_code: int) -> ProxyException:
answered_status: Final = answered_status_code(message, status_code)
return ProxyException(
message=message,
type=_catch_all_error_type(exc, answered_status),
param=openai_error_param(exc),
code=answered_status,
)
return _proxy_exception(exc, message, answered_status, _catch_all_error_type(exc, answered_status))
def status_typed_proxy_exception(exc: HTTPException) -> ProxyException:
message: Final = str(exc.detail)
answered_status: Final = answered_status_code(message, exc.status_code)
return _proxy_exception(exc, message, answered_status, openai_error_type(exc, answered_status))
def _proxy_exception(exc: Exception, message: str, answered_status: int, error_type: str) -> ProxyException:
return ProxyException(message=message, type=error_type, param=openai_error_param(exc), code=answered_status)
def _catch_all_error_type(exc: Exception, status_code: int) -> str:

View file

@ -22,6 +22,7 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.callback_utils import CALLBACK_VAR_ENCRYPTED_PREFIX
from litellm.proxy.common_utils.openai_error_payload import status_typed_proxy_exception
from litellm.proxy.common_utils.path_utils import is_within, safe_join
from litellm.proxy.guardrails.content_filter_data import CATEGORIES_DIR, DATA_ROOTS, category_dirs, find_category_file
from litellm.proxy.guardrails.guardrail_hooks.custom_code.bounded_execution import (
@ -2533,7 +2534,7 @@ async def apply_guardrail(
e = transformed_exception
except Exception:
verbose_proxy_logger.exception("apply_guardrail: post_call_failure_hook failed")
raise handle_exception_on_proxy(e)
raise handle_exception_on_proxy(status_typed_proxy_exception(e) if isinstance(e, HTTPException) else e)
# Success logging outside except so a hook error never triggers failure handlers.
response = await _emit_guardrail_success_logs(

View file

@ -85,8 +85,8 @@ from litellm.proxy._types import (
from litellm.proxy.bug_report_config import build_proxy_bug_report
from litellm.proxy.common_utils.openai_error_payload import (
attribute_of,
litellm_call_id_headers,
openai_error_param,
error_status_code,
openai_shaped_proxy_exception,
with_litellm_call_id,
)
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
@ -9186,39 +9186,30 @@ def _recreate_writer_on_read_only_transaction(prisma_client: "PrismaClient | Non
asyncio.create_task(prisma_client.recreate_read_only_writer(reason="postgres_read_only_transaction"))
def _client_message(e: Exception) -> str:
status_is_none: Final = hasattr(e, "status_code") and attribute_of(e, "status_code") is None
return "Internal server error" if status_is_none else strip_bug_report_notice(str(e))
def handle_exception_on_proxy(e: Exception, litellm_call_id: str | None = None) -> ProxyException:
"""
Returns an Exception as ProxyException, this ensures all exceptions are OpenAI API compatible
"""
from fastapi import status
verbose_proxy_logger.exception("Exception: %s", e)
if PrismaDBExceptionHandler.is_read_only_transaction_error(e):
from litellm.proxy.proxy_server import prisma_client
_recreate_writer_on_read_only_transaction(prisma_client)
headers: Final = litellm_call_id_headers(litellm_call_id)
if isinstance(e, HTTPException):
return ProxyException(
message=getattr(e, "detail", f"error({e})"),
type=ProxyErrorTypes.internal_server_error,
param=openai_error_param(e),
headers=headers,
code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
)
elif isinstance(e, ProxyException):
if isinstance(e, ProxyException):
return with_litellm_call_id(e, litellm_call_id)
_status_code: Final = getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
if isinstance(e, HTTPException):
return with_litellm_call_id(openai_shaped_proxy_exception(e, str(e.detail), e.status_code), litellm_call_id)
if should_report_bug(e):
verbose_proxy_logger.error(bug_report_notice(build_proxy_bug_report(e)))
return ProxyException(
message=strip_bug_report_notice(str(e)),
type=ProxyErrorTypes.internal_server_error,
param=openai_error_param(e),
headers=headers,
code=_status_code,
)
message: Final = _client_message(e)
status_code: Final = error_status_code(e, status.HTTP_500_INTERNAL_SERVER_ERROR)
return with_litellm_call_id(openai_shaped_proxy_exception(e, message, status_code), litellm_call_id)
def require_enterprise_license(feature: str | None = None) -> None:

View file

@ -432,7 +432,7 @@ def test_team_guardrail_submissions_require_admin_approval(
f"Guardrail '{first_name}' not found. "
"Please ensure the guardrail is configured in your LiteLLM proxy."
),
"type": "internal_server_error",
"type": "invalid_request_error",
"param": None,
"code": "404",
}
@ -481,7 +481,7 @@ def test_team_guardrail_submissions_require_admin_approval(
f"Guardrail '{second_name}' not found. "
"Please ensure the guardrail is configured in your LiteLLM proxy."
),
"type": "internal_server_error",
"type": "invalid_request_error",
"param": None,
"code": "404",
}

View file

@ -279,7 +279,7 @@ def test_new_user_with_admin_role_requires_proxy_admin(gateway: Gateway, role: s
assert refused.json() == {
"error": {
"message": f"Only proxy admins can create administrative users (proxy_admin, proxy_admin_viewer). Attempted to create user with role: {role}. Your role: internal_user",
"type": "internal_server_error",
"type": "auth_error",
"param": None,
"code": "403",
}

View file

@ -170,7 +170,7 @@ def test_user_delete_revokes_keys_and_memberships(gateway: Gateway, peer: Gatewa
assert info.json() == {
"error": {
"message": f"User {user_id} not found",
"type": "internal_server_error",
"type": "invalid_request_error",
"param": None,
"code": "404",
}
@ -337,7 +337,7 @@ def test_scim_user_delete_revokes_keys(gateway: Gateway, peer: Gateway, monkeypa
expected_info: Final = {
"error": {
"message": f"User {user_id} not found",
"type": "internal_server_error",
"type": "invalid_request_error",
"param": None,
"code": "404",
}

View file

@ -156,7 +156,7 @@ def test_apply_guardrail_returns_a_block_error_for_generic_guardrails(gateway: G
assert error.model_dump() == {
"error": {
"message": _BLOCK_REASON,
"type": "internal_server_error",
"type": "invalid_request_error",
"param": None,
"code": 400,
}

View file

@ -111,10 +111,13 @@ def test_the_default_status_stands_when_the_exception_carries_none(default: int)
assert error_status_code(ValueError("boom"), default) == default
@pytest.mark.parametrize("carried_status", [True, False, "429", None, 429.0])
def test_a_non_int_carried_status_falls_back_to_the_default(carried_status: object):
"""True is an int in Python but not an HTTP status, and a stringified one would break
every caller that compares the code numerically."""
@pytest.mark.parametrize(
"carried_status",
[True, False, "slow down", "", None, 429.0, 200, 1301, 0, "200", "1301", "0", "0429", " 429", "\u0664\u0662\u0669", "4" * 5000],
)
def test_a_carried_status_that_is_not_an_error_status_falls_back_to_the_default(carried_status: object):
"""True is an int in Python but not an HTTP status, and answering a 200 or a 1301 either reads
as success or makes uvicorn drop the connection."""
class _Carrier(Exception):
status_code = carried_status
@ -122,6 +125,22 @@ def test_a_non_int_carried_status_falls_back_to_the_default(carried_status: obje
assert error_status_code(_Carrier("boom"), 500) == 500
def test_a_status_carried_as_a_decimal_string_is_answered_as_that_status():
"""Provider stream-error chunks pass the vendor's code through as a string ``status_code``,
and the shared handler used to answer it as is, so dropping it turned a rate limit into a 500."""
class _Carrier(Exception):
status_code = "429"
assert error_status_code(_Carrier("slow down"), 500) == 429
assert proxy_exception_for(_Carrier("slow down"), default_status_code=500).to_dict() == {
"message": "slow down",
"type": "rate_limit_error",
"param": None,
"code": "429",
}
def test_a_proxy_exception_keeps_the_status_it_was_raised_with():
"""ProxyException stores its status as the string ``code`` rather than ``status_code``,
so a route tail that rewraps one used to answer a 4xx rejection as a 500."""
@ -130,8 +149,8 @@ def test_a_proxy_exception_keeps_the_status_it_was_raised_with():
assert error_status_code(rejection, 500) == 400
@pytest.mark.parametrize("carried_code", [None, "None", "", "rate_limited", "4xx", 404])
def test_a_code_that_is_not_a_decimal_string_falls_back_to_the_default(carried_code: object):
@pytest.mark.parametrize("carried_code", [None, "None", "", "rate_limited", "4xx", 404, "200", "1301"])
def test_a_code_that_is_not_a_stringified_error_status_falls_back_to_the_default(carried_code: object):
"""Only ProxyException's stringified status is a status; ``code`` on anything else
(OpenAI's ``invalid_api_key``, a stray int) says nothing about the HTTP answer."""

View file

@ -3277,6 +3277,23 @@ class TestCredentialDisplayName:
assert listed["openai-prod"]["credential_values"]["api_key"] != "sk-old"
def test_looking_up_a_missing_credential_is_labelled_a_client_error_not_a_gateway_failure(
restore_credential_list: None,
):
"""Naming a credential that does not exist is the caller's own mistake, and the shared
handler used to label every one of these internal_server_error, so an SDK branching on
the type read a 404 it can never retry past as a gateway outage and backed off forever."""
response: Final = _call_as("GET", "/credentials/by_name/definitely-not-there")
assert response.status_code == 404, f"lookup answered {response.status_code}: {response.text}"
assert response.json()["error"] == {
"message": "Credential not found. Got credential name: definitely-not-there",
"type": "invalid_request_error",
"param": None,
"code": "404",
}
def test_get_credential_by_model_masks_the_deployment_credentials(monkeypatch):
import asyncio

View file

@ -13,7 +13,7 @@ import yaml
from fastapi import HTTPException
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth
from litellm.proxy.guardrails.guardrail_endpoints import (
CreateGuardrailRequest,
ListGuardrailsResponse,
@ -2198,6 +2198,39 @@ def _patch_apply_guardrail_env(mocker, guardrail_result):
return mock_guardrail
@pytest.mark.asyncio
@pytest.mark.parametrize(
("vendor_status", "expected_type"),
[(401, "authentication_error"), (403, "permission_error")],
)
async def test_a_guardrail_vendors_rejection_is_labelled_the_way_chat_labels_it(
mocker, vendor_status: int, expected_type: str
):
"""A vendor refusing the guardrail's own credentials is not the caller's key being rejected,
so it must not read as the proxy's auth_error the way a 401 the proxy raises does."""
mock_guardrail: Final = _patch_apply_guardrail_env(mocker, None)
mock_guardrail.apply_guardrail.side_effect = HTTPException(
status_code=vendor_status, detail="vendor refused the request"
)
mocker.patch(
"litellm.proxy.proxy_server.proxy_logging_obj.post_call_failure_hook", AsyncMock(return_value=None)
)
with pytest.raises(ProxyException) as raised:
await apply_guardrail(
fastapi_request=mocker.Mock(),
request=ApplyGuardrailRequest(guardrail_name="test-guardrail", text="hello"),
user_api_key_dict=UserAPIKeyAuth(),
)
assert raised.value.to_dict() == {
"message": "vendor refused the request",
"type": expected_type,
"param": None,
"code": str(vendor_status),
}
@pytest.mark.asyncio
async def test_apply_guardrail_forwards_metadata_to_guardrail(mocker):
"""Client-supplied metadata must reach apply_guardrail via request_data so

View file

@ -18750,7 +18750,7 @@ async def test_list_keys_rejects_invalid_expires():
expires="expred",
)
assert exc_info.value.code == "400"
assert (exc_info.value.code, exc_info.value.type) == ("400", "invalid_request_error")
assert "Invalid expires value" in str(exc_info.value.message)

View file

@ -1,13 +1,20 @@
import asyncio
import json
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import httpx
import openai
import pytest
from fastapi import HTTPException
from prisma.errors import DataError
import litellm
from litellm.proxy._types import ProxyErrorTypes, ProxyException
from litellm.proxy.common_request_processing import proxy_exception_from_route_error
from litellm.proxy.common_utils.openai_error_payload import proxy_exception_for
from litellm.proxy.utils import get_error_message_str, handle_exception_on_proxy
from litellm.types.router import RouterErrors
def normalize(value):
@ -96,7 +103,7 @@ def test_handle_exception_on_proxy_happy_path_http_exception():
assert snapshot == {
"is_proxy_exception": True,
"message": "forbidden",
"type": ProxyErrorTypes.internal_server_error.value,
"type": "auth_error",
"code": "403",
}
@ -156,7 +163,7 @@ def test_handle_exception_on_proxy_uses_attached_status_code_when_present():
assert snapshot == {
"code": "418",
"message": "teapot",
"type": ProxyErrorTypes.internal_server_error.value,
"type": "invalid_request_error",
}
@ -176,6 +183,176 @@ def test_handle_exception_on_proxy_error_path_none_input_wraps_as_500():
}
@pytest.mark.parametrize(
"status_code, expected_type",
[
(400, "invalid_request_error"),
(401, "auth_error"),
(403, "auth_error"),
(404, "invalid_request_error"),
(408, "invalid_request_error"),
(409, "invalid_request_error"),
(422, "invalid_request_error"),
(429, "rate_limit_error"),
(500, ProxyErrorTypes.internal_server_error.value),
(503, ProxyErrorTypes.internal_server_error.value),
],
)
def test_handle_exception_on_proxy_labels_an_http_exception_by_the_status_it_answers(
status_code: int, expected_type: str
):
"""A management route that rejects a caller's own argument answers 400, and calling that
internal_server_error tells the caller's retry loop the gateway is down when nothing but
their request is wrong."""
result: Final = handle_exception_on_proxy(HTTPException(status_code=status_code, detail="boom"))
assert (result.code, result.type) == (str(status_code), expected_type)
def _exception_carrying_status(carried: int | None, message: str = "boom") -> Exception:
class _CarriedStatus(Exception):
status_code = carried
return _CarriedStatus(message)
@pytest.mark.parametrize(
"status_code, expected_type",
[
(400, "invalid_request_error"),
(404, "invalid_request_error"),
(408, "invalid_request_error"),
(429, "rate_limit_error"),
(500, ProxyErrorTypes.internal_server_error.value),
],
)
def test_handle_exception_on_proxy_labels_a_carried_status_the_same_way(status_code: int, expected_type: str):
"""Routes also reach this handler with plain exceptions carrying their own status, and
those must be named by that status rather than all landing on the 500 label."""
result: Final = handle_exception_on_proxy(_exception_carrying_status(status_code))
assert (result.code, result.type) == (str(status_code), expected_type)
def test_handle_exception_on_proxy_keeps_the_type_the_exception_already_names():
"""A rate limit litellm raised already names itself, and overwriting that with the status
map's guess would drop what the client branches on."""
result: Final = handle_exception_on_proxy(
litellm.RateLimitError(message="slow down", llm_provider="openai", model="gpt-4o")
)
assert (result.code, result.type) == ("429", "throttling_error")
@pytest.mark.parametrize(
"exc",
[
openai.InternalServerError(
message="upstream down",
response=httpx.Response(500, request=httpx.Request("GET", "https://api.openai.com/v1/batches/b")),
body={"type": "server_error"},
),
litellm.Timeout(message="took too long", model="gpt-6.1-sol", llm_provider="openai"),
litellm.RateLimitError(message="slow down", llm_provider="openai", model="gpt-6.1-sol"),
litellm.AuthenticationError(message="bad key", llm_provider="openai", model="gpt-6.1-sol"),
litellm.BadRequestError(message="bad input", model="gpt-6.1-sol", llm_provider="openai"),
HTTPException(status_code=404, detail="no such batch"),
],
ids=["provider_5xx_naming_its_own_type", "timeout", "rate_limit", "bad_key", "bad_request", "http_404"],
)
def test_handle_exception_on_proxy_labels_a_failure_the_way_the_chat_route_does(exc: Exception):
"""Batch, file, and management routes answer through this handler while chat answers through the route
error path, and one client calling both must read the same type for the same failure."""
shared: Final = handle_exception_on_proxy(exc)
chat: Final = proxy_exception_from_route_error(exc)
assert (shared.code, shared.type) == (chat.code, chat.type)
@pytest.mark.parametrize(
"exc",
[
HTTPException(status_code=400, detail="bad budget_duration"),
HTTPException(status_code=401, detail="not allowed"),
HTTPException(status_code=403, detail="admin only"),
HTTPException(status_code=404, detail="no such team"),
litellm.AuthenticationError(message="bad key", llm_provider="openai", model="gpt-6.1-sol"),
ValueError(f"{RouterErrors.no_deployments_with_tag_routing.value}=['paid']"),
_exception_carrying_status(409, "duplicate credential"),
],
ids=["http_400", "http_401", "http_403", "http_404", "provider_401", "tag_denial", "carried_409"],
)
def test_handle_exception_on_proxy_labels_a_failure_the_way_route_catch_alls_do(exc: Exception):
"""Management routes answer some failures through their own catch-all and the rest through this handler,
so a client reading /key/list and /key/generate must see the same type and status for the same failure."""
shared: Final = handle_exception_on_proxy(exc)
catch_all: Final = proxy_exception_for(exc, default_status_code=500)
assert (shared.code, shared.type, shared.message) == (catch_all.code, catch_all.type, catch_all.message)
@pytest.mark.parametrize(
"message, expected",
[
(f"{RouterErrors.no_deployments_with_provider_budget_routing.value}, try later", ("429", "rate_limit_error")),
(f"{RouterErrors.no_deployments_with_tag_routing.value}=['paid']", ("401", "auth_error")),
],
ids=["no_deployment_left", "no_deployment_for_tags"],
)
def test_handle_exception_on_proxy_names_a_router_failure_by_the_status_it_answers(
message: str, expected: tuple[str, str]
):
"""A router failure that carries no status answers the 429 or 401 its message maps to, and a 429 labelled
internal_server_error reads as an outage to a client that branches on the type."""
result: Final = handle_exception_on_proxy(ValueError(message))
assert (result.code, result.type) == expected
def test_handle_exception_on_proxy_keeps_the_path_a_rejected_request_names():
"""A provider rejecting a batch input names the caller's own bucket path, and redacting the /tmp part of it
leaves the caller nothing to fix their request with."""
result: Final = handle_exception_on_proxy(
litellm.BadRequestError(
message="Invalid input_file_id gs://my-bucket/tmp/batch_input.jsonl",
model="gemini-3.1-pro-preview",
llm_provider="vertex_ai",
)
)
assert result.code == "400"
assert "gs://my-bucket/tmp/batch_input.jsonl" in result.message
def test_handle_exception_on_proxy_keeps_the_message_an_exception_with_a_status_carries():
"""Main sends a status-bearing failure's message verbatim, and relabeling its type must not change that text."""
result: Final = handle_exception_on_proxy(_exception_carrying_status(503, "Cannot connect to host 10.12.4.7:8443"))
assert (result.code, result.message) == ("503", "Cannot connect to host 10.12.4.7:8443")
@pytest.mark.parametrize(
"message",
[
"boom",
"Cannot connect to host 10.12.4.7:8443",
"Cannot connect to host guardrail.internal.svc.cluster.local:443",
"Cannot connect to host [fd00::12]:8443",
],
ids=["plain", "ipv4", "cluster_dns", "ipv6"],
)
def test_handle_exception_on_proxy_answers_a_generic_500_for_an_exception_whose_status_is_none(message: str):
"""A guardrail whose upstream refuses the connection raises with status_code=None, which crashed the handler
into a 503 blaming the auth database, and its text names private hosts no virtual key was ever sent."""
result: Final = handle_exception_on_proxy(_exception_carrying_status(None, message))
assert (result.code, result.type, result.message) == (
"500",
ProxyErrorTypes.internal_server_error.value,
"Internal server error",
)
@pytest.mark.parametrize(
"exc",
[