From f0da259f66448c27dce163eeeb436a23c0af61ed Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Wed, 12 Aug 2026 20:11:12 -0700 Subject: [PATCH] fix(guardrails): key the streamed block decision on status, not detail shape InvokeGuardrailChecks details a Mapping on its 500 for an unparseable response, so a detail-shape test read that outage as a policy block and framed it as a 200 guardrail_error. Both block sites raise 400, so gate on the status too. --- .../guardrail_hooks/bedrock_guardrails.py | 8 ++++---- .../guardrail_hooks/test_bedrock_guardrails.py | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 84fa3473e4a..b7bd4884ff0 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -2626,10 +2626,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ) except HTTPException as block_exc: block_detail: Final = block_exc.detail - # Only a policy block carries a dict detail; every API/transport failure details a - # plain string (see _is_input_too_large_error). Re-raising those keeps their real - # status instead of reporting an outage as a guardrail decision - if not raw_sse or not isinstance(block_detail, Mapping): + # A policy block is the only 400 carrying a structured detail; a service failure + # either details a plain string or reports a non-400 status, and re-raising those + # keeps their real status instead of reporting an outage as a guardrail decision + if not raw_sse or block_exc.status_code != 400 or not isinstance(block_detail, Mapping): raise # past the keepalive ping the headers are already flushed, so a raise cannot reach # the client; the block travels as a frame diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index a8f39e0a0b1..4d287dbcb9b 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -2958,7 +2958,7 @@ async def test_streaming_hook_block_stream_keeps_upstream_identity(): async def test_streaming_hook_reraises_guardrail_service_failures(): """A Bedrock outage must keep its status, not be reported to the caller as a guardrail decision. - Only a policy block details a Mapping; every API/transport failure details a plain string. + A policy block is the only 400 detailing a Mapping. """ guardrail = _sse_guardrail() @@ -2972,6 +2972,22 @@ async def test_streaming_hook_reraises_guardrail_service_failures(): assert exc.value.status_code == 500 +@pytest.mark.asyncio +async def test_streaming_hook_reraises_a_service_failure_that_details_a_mapping(): + """InvokeGuardrailChecks details a Mapping on its 500, so detail shape alone cannot mean "block".""" + guardrail = _sse_guardrail() + + with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api: + mock_api.side_effect = HTTPException( + status_code=500, + detail={"error": "Bedrock InvokeGuardrailChecks returned an unexpected response shape"}, + ) + with pytest.raises(HTTPException) as exc: + await _drain_streaming_hook(guardrail) + + assert exc.value.status_code == 500 + + @pytest.mark.asyncio async def test_streaming_block_error_frame_message_is_a_string(): """AnthropicErrorDetail.message is typed str, built by the proxy's own detail serializer."""