Skip to content

Commit 5f6aec9

Browse files
CyMuleNick Franck
andauthored
feat(etl-uvicorn): carry failure_category through invoke and precheck responses (#77)
## Summary - add optional `failure_category` to `InvokeResponse` and `InvokePrecheckResponse`, populated from a `failure_category` attribute on the raised error (same pattern as the existing `status_code` pickup), and thread it through the `/precheck` route - lets a plugin's precheck report a preflight failure category through the standard `precheck_func=` wiring; today the pydantic response model silently drops the field, forcing plugins that need it to bypass the SDK-installed precheck route ## Testing - `PYTHONPATH=. uv run pytest test/api/test_api.py` — 43 passed, including new coverage for a failing precheck carrying `failure_category` and a passing precheck leaving it null - ruff check clean (the one `ruff format` complaint in `api_generator.py` predates this change) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/Unstructured-IO/unstructured-platform-plugins/pull/77?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Nick Franck <nickfranck@unstructured.io>
1 parent 6a88783 commit 5f6aec9

2 files changed

Lines changed: 336 additions & 30 deletions

File tree

test/api/test_api.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import logging
12
from pathlib import Path
23
from typing import Any, Optional, Union
4+
from unittest.mock import patch
35

46
import pytest
57
from fastapi.testclient import TestClient
@@ -581,3 +583,248 @@ def test_no_param_plugin_still_accepts_a_bodyless_post():
581583

582584
assert resp.status_code == 200
583585
assert InvokeResponse.model_validate(resp.json()).output["received"] == "ok"
586+
587+
588+
class _PrecheckFailure(Exception):
589+
status_code = 403
590+
failure_category = "AUTH_PERMISSION_DENIED"
591+
592+
593+
def _failing_precheck() -> None:
594+
raise _PrecheckFailure("credential rejected")
595+
596+
597+
def _passing_precheck() -> None:
598+
return None
599+
600+
601+
def test_precheck_reports_failure_category_from_raised_error():
602+
client = TestClient(
603+
wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_failing_precheck)
604+
)
605+
606+
resp = client.get("/precheck")
607+
608+
body = resp.json()
609+
assert body["status_code"] == 403
610+
assert body["failure_category"] == "AUTH_PERMISSION_DENIED"
611+
assert "credential rejected" in body["status_code_text"]
612+
613+
614+
def test_precheck_success_has_no_failure_category():
615+
client = TestClient(
616+
wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_passing_precheck)
617+
)
618+
619+
resp = client.get("/precheck")
620+
621+
body = resp.json()
622+
assert body["status_code"] == 200
623+
assert body["failure_category"] is None
624+
625+
626+
def test_precheck_ignores_non_string_failure_category():
627+
class _NonStringCategoryFailure(Exception):
628+
status_code = 403
629+
failure_category = 403
630+
631+
def _enum_category_precheck() -> None:
632+
raise _NonStringCategoryFailure("credential rejected")
633+
634+
client = TestClient(
635+
wrap_in_fastapi(
636+
func=_no_params, plugin_id="mock_plugin", precheck_func=_enum_category_precheck
637+
)
638+
)
639+
640+
resp = client.get("/precheck")
641+
642+
body = resp.json()
643+
assert body["status_code"] == 403
644+
assert body["failure_category"] is None
645+
assert "credential rejected" in body["status_code_text"]
646+
647+
648+
def test_invoke_reports_failure_category_from_raised_error():
649+
client = TestClient(wrap_in_fastapi(func=_failing_precheck, plugin_id="mock_plugin"))
650+
651+
body = client.post("/invoke").json()
652+
assert body["status_code"] == 403
653+
assert body["failure_category"] == "AUTH_PERMISSION_DENIED"
654+
655+
656+
def test_invoke_sanitizes_raising_error_attributes():
657+
class _HostileError(Exception):
658+
@property
659+
def status_code(self) -> int:
660+
raise RuntimeError("status_code exploded")
661+
662+
@property
663+
def failure_category(self) -> str:
664+
raise RuntimeError("failure_category exploded")
665+
666+
def _raising_func() -> None:
667+
raise _HostileError("original message")
668+
669+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
670+
671+
resp = client.post("/invoke")
672+
assert resp.status_code == 200
673+
body = resp.json()
674+
assert body["status_code"] == 500
675+
assert body["failure_category"] is None
676+
assert "original message" in body["status_code_text"]
677+
678+
679+
class _UnrenderableError(Exception):
680+
status_code = 403
681+
682+
def __str__(self) -> str:
683+
raise RuntimeError("__str__ exploded")
684+
685+
686+
def test_invoke_survives_error_whose_str_raises():
687+
def _raising_func() -> None:
688+
raise _UnrenderableError()
689+
690+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
691+
692+
resp = client.post("/invoke")
693+
assert resp.status_code == 200
694+
body = resp.json()
695+
assert body["status_code"] == 403
696+
assert "<unrenderable error>" in body["status_code_text"]
697+
698+
699+
def test_invoke_survives_error_with_hostile_class_access():
700+
class _HostileClassError(Exception):
701+
status_code = 403
702+
703+
def __getattribute__(self, name: str):
704+
if name == "__class__":
705+
raise RuntimeError("__class__ exploded")
706+
return super().__getattribute__(name)
707+
708+
def _raising_func() -> None:
709+
raise _HostileClassError("original message")
710+
711+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
712+
713+
# The handler logs the error with exc_info; formatting this exception's
714+
# traceback outside the handler would raise, so keep the record out of
715+
# the captured-log machinery.
716+
with patch.object(logging.getLogger("uvicorn.error"), "error"):
717+
resp = client.post("/invoke")
718+
719+
assert resp.status_code == 200
720+
body = resp.json()
721+
assert body["status_code"] == 403
722+
assert "_HostileClassError" in body["status_code_text"]
723+
assert "original message" in body["status_code_text"]
724+
725+
726+
def test_precheck_survives_error_whose_str_raises():
727+
def _unrenderable_precheck() -> None:
728+
raise _UnrenderableError()
729+
730+
client = TestClient(
731+
wrap_in_fastapi(
732+
func=_no_params, plugin_id="mock_plugin", precheck_func=_unrenderable_precheck
733+
)
734+
)
735+
736+
resp = client.get("/precheck")
737+
assert resp.status_code == 200
738+
body = resp.json()
739+
assert body["status_code"] == 403
740+
assert "<unrenderable error>" in body["status_code_text"]
741+
742+
743+
def test_invoke_ignores_non_integer_status_code():
744+
class _BadStatusError(Exception):
745+
status_code = "not-a-code"
746+
747+
def _raising_func() -> None:
748+
raise _BadStatusError("boom")
749+
750+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
751+
752+
body = client.post("/invoke").json()
753+
assert body["status_code"] == 500
754+
755+
756+
def test_invoke_serializes_non_string_http_exception_detail():
757+
from fastapi import HTTPException
758+
759+
def _raising_func() -> None:
760+
raise HTTPException(status_code=422, detail=["field a", "field b"])
761+
762+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
763+
764+
body = client.post("/invoke").json()
765+
assert body["status_code"] == 422
766+
assert body["status_code_text"] == '["field a", "field b"]'
767+
768+
769+
def test_precheck_func_may_take_a_usage_list_parameter():
770+
def _usage_precheck(usage: list) -> None:
771+
return None
772+
773+
client = TestClient(
774+
wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_usage_precheck)
775+
)
776+
777+
assert client.get("/precheck").json()["status_code"] == 200
778+
779+
780+
def test_precheck_func_with_non_list_usage_parameter_is_rejected():
781+
from unstructured_platform_plugins.etl_uvicorn.api_generator import EtlApiException
782+
783+
def _bad_precheck(usage: int) -> None:
784+
return None
785+
786+
with pytest.raises(EtlApiException):
787+
wrap_in_fastapi(func=_no_params, plugin_id="mock_plugin", precheck_func=_bad_precheck)
788+
789+
790+
def test_invoke_clamps_out_of_range_status_code():
791+
class _ZeroStatusError(Exception):
792+
status_code = 0
793+
794+
def _raising_func() -> None:
795+
raise _ZeroStatusError("boom")
796+
797+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
798+
799+
assert client.post("/invoke").json()["status_code"] == 500
800+
801+
802+
def test_invoke_survives_ingest_error_with_raising_status_code():
803+
from unstructured_ingest.error import UnstructuredIngestError
804+
805+
class _HostileIngestError(UnstructuredIngestError):
806+
@property
807+
def status_code(self) -> int:
808+
raise RuntimeError("status_code exploded")
809+
810+
def _raising_func() -> None:
811+
raise _HostileIngestError("boom")
812+
813+
client = TestClient(wrap_in_fastapi(func=_raising_func, plugin_id="mock_plugin"))
814+
815+
resp = client.post("/invoke")
816+
assert resp.status_code == 200
817+
assert resp.json()["status_code"] == 500
818+
819+
820+
def test_precheck_func_accepts_string_annotations():
821+
def _string_annotated_precheck(usage: "list") -> "None":
822+
return None
823+
824+
client = TestClient(
825+
wrap_in_fastapi(
826+
func=_no_params, plugin_id="mock_plugin", precheck_func=_string_annotated_precheck
827+
)
828+
)
829+
830+
assert client.get("/precheck").json()["status_code"] == 200

0 commit comments

Comments
 (0)