Skip to content

Commit fb74513

Browse files
committed
fix: guard error stringification in the invoke and precheck handlers
str() on a plugin-raised error can itself raise, replacing the sanitized envelope with a raw HTTP 500 that the controller's preflight treats as fail-open.
1 parent 8c73bf7 commit fb74513

2 files changed

Lines changed: 59 additions & 8 deletions

File tree

test/api/test_api.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,43 @@ def _raising_func() -> None:
674674
assert "original message" in body["status_code_text"]
675675

676676

677+
class _UnrenderableError(Exception):
678+
status_code = 403
679+
680+
def __str__(self) -> str:
681+
raise RuntimeError("__str__ exploded")
682+
683+
684+
def test_invoke_survives_error_whose_str_raises():
685+
def _raising_func() -> None:
686+
raise _UnrenderableError()
687+
688+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
689+
690+
resp = client.post("/invoke")
691+
assert resp.status_code == 200
692+
body = resp.json()
693+
assert body["status_code"] == 403
694+
assert "<unrenderable error>" in body["status_code_text"]
695+
696+
697+
def test_precheck_survives_error_whose_str_raises():
698+
def _unrenderable_precheck() -> None:
699+
raise _UnrenderableError()
700+
701+
client = TestClient(
702+
wrap_in_fastapi(
703+
func=_no_params, plugin_id="mock_plugin", precheck_func=_unrenderable_precheck
704+
)
705+
)
706+
707+
resp = client.get("/precheck")
708+
assert resp.status_code == 200
709+
body = resp.json()
710+
assert body["status_code"] == 403
711+
assert "<unrenderable error>" in body["status_code_text"]
712+
713+
677714
def test_invoke_ignores_non_integer_status_code():
678715
class _BadStatusError(Exception):
679716
status_code = "not-a-code"

unstructured_platform_plugins/etl_uvicorn/api_generator.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ def _error_attr(error: BaseException, name: str) -> Any:
7474
return None
7575

7676

77+
def _safe_str(value: object) -> str:
78+
"""str() on a plugin-supplied error can itself raise; never let that escape the handler.
79+
80+
An escape replaces the sanitized envelope with a raw HTTP 500, which the
81+
controller's preflight reads as fail-open — a plugin-reported failure would
82+
silently become a proceed.
83+
"""
84+
try:
85+
return str(value)
86+
except Exception:
87+
return "<unrenderable error>"
88+
89+
7790
def failure_category_of(error: BaseException) -> Optional[str]:
7891
"""Return the error's failure_category only when it is a plain string."""
7992
category = _error_attr(error, "failure_category")
@@ -231,7 +244,7 @@ async def _stream_response():
231244
+ "\n"
232245
)
233246
except Exception as e:
234-
logger.error(f"Failure streaming response: {e}", exc_info=True)
247+
logger.error(f"Failure streaming response: {_safe_str(e)}", exc_info=True)
235248
yield (
236249
InvokeResponse(
237250
usage=usage,
@@ -240,7 +253,7 @@ async def _stream_response():
240253
filedata_meta.model_dump()
241254
),
242255
status_code=status_code_of(e),
243-
status_code_text=f"[{e.__class__.__name__}] {e}",
256+
status_code_text=f"[{e.__class__.__name__}] {_safe_str(e)}",
244257
failure_category=failure_category_of(e),
245258
).model_dump_json()
246259
+ "\n"
@@ -259,7 +272,8 @@ async def _stream_response():
259272
)
260273
except HTTPException as exc:
261274
logger.error(
262-
f"HTTPException: {exc.detail} (status_code={exc.status_code})", exc_info=True
275+
f"HTTPException: {_safe_str(exc.detail)} (status_code={exc.status_code})",
276+
exc_info=True,
263277
)
264278
return InvokeResponse(
265279
usage=usage,
@@ -268,13 +282,13 @@ async def _stream_response():
268282
status_code=exc.status_code,
269283
status_code_text=exc.detail
270284
if isinstance(exc.detail, str)
271-
else json.dumps(exc.detail, default=str),
285+
else json.dumps(exc.detail, default=_safe_str),
272286
failure_category=failure_category_of(exc),
273287
file_data=request_dict.get("file_data", None),
274288
)
275289
except UnstructuredIngestError as exc:
276290
logger.error(
277-
f"UnstructuredIngestError: {exc} "
291+
f"UnstructuredIngestError: {_safe_str(exc)} "
278292
f"(status_code={_error_attr(exc, 'status_code')})",
279293
exc_info=True,
280294
)
@@ -283,18 +297,18 @@ async def _stream_response():
283297
message_channels=message_channels,
284298
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
285299
status_code=status_code_of(exc),
286-
status_code_text=str(exc),
300+
status_code_text=_safe_str(exc),
287301
failure_category=failure_category_of(exc),
288302
file_data=request_dict.get("file_data", None),
289303
)
290304
except Exception as invoke_error:
291-
logger.error(f"failed to invoke plugin: {invoke_error}", exc_info=True)
305+
logger.error(f"failed to invoke plugin: {_safe_str(invoke_error)}", exc_info=True)
292306
return InvokeResponse(
293307
usage=usage,
294308
message_channels=message_channels,
295309
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
296310
status_code=status_code_of(invoke_error),
297-
status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}",
311+
status_code_text=f"[{invoke_error.__class__.__name__}] {_safe_str(invoke_error)}",
298312
failure_category=failure_category_of(invoke_error),
299313
file_data=request_dict.get("file_data", None),
300314
)

0 commit comments

Comments
 (0)