Skip to content

Commit a86b9a1

Browse files
committed
check for AWS exceptions despite a 200 response
1 parent c6e37bb commit a86b9a1

File tree

2 files changed

+109
-14
lines changed

2 files changed

+109
-14
lines changed

litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,9 @@ async def make_bedrock_api_request(
383383
)
384384
#########################################################
385385
if response.status_code == 200:
386+
# check if the response contains an error
387+
if self._check_bedrock_response_for_exception(response=response):
388+
raise self._get_http_exception_for_failed_guardrail(response)
386389
# check if the response was flagged
387390
_json_response = response.json()
388391
redacted_response = _redact_pii_matches(_json_response)
@@ -403,16 +406,36 @@ async def make_bedrock_api_request(
403406

404407
return bedrock_guardrail_response
405408

409+
def _check_bedrock_response_for_exception(self, response: httpx.Response) -> bool:
410+
return "Exception" in json.loads(response.content.decode("utf-8")).get(
411+
"Output", {}
412+
).get("__type", "")
413+
406414
def _get_bedrock_guardrail_response_status(
407415
self, response: httpx.Response
408416
) -> Literal["success", "failure"]:
409417
"""
410418
Get the status of the bedrock guardrail response.
411419
"""
412420
if response.status_code == 200:
421+
if self._check_bedrock_response_for_exception(response):
422+
return "failure"
413423
return "success"
414424
return "failure"
415425

426+
def _get_http_exception_for_failed_guardrail(
427+
self, response: httpx.Response
428+
) -> HTTPException:
429+
return HTTPException(
430+
status_code=400,
431+
detail={
432+
"error": "Guardrail application failed.",
433+
"bedrock_guardrail_response": json.loads(
434+
response.content.decode("utf-8")
435+
).get("Output", {}),
436+
},
437+
)
438+
416439
def _get_http_exception_for_blocked_guardrail(
417440
self, response: BedrockGuardrailResponse
418441
) -> HTTPException:
@@ -562,11 +585,11 @@ async def async_pre_call_hook(
562585
#########################################################
563586
########## 2. Update the messages with the guardrail response ##########
564587
#########################################################
565-
data["messages"] = (
566-
self._update_messages_with_updated_bedrock_guardrail_response(
567-
messages=new_messages,
568-
bedrock_guardrail_response=bedrock_guardrail_response,
569-
)
588+
data[
589+
"messages"
590+
] = self._update_messages_with_updated_bedrock_guardrail_response(
591+
messages=new_messages,
592+
bedrock_guardrail_response=bedrock_guardrail_response,
570593
)
571594

572595
#########################################################
@@ -617,11 +640,11 @@ async def async_moderation_hook(
617640
#########################################################
618641
########## 2. Update the messages with the guardrail response ##########
619642
#########################################################
620-
data["messages"] = (
621-
self._update_messages_with_updated_bedrock_guardrail_response(
622-
messages=new_messages,
623-
bedrock_guardrail_response=bedrock_guardrail_response,
624-
)
643+
data[
644+
"messages"
645+
] = self._update_messages_with_updated_bedrock_guardrail_response(
646+
messages=new_messages,
647+
bedrock_guardrail_response=bedrock_guardrail_response,
625648
)
626649

627650
#########################################################

tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""
22
Unit tests for Bedrock Guardrails
33
"""
4-
4+
import json
55
import os
66
import sys
77
from unittest.mock import AsyncMock, MagicMock, patch
88

99
import pytest
10+
from fastapi import HTTPException
1011

1112
sys.path.insert(0, os.path.abspath("../../../../../.."))
1213

@@ -238,7 +239,6 @@ async def test_bedrock_guardrail_logging_uses_redacted_response():
238239
) as mock_load_creds, patch.object(
239240
guardrail, "_prepare_request", return_value=MagicMock()
240241
) as mock_prepare_request:
241-
242242
mock_post.return_value = mock_bedrock_response
243243

244244
# Call the method that should log the redacted response
@@ -345,7 +345,6 @@ async def test_bedrock_guardrail_original_response_not_modified():
345345
) as mock_load_creds, patch.object(
346346
guardrail, "_prepare_request", return_value=MagicMock()
347347
) as mock_prepare_request:
348-
349348
mock_post.return_value = mock_bedrock_response
350349

351350
# Call the method
@@ -860,7 +859,6 @@ async def test__redact_pii_matches_comprehensive_coverage():
860859

861860
print("Comprehensive coverage redaction test passed")
862861

863-
864862
@pytest.mark.asyncio
865863
async def test_bedrock_guardrail_respects_custom_runtime_endpoint(monkeypatch):
866864
"""Test that BedrockGuardrail respects aws_bedrock_runtime_endpoint when set"""
@@ -1049,3 +1047,77 @@ async def test_bedrock_guardrail_parameter_takes_precedence_over_env(monkeypatch
10491047
), f"Expected parameter endpoint to take precedence. Got: {prepped_request.url}"
10501048

10511049
print(f"Parameter precedence test passed. URL: {prepped_request.url}")
1050+
1051+
1052+
@pytest.mark.asyncio
1053+
async def test_bedrock_guardrail_200_with_exception_in_output_raises_and_logs_failure():
1054+
"""
1055+
When Bedrock returns HTTP 200 but the body contains Output.__type with 'Exception',
1056+
the guardrail should:
1057+
- raise an HTTPException(400) with the Output payload in detail
1058+
- log the request trace with guardrail_status='failure'
1059+
"""
1060+
guardrail = BedrockGuardrail(
1061+
guardrailIdentifier="test-guardrail", guardrailVersion="DRAFT"
1062+
)
1063+
1064+
# Mock a Bedrock "success" HTTP status but an Exception embedded in the body
1065+
payload = {
1066+
"Output": {
1067+
"__type": "com.amazonaws#InternalServerException",
1068+
"message": "Something went wrong upstream",
1069+
},
1070+
"action": "NONE",
1071+
}
1072+
mock_resp = MagicMock()
1073+
mock_resp.status_code = 200
1074+
mock_resp.content = json.dumps(payload).encode("utf-8")
1075+
mock_resp.text = json.dumps(payload)
1076+
mock_resp.json.return_value = payload
1077+
1078+
# Minimal request data
1079+
request_data = {
1080+
"model": "gpt-4o",
1081+
"messages": [{"role": "user", "content": "hello"}],
1082+
}
1083+
1084+
# Mock creds and request prep
1085+
mock_credentials = MagicMock()
1086+
mock_credentials.access_key = "ak"
1087+
mock_credentials.secret_key = "sk"
1088+
mock_credentials.token = None
1089+
1090+
with patch.object(
1091+
guardrail.async_handler, "post", new_callable=AsyncMock
1092+
) as mock_post, patch.object(
1093+
guardrail, "_load_credentials", return_value=(mock_credentials, "us-east-1")
1094+
), patch.object(
1095+
guardrail,
1096+
"_prepare_request",
1097+
return_value=MagicMock(url="http://example", headers={}, body=b""),
1098+
), patch.object(
1099+
guardrail, "add_standard_logging_guardrail_information_to_request_data"
1100+
) as mock_add_trace:
1101+
mock_post.return_value = mock_resp
1102+
1103+
with pytest.raises(HTTPException) as excinfo:
1104+
await guardrail.make_bedrock_api_request(
1105+
source="INPUT",
1106+
messages=request_data["messages"],
1107+
request_data=request_data,
1108+
)
1109+
1110+
# 1) Raised HTTPException with 400 status
1111+
err = excinfo.value
1112+
assert err.status_code == 400
1113+
assert err.detail["error"] == "Guardrail application failed."
1114+
1115+
# 2) Detail includes the Output object from the Bedrock body
1116+
assert err.detail["bedrock_guardrail_response"] == payload["Output"]
1117+
1118+
# 3) Trace logging received a 'failure' status
1119+
assert mock_add_trace.called
1120+
_, kwargs = mock_add_trace.call_args
1121+
assert kwargs["guardrail_status"] == "failure"
1122+
# And the JSON passed to tracing is the same response we received
1123+
assert kwargs["guardrail_json_response"] == payload

0 commit comments

Comments
 (0)