Skip to content

Commit 2371461

Browse files
authored
fix: allow custom HTTP status codes from entrypoint handlers (#284) (#296)
* fix: allow custom HTTP status codes from entrypoint handlers (#284) * test: add tests for custom HTTP status codes from entrypoint handlers (#284)
1 parent 9e865da commit 2371461

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

src/bedrock_agentcore/runtime/app.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from starlette.applications import Starlette
2020
from starlette.concurrency import run_in_threadpool
21+
from starlette.exceptions import HTTPException
2122
from starlette.middleware import Middleware
2223
from starlette.responses import JSONResponse, Response, StreamingResponse
2324
from starlette.routing import Route, WebSocketRoute
@@ -418,6 +419,18 @@ async def _handle_invocation(self, request):
418419
self.logger.info("Returning streaming response (async generator) (%.3fs)", duration)
419420
return StreamingResponse(self._stream_with_error_handling(result), media_type="text/event-stream")
420421

422+
# If handler returned a Starlette Response directly, pass it through.
423+
# This lets handlers control status codes (e.g. JSONResponse(data, status_code=404)).
424+
if isinstance(result, Response):
425+
status = getattr(result, "status_code", 200)
426+
# Log at warning level for error responses so operators can distinguish
427+
# intentional error responses from successful invocations in logs.
428+
if status >= 400:
429+
self.logger.warning("Invocation returned HTTP %d (%.3fs)", status, duration)
430+
else:
431+
self.logger.info("Invocation completed successfully (%.3fs)", duration)
432+
return result
433+
421434
self.logger.info("Invocation completed successfully (%.3fs)", duration)
422435
# Use safe serialization for consistency with streaming paths
423436
safe_json_string = self._safe_serialize_to_json_string(result)
@@ -427,6 +440,16 @@ async def _handle_invocation(self, request):
427440
duration = time.time() - start_time
428441
self.logger.warning("Invalid JSON in request (%.3fs): %s", duration, e)
429442
return JSONResponse({"error": "Invalid JSON", "details": str(e)}, status_code=400)
443+
except HTTPException as e:
444+
duration = time.time() - start_time
445+
# Use error level for 5xx to match the generic Exception handler's severity,
446+
# since server errors warrant the same urgency regardless of how they're raised.
447+
# Use warning for 4xx since those are intentional client-error responses.
448+
if e.status_code >= 500:
449+
self.logger.error("HTTP %d (%.3fs): %s", e.status_code, duration, e.detail)
450+
else:
451+
self.logger.warning("HTTP %d (%.3fs): %s", e.status_code, duration, e.detail)
452+
return JSONResponse({"error": e.detail}, status_code=e.status_code)
430453
except Exception as e:
431454
duration = time.time() - start_time
432455
self.logger.exception("Invocation failed (%.3fs)", duration)

tests/bedrock_agentcore/runtime/test_app.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,151 @@ def handler(payload):
421421
assert response.headers.get("x-custom-mw") == "mw"
422422

423423

424+
class TestCustomStatusCodes:
425+
"""Test that entrypoint handlers can return custom HTTP status codes (#284)."""
426+
427+
def test_http_exception_returns_custom_status(self):
428+
"""Test raising HTTPException returns its status code instead of 500."""
429+
from starlette.exceptions import HTTPException
430+
431+
app = BedrockAgentCoreApp()
432+
433+
@app.entrypoint
434+
def handler(payload):
435+
raise HTTPException(status_code=400, detail="Prompt missing")
436+
437+
client = TestClient(app)
438+
response = client.post("/invocations", json={})
439+
440+
assert response.status_code == 400
441+
assert response.json() == {"error": "Prompt missing"}
442+
443+
def test_http_exception_422(self):
444+
"""Test HTTPException with 422 status code."""
445+
from starlette.exceptions import HTTPException
446+
447+
app = BedrockAgentCoreApp()
448+
449+
@app.entrypoint
450+
def handler(payload):
451+
raise HTTPException(status_code=422, detail="Validation failed")
452+
453+
client = TestClient(app)
454+
response = client.post("/invocations", json={})
455+
456+
assert response.status_code == 422
457+
assert response.json() == {"error": "Validation failed"}
458+
459+
def test_http_exception_500_still_returns_500(self):
460+
"""Test HTTPException with 500 is not swallowed."""
461+
from starlette.exceptions import HTTPException
462+
463+
app = BedrockAgentCoreApp()
464+
465+
@app.entrypoint
466+
def handler(payload):
467+
raise HTTPException(status_code=500, detail="Intentional server error")
468+
469+
client = TestClient(app)
470+
response = client.post("/invocations", json={})
471+
472+
assert response.status_code == 500
473+
assert response.json() == {"error": "Intentional server error"}
474+
475+
def test_return_response_passthrough(self):
476+
"""Test returning a Response object passes it through without wrapping."""
477+
from starlette.responses import JSONResponse
478+
479+
app = BedrockAgentCoreApp()
480+
481+
@app.entrypoint
482+
def handler(payload):
483+
return JSONResponse({"error": "not found"}, status_code=404)
484+
485+
client = TestClient(app)
486+
response = client.post("/invocations", json={})
487+
488+
assert response.status_code == 404
489+
assert response.json() == {"error": "not found"}
490+
491+
def test_return_response_200_passthrough(self):
492+
"""Test returning a Response with 200 also passes through."""
493+
from starlette.responses import JSONResponse
494+
495+
app = BedrockAgentCoreApp()
496+
497+
@app.entrypoint
498+
def handler(payload):
499+
return JSONResponse({"custom": True}, status_code=200, headers={"x-custom": "yes"})
500+
501+
client = TestClient(app)
502+
response = client.post("/invocations", json={})
503+
504+
assert response.status_code == 200
505+
assert response.json() == {"custom": True}
506+
assert response.headers.get("x-custom") == "yes"
507+
508+
def test_normal_dict_return_still_200(self):
509+
"""Test that normal dict returns are unaffected."""
510+
app = BedrockAgentCoreApp()
511+
512+
@app.entrypoint
513+
def handler(payload):
514+
return {"message": "ok"}
515+
516+
client = TestClient(app)
517+
response = client.post("/invocations", json={})
518+
519+
assert response.status_code == 200
520+
assert response.json() == {"message": "ok"}
521+
522+
def test_generic_exception_still_500(self):
523+
"""Test that non-HTTP exceptions still return 500."""
524+
app = BedrockAgentCoreApp()
525+
526+
@app.entrypoint
527+
def handler(payload):
528+
raise ValueError("something broke")
529+
530+
client = TestClient(app)
531+
response = client.post("/invocations", json={})
532+
533+
assert response.status_code == 500
534+
assert response.json() == {"error": "something broke"}
535+
536+
def test_async_handler_http_exception(self):
537+
"""Test HTTPException works from async handlers too."""
538+
from starlette.exceptions import HTTPException
539+
540+
app = BedrockAgentCoreApp()
541+
542+
@app.entrypoint
543+
async def handler(payload):
544+
raise HTTPException(status_code=400, detail="Bad request")
545+
546+
client = TestClient(app)
547+
response = client.post("/invocations", json={})
548+
549+
assert response.status_code == 400
550+
assert response.json() == {"error": "Bad request"}
551+
552+
def test_async_handler_response_passthrough(self):
553+
"""Test returning Response from async handler."""
554+
from starlette.responses import JSONResponse
555+
556+
app = BedrockAgentCoreApp()
557+
558+
@app.entrypoint
559+
async def handler(payload):
560+
return JSONResponse({"error": "gone"}, status_code=410)
561+
562+
client = TestClient(app)
563+
response = client.post("/invocations", json={})
564+
565+
assert response.status_code == 410
566+
assert response.json() == {"error": "gone"}
567+
568+
424569
class TestConcurrentInvocations:
425570
"""Test concurrent invocation handling simplified without limits."""
426571

0 commit comments

Comments
 (0)