Skip to content

Commit c8ff7d1

Browse files
author
Nick Franck
committed
fix: keep error responses well-formed under hostile error attributes
Guard failure_category and status_code pickup against raising descriptors and non-int values, serialize non-string HTTPException details, and repair the inverted single-parameter validation in check_precheck_func.
1 parent 6d46d54 commit c8ff7d1

2 files changed

Lines changed: 119 additions & 13 deletions

File tree

test/api/test_api.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,3 +642,88 @@ def _enum_category_precheck() -> None:
642642
assert body["status_code"] == 403
643643
assert body["failure_category"] is None
644644
assert "credential rejected" in body["status_code_text"]
645+
646+
647+
def test_invoke_reports_failure_category_from_raised_error():
648+
class _CategorizedError(Exception):
649+
status_code = 403
650+
failure_category = "AUTH_PERMISSION_DENIED"
651+
652+
def _raising_func() -> None:
653+
raise _CategorizedError("credential rejected")
654+
655+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
656+
657+
body = client.post("/invoke").json()
658+
assert body["status_code"] == 403
659+
assert body["failure_category"] == "AUTH_PERMISSION_DENIED"
660+
661+
662+
def test_invoke_sanitizes_raising_error_attributes():
663+
class _HostileError(Exception):
664+
@property
665+
def status_code(self) -> int:
666+
raise RuntimeError("status_code exploded")
667+
668+
@property
669+
def failure_category(self) -> str:
670+
raise RuntimeError("failure_category exploded")
671+
672+
def _raising_func() -> None:
673+
raise _HostileError("original message")
674+
675+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
676+
677+
resp = client.post("/invoke")
678+
assert resp.status_code == 200
679+
body = resp.json()
680+
assert body["status_code"] == 500
681+
assert body["failure_category"] is None
682+
assert "original message" in body["status_code_text"]
683+
684+
685+
def test_invoke_ignores_non_integer_status_code():
686+
class _BadStatusError(Exception):
687+
status_code = "not-a-code"
688+
689+
def _raising_func() -> None:
690+
raise _BadStatusError("boom")
691+
692+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
693+
694+
body = client.post("/invoke").json()
695+
assert body["status_code"] == 500
696+
697+
698+
def test_invoke_serializes_non_string_http_exception_detail():
699+
from fastapi import HTTPException
700+
701+
def _raising_func() -> None:
702+
raise HTTPException(status_code=422, detail=["field a", "field b"])
703+
704+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
705+
706+
body = client.post("/invoke").json()
707+
assert body["status_code"] == 422
708+
assert body["status_code_text"] == '["field a", "field b"]'
709+
710+
711+
def test_precheck_func_may_take_a_usage_list_parameter():
712+
def _usage_precheck(usage: list) -> None:
713+
return None
714+
715+
client = TestClient(
716+
wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_usage_precheck)
717+
)
718+
719+
assert client.get("/precheck").json()["status_code"] == 200
720+
721+
722+
def test_precheck_func_with_non_list_usage_parameter_is_rejected():
723+
from unstructured_platform_plugins.etl_uvicorn.api_generator import EtlApiException
724+
725+
def _bad_precheck(usage: int) -> None:
726+
return None
727+
728+
with pytest.raises(EtlApiException):
729+
wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_bad_precheck)

unstructured_platform_plugins/etl_uvicorn/api_generator.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import logging
66
from functools import partial
7-
from typing import Any, Callable, Optional, Union
7+
from typing import Any, Callable, Optional, Union, get_origin
88

99
from fastapi import FastAPI, HTTPException, status
1010
from fastapi.responses import StreamingResponse
@@ -65,13 +65,33 @@ def log_func_and_body(func: Callable, body: Optional[str] = None) -> None:
6565
def failure_category_of(error: BaseException) -> Optional[str]:
6666
"""Return the error's failure_category only when it is a plain string.
6767
68-
Any other value would fail response-model validation inside an exception
69-
handler, replacing the sanitized error body with a raw 500.
68+
Runs while an exception handler is building the sanitized response, so a
69+
non-string value — or an attribute access that itself raises — is treated
70+
as absent rather than allowed to replace that response with a raw 500.
7071
"""
71-
category = getattr(error, "failure_category", None)
72+
try:
73+
category = getattr(error, "failure_category", None)
74+
except Exception:
75+
return None
7276
return category if isinstance(category, str) else None
7377

7478

79+
def status_code_of(error: BaseException) -> int:
80+
"""Return the error's status_code only when it is a usable integer.
81+
82+
Same contract as failure_category_of: runs inside exception handlers, so
83+
anything other than a plain int falls back to 500 instead of failing
84+
response-model validation.
85+
"""
86+
try:
87+
status_code = getattr(error, "status_code", None)
88+
except Exception:
89+
return status.HTTP_500_INTERNAL_SERVER_ERROR
90+
if isinstance(status_code, int) and not isinstance(status_code, bool):
91+
return status_code
92+
return status.HTTP_500_INTERNAL_SERVER_ERROR
93+
94+
7595
async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Any:
7696
kwargs = kwargs or {}
7797
if inspect.iscoroutinefunction(func):
@@ -82,11 +102,14 @@ async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) -
82102

83103
def check_precheck_func(precheck_func: Callable):
84104
sig = inspect.signature(precheck_func)
85-
inputs = sig.parameters.values()
105+
inputs = list(sig.parameters.values())
86106
outputs = sig.return_annotation
87107
if len(inputs) == 1:
88108
i = inputs[0]
89-
if i.name != "usage" or i.annotation is list:
109+
annotation_is_list = (
110+
i.annotation is sig.empty or i.annotation is list or get_origin(i.annotation) is list
111+
)
112+
if i.name != "usage" or not annotation_is_list:
90113
raise ValueError("the only input available for precheck is usage which must be a list")
91114
if outputs not in [None, sig.empty]:
92115
raise ValueError(f"no output should exist for precheck function, found: {outputs}")
@@ -208,8 +231,7 @@ async def _stream_response():
208231
filedata_meta=filedata_meta_model.model_validate(
209232
filedata_meta.model_dump()
210233
),
211-
status_code=getattr(e, "status_code", None)
212-
or status.HTTP_500_INTERNAL_SERVER_ERROR,
234+
status_code=status_code_of(e),
213235
status_code_text=f"[{e.__class__.__name__}] {e}",
214236
failure_category=failure_category_of(e),
215237
).model_dump_json()
@@ -236,9 +258,9 @@ async def _stream_response():
236258
message_channels=message_channels,
237259
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
238260
status_code=exc.status_code,
239-
status_code_text=json.dumps(exc.detail)
240-
if isinstance(exc.detail, dict)
241-
else exc.detail,
261+
status_code_text=exc.detail
262+
if isinstance(exc.detail, str)
263+
else json.dumps(exc.detail, default=str),
242264
failure_category=failure_category_of(exc),
243265
file_data=request_dict.get("file_data", None),
244266
)
@@ -262,8 +284,7 @@ async def _stream_response():
262284
usage=usage,
263285
message_channels=message_channels,
264286
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
265-
status_code=getattr(invoke_error, "status_code", None)
266-
or status.HTTP_500_INTERNAL_SERVER_ERROR,
287+
status_code=status_code_of(invoke_error),
267288
status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}",
268289
failure_category=failure_category_of(invoke_error),
269290
file_data=request_dict.get("file_data", None),

0 commit comments

Comments
 (0)