From 2d15960321fbbb3514cd408d8d9fcd54cc16f1ae Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:45:52 -0400 Subject: [PATCH 1/9] feat(etl-uvicorn): carry failure_category through invoke and precheck responses --- test/api/test_api.py | 38 +++++++++++++++++++ .../etl_uvicorn/api_generator.py | 7 ++++ 2 files changed, 45 insertions(+) diff --git a/test/api/test_api.py b/test/api/test_api.py index 83e2b23..d381b5e 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -581,3 +581,41 @@ def test_no_param_plugin_still_accepts_a_bodyless_post(): assert resp.status_code == 200 assert InvokeResponse.model_validate(resp.json()).output["received"] == "ok" + + +class _PrecheckFailure(Exception): + status_code = 403 + failure_category = "AUTH_PERMISSION_DENIED" + + +def _failing_precheck() -> None: + raise _PrecheckFailure("credential rejected") + + +def _passing_precheck() -> None: + return None + + +def test_precheck_reports_failure_category_from_raised_error(): + client = TestClient( + wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_failing_precheck) + ) + + resp = client.get("/precheck") + + body = resp.json() + assert body["status_code"] == 403 + assert body["failure_category"] == "AUTH_PERMISSION_DENIED" + assert "credential rejected" in body["status_code_text"] + + +def test_precheck_success_has_no_failure_category(): + client = TestClient( + wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_passing_precheck) + ) + + resp = client.get("/precheck") + + body = resp.json() + assert body["status_code"] == 200 + assert body["failure_category"] is None diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 4b7d8ce..168a3d3 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -146,6 +146,7 @@ class InvokeResponse(BaseModel): file_data: Optional[FileDataType] = None filedata_meta: Optional[filedata_meta_model] = None status_code_text: Optional[str] = None + failure_category: Optional[str] = None output: Optional[response_type] = None message_channels: MessageChannels = Field(default_factory=MessageChannels) @@ -200,6 +201,7 @@ async def _stream_response(): status_code=getattr(e, "status_code", None) or status.HTTP_500_INTERNAL_SERVER_ERROR, status_code_text=f"[{e.__class__.__name__}] {e}", + failure_category=getattr(e, "failure_category", None), ).model_dump_json() + "\n" ) @@ -227,6 +229,7 @@ async def _stream_response(): status_code_text=json.dumps(exc.detail) if isinstance(exc.detail, dict) else exc.detail, + failure_category=getattr(exc, "failure_category", None), file_data=request_dict.get("file_data", None), ) except UnstructuredIngestError as exc: @@ -240,6 +243,7 @@ async def _stream_response(): filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), status_code=exc.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR, status_code_text=str(exc), + failure_category=getattr(exc, "failure_category", None), file_data=request_dict.get("file_data", None), ) except Exception as invoke_error: @@ -251,6 +255,7 @@ async def _stream_response(): status_code=getattr(invoke_error, "status_code", None) or status.HTTP_500_INTERNAL_SERVER_ERROR, status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}", + failure_category=getattr(invoke_error, "failure_category", None), file_data=request_dict.get("file_data", None), ) @@ -318,6 +323,7 @@ class InvokePrecheckResponse(BaseModel): usage: list[UsageData] status_code: int status_code_text: Optional[str] = None + failure_category: Optional[str] = None @fastapi_app.get("/schema") async def get_schema() -> SchemaOutputResponse: @@ -332,6 +338,7 @@ async def run_precheck() -> InvokePrecheckResponse: return InvokePrecheckResponse( status_code=fn_response.status_code, status_code_text=fn_response.status_code_text, + failure_category=fn_response.failure_category, usage=fn_response.usage, ) else: From 6d46d5411160b6283ca7f9bf4d1ca66422a79abe Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 12 Aug 2026 11:54:50 -0400 Subject: [PATCH 2/9] fix: only accept string failure_category values from raised errors --- test/api/test_api.py | 23 +++++++++++++++++++ .../etl_uvicorn/api_generator.py | 18 +++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index d381b5e..0ce0a2d 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -1,3 +1,4 @@ +import enum from pathlib import Path from typing import Any, Optional, Union @@ -619,3 +620,25 @@ def test_precheck_success_has_no_failure_category(): body = resp.json() assert body["status_code"] == 200 assert body["failure_category"] is None + + +def test_precheck_ignores_non_string_failure_category(): + class _EnumCategoryFailure(Exception): + status_code = 403 + failure_category = enum.Enum("Category", ["AUTH_PERMISSION_DENIED"]).AUTH_PERMISSION_DENIED + + def _enum_category_precheck() -> None: + raise _EnumCategoryFailure("credential rejected") + + client = TestClient( + wrap_in_fastapi( + func=_no_params, plugin_id="mock_plugin", precheck_func=_enum_category_precheck + ) + ) + + resp = client.get("/precheck") + + body = resp.json() + assert body["status_code"] == 403 + assert body["failure_category"] is None + assert "credential rejected" in body["status_code_text"] diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 168a3d3..1bf6314 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -62,6 +62,16 @@ def log_func_and_body(func: Callable, body: Optional[str] = None) -> None: logger.log(level=logger.level, msg=msg) +def failure_category_of(error: BaseException) -> Optional[str]: + """Return the error's failure_category only when it is a plain string. + + Any other value would fail response-model validation inside an exception + handler, replacing the sanitized error body with a raw 500. + """ + category = getattr(error, "failure_category", None) + return category if isinstance(category, str) else None + + async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Any: kwargs = kwargs or {} if inspect.iscoroutinefunction(func): @@ -201,7 +211,7 @@ async def _stream_response(): status_code=getattr(e, "status_code", None) or status.HTTP_500_INTERNAL_SERVER_ERROR, status_code_text=f"[{e.__class__.__name__}] {e}", - failure_category=getattr(e, "failure_category", None), + failure_category=failure_category_of(e), ).model_dump_json() + "\n" ) @@ -229,7 +239,7 @@ async def _stream_response(): status_code_text=json.dumps(exc.detail) if isinstance(exc.detail, dict) else exc.detail, - failure_category=getattr(exc, "failure_category", None), + failure_category=failure_category_of(exc), file_data=request_dict.get("file_data", None), ) except UnstructuredIngestError as exc: @@ -243,7 +253,7 @@ async def _stream_response(): filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), status_code=exc.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR, status_code_text=str(exc), - failure_category=getattr(exc, "failure_category", None), + failure_category=failure_category_of(exc), file_data=request_dict.get("file_data", None), ) except Exception as invoke_error: @@ -255,7 +265,7 @@ async def _stream_response(): status_code=getattr(invoke_error, "status_code", None) or status.HTTP_500_INTERNAL_SERVER_ERROR, status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}", - failure_category=getattr(invoke_error, "failure_category", None), + failure_category=failure_category_of(invoke_error), file_data=request_dict.get("file_data", None), ) From c8ff7d163c22f0163df71abceefd27c8c784c322 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 12 Aug 2026 12:14:32 -0400 Subject: [PATCH 3/9] 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. --- test/api/test_api.py | 85 +++++++++++++++++++ .../etl_uvicorn/api_generator.py | 47 +++++++--- 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index 0ce0a2d..fb958b5 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -642,3 +642,88 @@ def _enum_category_precheck() -> None: assert body["status_code"] == 403 assert body["failure_category"] is None assert "credential rejected" in body["status_code_text"] + + +def test_invoke_reports_failure_category_from_raised_error(): + class _CategorizedError(Exception): + status_code = 403 + failure_category = "AUTH_PERMISSION_DENIED" + + def _raising_func() -> None: + raise _CategorizedError("credential rejected") + + client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + + body = client.post("/invoke").json() + assert body["status_code"] == 403 + assert body["failure_category"] == "AUTH_PERMISSION_DENIED" + + +def test_invoke_sanitizes_raising_error_attributes(): + class _HostileError(Exception): + @property + def status_code(self) -> int: + raise RuntimeError("status_code exploded") + + @property + def failure_category(self) -> str: + raise RuntimeError("failure_category exploded") + + def _raising_func() -> None: + raise _HostileError("original message") + + client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + + resp = client.post("/invoke") + assert resp.status_code == 200 + body = resp.json() + assert body["status_code"] == 500 + assert body["failure_category"] is None + assert "original message" in body["status_code_text"] + + +def test_invoke_ignores_non_integer_status_code(): + class _BadStatusError(Exception): + status_code = "not-a-code" + + def _raising_func() -> None: + raise _BadStatusError("boom") + + client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + + body = client.post("/invoke").json() + assert body["status_code"] == 500 + + +def test_invoke_serializes_non_string_http_exception_detail(): + from fastapi import HTTPException + + def _raising_func() -> None: + raise HTTPException(status_code=422, detail=["field a", "field b"]) + + client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + + body = client.post("/invoke").json() + assert body["status_code"] == 422 + assert body["status_code_text"] == '["field a", "field b"]' + + +def test_precheck_func_may_take_a_usage_list_parameter(): + def _usage_precheck(usage: list) -> None: + return None + + client = TestClient( + wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_usage_precheck) + ) + + assert client.get("/precheck").json()["status_code"] == 200 + + +def test_precheck_func_with_non_list_usage_parameter_is_rejected(): + from unstructured_platform_plugins.etl_uvicorn.api_generator import EtlApiException + + def _bad_precheck(usage: int) -> None: + return None + + with pytest.raises(EtlApiException): + wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_bad_precheck) diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 1bf6314..13d82b1 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -4,7 +4,7 @@ import json import logging from functools import partial -from typing import Any, Callable, Optional, Union +from typing import Any, Callable, Optional, Union, get_origin from fastapi import FastAPI, HTTPException, status from fastapi.responses import StreamingResponse @@ -65,13 +65,33 @@ def log_func_and_body(func: Callable, body: Optional[str] = None) -> None: def failure_category_of(error: BaseException) -> Optional[str]: """Return the error's failure_category only when it is a plain string. - Any other value would fail response-model validation inside an exception - handler, replacing the sanitized error body with a raw 500. + Runs while an exception handler is building the sanitized response, so a + non-string value — or an attribute access that itself raises — is treated + as absent rather than allowed to replace that response with a raw 500. """ - category = getattr(error, "failure_category", None) + try: + category = getattr(error, "failure_category", None) + except Exception: + return None return category if isinstance(category, str) else None +def status_code_of(error: BaseException) -> int: + """Return the error's status_code only when it is a usable integer. + + Same contract as failure_category_of: runs inside exception handlers, so + anything other than a plain int falls back to 500 instead of failing + response-model validation. + """ + try: + status_code = getattr(error, "status_code", None) + except Exception: + return status.HTTP_500_INTERNAL_SERVER_ERROR + if isinstance(status_code, int) and not isinstance(status_code, bool): + return status_code + return status.HTTP_500_INTERNAL_SERVER_ERROR + + async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Any: kwargs = kwargs or {} if inspect.iscoroutinefunction(func): @@ -82,11 +102,14 @@ async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) - def check_precheck_func(precheck_func: Callable): sig = inspect.signature(precheck_func) - inputs = sig.parameters.values() + inputs = list(sig.parameters.values()) outputs = sig.return_annotation if len(inputs) == 1: i = inputs[0] - if i.name != "usage" or i.annotation is list: + annotation_is_list = ( + i.annotation is sig.empty or i.annotation is list or get_origin(i.annotation) is list + ) + if i.name != "usage" or not annotation_is_list: raise ValueError("the only input available for precheck is usage which must be a list") if outputs not in [None, sig.empty]: raise ValueError(f"no output should exist for precheck function, found: {outputs}") @@ -208,8 +231,7 @@ async def _stream_response(): filedata_meta=filedata_meta_model.model_validate( filedata_meta.model_dump() ), - status_code=getattr(e, "status_code", None) - or status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code=status_code_of(e), status_code_text=f"[{e.__class__.__name__}] {e}", failure_category=failure_category_of(e), ).model_dump_json() @@ -236,9 +258,9 @@ async def _stream_response(): message_channels=message_channels, filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), status_code=exc.status_code, - status_code_text=json.dumps(exc.detail) - if isinstance(exc.detail, dict) - else exc.detail, + status_code_text=exc.detail + if isinstance(exc.detail, str) + else json.dumps(exc.detail, default=str), failure_category=failure_category_of(exc), file_data=request_dict.get("file_data", None), ) @@ -262,8 +284,7 @@ async def _stream_response(): usage=usage, message_channels=message_channels, filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), - status_code=getattr(invoke_error, "status_code", None) - or status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code=status_code_of(invoke_error), status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}", failure_category=failure_category_of(invoke_error), file_data=request_dict.get("file_data", None), From a02a11cbdf2768b92a3fe949313f8986a2635ab1 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 12 Aug 2026 14:53:37 -0400 Subject: [PATCH 4/9] refactor: consolidate hostile-error attribute guards and quiet the precheck path Share one guarded attribute reader between failure_category_of and status_code_of, apply status_code_of at the UnstructuredIngestError site it missed, compute the function signature once per request, and emit the missing-usage-parameter warning once at wrap time instead of on every request. Reuse existing test scaffolding instead of duplicating it. --- test/api/test_api.py | 16 ++----- .../etl_uvicorn/api_generator.py | 43 +++++++++---------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index fb958b5..b387cb3 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -1,4 +1,3 @@ -import enum from pathlib import Path from typing import Any, Optional, Union @@ -623,12 +622,12 @@ def test_precheck_success_has_no_failure_category(): def test_precheck_ignores_non_string_failure_category(): - class _EnumCategoryFailure(Exception): + class _NonStringCategoryFailure(Exception): status_code = 403 - failure_category = enum.Enum("Category", ["AUTH_PERMISSION_DENIED"]).AUTH_PERMISSION_DENIED + failure_category = 403 def _enum_category_precheck() -> None: - raise _EnumCategoryFailure("credential rejected") + raise _NonStringCategoryFailure("credential rejected") client = TestClient( wrap_in_fastapi( @@ -645,14 +644,7 @@ def _enum_category_precheck() -> None: def test_invoke_reports_failure_category_from_raised_error(): - class _CategorizedError(Exception): - status_code = 403 - failure_category = "AUTH_PERMISSION_DENIED" - - def _raising_func() -> None: - raise _CategorizedError("credential rejected") - - client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + client = TestClient(wrap_in_fastapi(func=_failing_precheck, plugin_id="mock_plugin")) body = client.post("/invoke").json() assert body["status_code"] == 403 diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 13d82b1..457a810 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -62,31 +62,28 @@ def log_func_and_body(func: Callable, body: Optional[str] = None) -> None: logger.log(level=logger.level, msg=msg) -def failure_category_of(error: BaseException) -> Optional[str]: - """Return the error's failure_category only when it is a plain string. +def _error_attr(error: BaseException, name: str) -> Any: + """Read an attribute off a raised error, treating a raising property as absent. - Runs while an exception handler is building the sanitized response, so a - non-string value — or an attribute access that itself raises — is treated - as absent rather than allowed to replace that response with a raw 500. + Runs while an exception handler is building the sanitized response; an + attribute access that itself raises must not replace that response with a + raw 500. """ try: - category = getattr(error, "failure_category", None) + return getattr(error, name, None) except Exception: return None + + +def failure_category_of(error: BaseException) -> Optional[str]: + """Return the error's failure_category only when it is a plain string.""" + category = _error_attr(error, "failure_category") return category if isinstance(category, str) else None def status_code_of(error: BaseException) -> int: - """Return the error's status_code only when it is a usable integer. - - Same contract as failure_category_of: runs inside exception handlers, so - anything other than a plain int falls back to 500 instead of failing - response-model validation. - """ - try: - status_code = getattr(error, "status_code", None) - except Exception: - return status.HTTP_500_INTERNAL_SERVER_ERROR + """Return the error's status_code only when it is a usable integer, else 500.""" + status_code = _error_attr(error, "status_code") if isinstance(status_code, int) and not isinstance(status_code, bool): return status_code return status.HTTP_500_INTERNAL_SERVER_ERROR @@ -168,6 +165,9 @@ def _wrap_in_fastapi( logger.debug(f"set static id response to: {plugin_id}") + if "usage" not in inspect.signature(func).parameters: + logger.warning("usage data not an expected parameter, omitting") + fastapi_app = FastAPI() response_type = get_output_sig(func) @@ -195,13 +195,12 @@ async def wrap_fn(func: Callable, kwargs: Optional[dict[str, Any]] = None) -> Re filedata_meta = FileDataMeta() message_channels = MessageChannels() request_dict = kwargs if kwargs else {} - if "usage" in inspect.signature(func).parameters: + params = inspect.signature(func).parameters + if "usage" in params: request_dict["usage"] = usage - else: - logger.warning("usage data not an expected parameter, omitting") - if "message_channels" in inspect.signature(func).parameters: + if "message_channels" in params: request_dict["message_channels"] = message_channels - if "filedata_meta" in inspect.signature(func).parameters: + if "filedata_meta" in params: request_dict["filedata_meta"] = filedata_meta try: if inspect.isasyncgenfunction(func): @@ -273,7 +272,7 @@ async def _stream_response(): usage=usage, message_channels=message_channels, filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), - status_code=exc.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR, + status_code=status_code_of(exc), status_code_text=str(exc), failure_category=failure_category_of(exc), file_data=request_dict.get("file_data", None), From 9ab9e32b5f777b60bed9a0871051e1f01636ff9c Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 12 Aug 2026 15:06:09 -0400 Subject: [PATCH 5/9] fix: close remaining error-contract gaps in the invoke handlers Clamp status_code_of to the HTTP status range (0 regressed to being served verbatim instead of falling back to 500), read status_code through the guarded accessor in the UnstructuredIngestError log line, and resolve string/postponed annotations before validating precheck signatures. --- test/api/test_api.py | 43 +++++++++++++++++++ .../etl_uvicorn/api_generator.py | 21 ++++++--- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index b387cb3..e683202 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -719,3 +719,46 @@ def _bad_precheck(usage: int) -> None: with pytest.raises(EtlApiException): wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_bad_precheck) + + +def test_invoke_clamps_out_of_range_status_code(): + class _ZeroStatusError(Exception): + status_code = 0 + + def _raising_func() -> None: + raise _ZeroStatusError("boom") + + client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + + assert client.post("/invoke").json()["status_code"] == 500 + + +def test_invoke_survives_ingest_error_with_raising_status_code(): + from unstructured_ingest.error import UnstructuredIngestError + + class _HostileIngestError(UnstructuredIngestError): + @property + def status_code(self) -> int: + raise RuntimeError("status_code exploded") + + def _raising_func() -> None: + raise _HostileIngestError("boom") + + client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + + resp = client.post("/invoke") + assert resp.status_code == 200 + assert resp.json()["status_code"] == 500 + + +def test_precheck_func_accepts_string_annotations(): + def _string_annotated_precheck(usage: "list") -> "None": + return None + + client = TestClient( + wrap_in_fastapi( + func=_no_params, plugin_id="mock_plugin", precheck_func=_string_annotated_precheck + ) + ) + + assert client.get("/precheck").json()["status_code"] == 200 diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 457a810..1359d9e 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -82,9 +82,13 @@ def failure_category_of(error: BaseException) -> Optional[str]: def status_code_of(error: BaseException) -> int: - """Return the error's status_code only when it is a usable integer, else 500.""" + """Return the error's status_code only when it is an int in the HTTP range, else 500.""" status_code = _error_attr(error, "status_code") - if isinstance(status_code, int) and not isinstance(status_code, bool): + if ( + isinstance(status_code, int) + and not isinstance(status_code, bool) + and 100 <= status_code <= 599 + ): return status_code return status.HTTP_500_INTERNAL_SERVER_ERROR @@ -98,7 +102,11 @@ async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) - def check_precheck_func(precheck_func: Callable): - sig = inspect.signature(precheck_func) + try: + # eval_str resolves postponed/string annotations ('list', 'None') + sig = inspect.signature(precheck_func, eval_str=True) + except (NameError, TypeError): + sig = inspect.signature(precheck_func) inputs = list(sig.parameters.values()) outputs = sig.return_annotation if len(inputs) == 1: @@ -265,7 +273,8 @@ async def _stream_response(): ) except UnstructuredIngestError as exc: logger.error( - f"UnstructuredIngestError: {str(exc)} (status_code={exc.status_code})", + f"UnstructuredIngestError: {exc} " + f"(status_code={_error_attr(exc, 'status_code')})", exc_info=True, ) return InvokeResponse( @@ -322,9 +331,7 @@ async def run_job_with_body(request: BaseModel) -> ResponseType: @fastapi_app.post("/invoke", response_model=InvokeResponse) async def run_job(request: Optional[input_schema_model] = None) -> ResponseType: - return await run_job_with_body( - request if request is not None else input_schema_model() - ) + return await run_job_with_body(request if request is not None else input_schema_model()) elif input_schema_model.model_fields: From 8c73bf71d9e6178fc2d48aef3c682d364451e956 Mon Sep 17 00:00:00 2001 From: Nick Franck Date: Wed, 12 Aug 2026 15:10:57 -0400 Subject: [PATCH 6/9] fix: propagate context into synchronous plugin functions run_in_executor does not copy contextvars, so OpenTelemetry context was lost crossing into the worker thread and wide events adopted inside synchronous invoke/precheck functions found no active span. asyncio.to_thread copies the calling context. --- unstructured_platform_plugins/etl_uvicorn/api_generator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 1359d9e..4ee21bb 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -3,7 +3,6 @@ import inspect import json import logging -from functools import partial from typing import Any, Callable, Optional, Union, get_origin from fastapi import FastAPI, HTTPException, status @@ -97,8 +96,10 @@ async def invoke_func(func: Callable, kwargs: Optional[dict[str, Any]] = None) - kwargs = kwargs or {} if inspect.iscoroutinefunction(func): return await func(**kwargs) - else: - return await asyncio.get_event_loop().run_in_executor(None, partial(func, **kwargs)) + # to_thread copies contextvars into the worker thread, so OpenTelemetry + # context (and any wide-event adoption inside func) survives the hop; + # run_in_executor does not. + return await asyncio.to_thread(func, **kwargs) def check_precheck_func(precheck_func: Callable): From fb7451334d8e33c10f0817bf93a927d6fe9c76fe Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:05:18 -0400 Subject: [PATCH 7/9] 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. --- test/api/test_api.py | 37 +++++++++++++++++++ .../etl_uvicorn/api_generator.py | 30 +++++++++++---- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index e683202..0dfd0b3 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -674,6 +674,43 @@ def _raising_func() -> None: assert "original message" in body["status_code_text"] +class _UnrenderableError(Exception): + status_code = 403 + + def __str__(self) -> str: + raise RuntimeError("__str__ exploded") + + +def test_invoke_survives_error_whose_str_raises(): + def _raising_func() -> None: + raise _UnrenderableError() + + client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + + resp = client.post("/invoke") + assert resp.status_code == 200 + body = resp.json() + assert body["status_code"] == 403 + assert "" in body["status_code_text"] + + +def test_precheck_survives_error_whose_str_raises(): + def _unrenderable_precheck() -> None: + raise _UnrenderableError() + + client = TestClient( + wrap_in_fastapi( + func=_no_params, plugin_id="mock_plugin", precheck_func=_unrenderable_precheck + ) + ) + + resp = client.get("/precheck") + assert resp.status_code == 200 + body = resp.json() + assert body["status_code"] == 403 + assert "" in body["status_code_text"] + + def test_invoke_ignores_non_integer_status_code(): class _BadStatusError(Exception): status_code = "not-a-code" diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 4ee21bb..5a45cf3 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -74,6 +74,19 @@ def _error_attr(error: BaseException, name: str) -> Any: return None +def _safe_str(value: object) -> str: + """str() on a plugin-supplied error can itself raise; never let that escape the handler. + + An escape replaces the sanitized envelope with a raw HTTP 500, which the + controller's preflight reads as fail-open — a plugin-reported failure would + silently become a proceed. + """ + try: + return str(value) + except Exception: + return "" + + def failure_category_of(error: BaseException) -> Optional[str]: """Return the error's failure_category only when it is a plain string.""" category = _error_attr(error, "failure_category") @@ -231,7 +244,7 @@ async def _stream_response(): + "\n" ) except Exception as e: - logger.error(f"Failure streaming response: {e}", exc_info=True) + logger.error(f"Failure streaming response: {_safe_str(e)}", exc_info=True) yield ( InvokeResponse( usage=usage, @@ -240,7 +253,7 @@ async def _stream_response(): filedata_meta.model_dump() ), status_code=status_code_of(e), - status_code_text=f"[{e.__class__.__name__}] {e}", + status_code_text=f"[{e.__class__.__name__}] {_safe_str(e)}", failure_category=failure_category_of(e), ).model_dump_json() + "\n" @@ -259,7 +272,8 @@ async def _stream_response(): ) except HTTPException as exc: logger.error( - f"HTTPException: {exc.detail} (status_code={exc.status_code})", exc_info=True + f"HTTPException: {_safe_str(exc.detail)} (status_code={exc.status_code})", + exc_info=True, ) return InvokeResponse( usage=usage, @@ -268,13 +282,13 @@ async def _stream_response(): status_code=exc.status_code, status_code_text=exc.detail if isinstance(exc.detail, str) - else json.dumps(exc.detail, default=str), + else json.dumps(exc.detail, default=_safe_str), failure_category=failure_category_of(exc), file_data=request_dict.get("file_data", None), ) except UnstructuredIngestError as exc: logger.error( - f"UnstructuredIngestError: {exc} " + f"UnstructuredIngestError: {_safe_str(exc)} " f"(status_code={_error_attr(exc, 'status_code')})", exc_info=True, ) @@ -283,18 +297,18 @@ async def _stream_response(): message_channels=message_channels, filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), status_code=status_code_of(exc), - status_code_text=str(exc), + status_code_text=_safe_str(exc), failure_category=failure_category_of(exc), file_data=request_dict.get("file_data", None), ) except Exception as invoke_error: - logger.error(f"failed to invoke plugin: {invoke_error}", exc_info=True) + logger.error(f"failed to invoke plugin: {_safe_str(invoke_error)}", exc_info=True) return InvokeResponse( usage=usage, message_channels=message_channels, filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), status_code=status_code_of(invoke_error), - status_code_text=f"[{invoke_error.__class__.__name__}] {invoke_error}", + status_code_text=f"[{invoke_error.__class__.__name__}] {_safe_str(invoke_error)}", failure_category=failure_category_of(invoke_error), file_data=request_dict.get("file_data", None), ) From 5e43c02637e27995735465f5dd07bef6be051745 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:32:33 -0400 Subject: [PATCH 8/9] fix: read the error class name via type() in the sanitized envelopes Instance __getattribute__ can intercept __class__ access; type() cannot be intercepted, so the class-name interpolation can never escape the handler. --- test/api/test_api.py | 22 +++++++++++++++++++ .../etl_uvicorn/api_generator.py | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index 0dfd0b3..3b222a6 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -694,6 +694,28 @@ def _raising_func() -> None: assert "" in body["status_code_text"] +def test_invoke_survives_error_with_hostile_class_access(): + class _HostileClassError(Exception): + status_code = 403 + + def __getattribute__(self, name: str): + if name == "__class__": + raise RuntimeError("__class__ exploded") + return super().__getattribute__(name) + + def _raising_func() -> None: + raise _HostileClassError("original message") + + client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) + + resp = client.post("/invoke") + assert resp.status_code == 200 + body = resp.json() + assert body["status_code"] == 403 + assert "_HostileClassError" in body["status_code_text"] + assert "original message" in body["status_code_text"] + + def test_precheck_survives_error_whose_str_raises(): def _unrenderable_precheck() -> None: raise _UnrenderableError() diff --git a/unstructured_platform_plugins/etl_uvicorn/api_generator.py b/unstructured_platform_plugins/etl_uvicorn/api_generator.py index 5a45cf3..5534260 100644 --- a/unstructured_platform_plugins/etl_uvicorn/api_generator.py +++ b/unstructured_platform_plugins/etl_uvicorn/api_generator.py @@ -253,7 +253,7 @@ async def _stream_response(): filedata_meta.model_dump() ), status_code=status_code_of(e), - status_code_text=f"[{e.__class__.__name__}] {_safe_str(e)}", + status_code_text=f"[{type(e).__name__}] {_safe_str(e)}", failure_category=failure_category_of(e), ).model_dump_json() + "\n" @@ -308,7 +308,7 @@ async def _stream_response(): message_channels=message_channels, filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()), status_code=status_code_of(invoke_error), - status_code_text=f"[{invoke_error.__class__.__name__}] {_safe_str(invoke_error)}", + status_code_text=f"[{type(invoke_error).__name__}] {_safe_str(invoke_error)}", failure_category=failure_category_of(invoke_error), file_data=request_dict.get("file_data", None), ) From c9a56176f544d7fdefd1b16d380a37bdd2bdd3a2 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:34:33 -0400 Subject: [PATCH 9/9] test: keep the hostile-__class__ error out of captured log records --- test/api/test_api.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/api/test_api.py b/test/api/test_api.py index 3b222a6..61a6c94 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -1,5 +1,7 @@ +import logging from pathlib import Path from typing import Any, Optional, Union +from unittest.mock import patch import pytest from fastapi.testclient import TestClient @@ -708,7 +710,12 @@ def _raising_func() -> None: client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin")) - resp = client.post("/invoke") + # The handler logs the error with exc_info; formatting this exception's + # traceback outside the handler would raise, so keep the record out of + # the captured-log machinery. + with patch.object(logging.getLogger("uvicorn.error"), "error"): + resp = client.post("/invoke") + assert resp.status_code == 200 body = resp.json() assert body["status_code"] == 403