litellm/tests/test_litellm/test_guardrail_exception_status_codes.py
robin-fiddler 99f2487d3b
fix(guardrails): return HTTP 400 instead of 500 for blocked requests (#27617)
* fix: reset org and tag budgets (#27326)

* reset org budgets

* reset tag budgets

---------

Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain>

* fix(ui): omit allowed_routes from key edit save when unchanged (#27553)

* fix(ui): omit allowed_routes from key edit save when unchanged

When a team admin opens Edit Settings on a key with key_type=AI APIs and
saves without changing anything, the UI re-sends the existing allowed_routes
value, which the backend's _check_allowed_routes_caller_permission gate
rejects for non-proxy-admins (LIT-2681).

Strip allowed_routes from the patch in handleSubmit when it deep-equals the
original keyData.allowed_routes. The backend treats absence as "leave alone,"
so no-op saves now succeed for non-admins. Admins explicitly editing the
field still send the new value.

* fix(ui): order-insensitive allowed_routes diff + cover null-original case

Address Greptile review:

- Switch the "is allowed_routes unchanged" check to a Set-based comparison so
  a server-side reorder of the array doesn't register as a user edit and
  re-trigger LIT-2681.
- Add two regression tests: (1) keyData.allowed_routes is null and the form
  is untouched — patch should strip the field; (2) server returned routes in
  a different order than the user originally entered — patch should still
  recognize the value as unchanged.

* chore(ui): strip ticket refs and tighten comments in key edit fix

- Remove internal-tracker references from in-code comments
- Tighten the WHY comment in handleSubmit to two lines
- Drop redundant test-block comments — test names already describe the case

* fix(ui): annotate Set<string> generic in allowed_routes diff to fix tsc

* fix(guardrails): return HTTP 400 instead of 500 for guardrail-blocked requests

GuardrailRaisedException and BlockedPiiEntityError both lacked a
status_code attribute.  When these exceptions reached the proxy
exception handler (getattr(e, 'status_code', 500)), the fallback
defaulted to HTTP 500 — making intentional guardrail blocks
indistinguishable from server errors and causing unnecessary client
retries.

Changes:
- Add status_code=400 (keyword-only) to GuardrailRaisedException
- Add status_code=400 (keyword-only) to BlockedPiiEntityError
- Update _is_guardrail_intervention() to recognize both exceptions
  so downstream loggers record 'guardrail_intervened' instead of
  'guardrail_failed_to_respond'
- Add 6 unit tests for default/custom status codes and getattr pattern
- Strengthen existing blocked-action test with status_code assertion

Fixes #24348

---------

Co-authored-by: Michael-RZ-Berri <michael@berri.ai>
Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain>
Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
Co-authored-by: Krrish Dholakia <krrish+github@berri.ai>
2026-05-18 18:47:40 +05:30

66 lines
2.1 KiB
Python

"""
Tests for guardrail exception status codes.
GuardrailRaisedException and BlockedPiiEntityError must carry
``status_code = 400`` so the proxy exception handler
(``getattr(e, "status_code", 500)``) returns HTTP 400 instead of 500
for intentional guardrail blocks.
"""
from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
class TestGuardrailRaisedExceptionStatusCode:
"""GuardrailRaisedException should default to status_code=400."""
def test_default_status_code(self):
exc = GuardrailRaisedException(
guardrail_name="test_guardrail",
message="blocked",
)
assert exc.status_code == 400
def test_custom_status_code(self):
exc = GuardrailRaisedException(
guardrail_name="test_guardrail",
message="rate limited",
status_code=429,
)
assert exc.status_code == 429
def test_getattr_fallback_resolves_to_400(self):
"""The proxy uses ``getattr(e, 'status_code', 500)`` — verify it
resolves to 400, not the 500 default."""
exc = GuardrailRaisedException(
guardrail_name="test_guardrail",
message="blocked",
)
assert getattr(exc, "status_code", 500) == 400
class TestBlockedPiiEntityErrorStatusCode:
"""BlockedPiiEntityError should default to status_code=400."""
def test_default_status_code(self):
exc = BlockedPiiEntityError(
entity_type="CREDIT_CARD",
guardrail_name="presidio",
)
assert exc.status_code == 400
def test_custom_status_code(self):
exc = BlockedPiiEntityError(
entity_type="SSN",
guardrail_name="presidio",
status_code=403,
)
assert exc.status_code == 403
def test_getattr_fallback_resolves_to_400(self):
"""The proxy uses ``getattr(e, 'status_code', 500)`` — verify it
resolves to 400, not the 500 default."""
exc = BlockedPiiEntityError(
entity_type="PHONE_NUMBER",
guardrail_name="presidio",
)
assert getattr(exc, "status_code", 500) == 400