fix(guardrails): keep the stream error message a string and drop an unreachable branch

This commit is contained in:
Yucheng Zhu 2026-08-12 17:02:51 -07:00
parent 4ab01f24d5
commit 249733e71c
3 changed files with 23 additions and 6 deletions

View file

@ -102,10 +102,13 @@ def anthropic_sse_error_frames(message: str | Mapping[str, object]) -> tuple[byt
"""Anthropic error event, for a failure discovered after the response headers were flushed.
Once a keepalive ping has been sent a raise cannot reach the client, so the failure has to
travel as a frame. A structured message is emitted as JSON rather than stringified, so the
guardrail's own detail stays machine readable.
travel as a frame. A structured detail is JSON-serialized into the string so it stays machine
readable without violating the string-typed contract.
"""
body: Final = json.dumps(message, default=str)
# AnthropicErrorDetail.message is a str, so a structured detail is serialized into the string
# rather than nested as an object
text: Final = message if isinstance(message, str) else json.dumps(message, default=str)
body: Final = json.dumps(text)
return (
f'event: error\ndata: {{"type": "error", "error": {{"type": "guardrail_error", '
f'"message": {body}}}}}\n\n'.encode(),

View file

@ -2664,9 +2664,6 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
)
if _original_usage is not None:
assembled_model_response.usage = _original_usage
if raw_sse:
assembled_model_response.id = _pre_block_response.id
assembled_model_response.model = _pre_block_response.model
output_guardrail_response = None
#########################################################################

View file

@ -2954,6 +2954,23 @@ async def test_streaming_hook_block_stream_keeps_upstream_identity():
assert b"my-proxy-alias" not in body
@pytest.mark.asyncio
async def test_streaming_block_error_frame_message_is_a_string():
"""AnthropicErrorDetail.message is typed str, so a structured guardrail detail is serialized."""
guardrail = _sse_guardrail()
with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
mock_api.side_effect = HTTPException(
status_code=400, detail={"error": "Violated guardrail policy", "guardrailIdentifier": "gid"}
)
delivered = await _drain_streaming_hook(guardrail)
frame = next(line for line in b"".join(delivered).decode().splitlines() if line.startswith("data: "))
message = json.loads(frame[6:])["error"]["message"]
assert isinstance(message, str)
assert json.loads(message)["error"] == "Violated guardrail policy"
@pytest.mark.asyncio
async def test_streaming_hook_fails_closed_when_raw_sse_cannot_be_assembled():
"""An unscannable stream must not be delivered: forwarding it silently disables the guardrail."""