Skip to content

Commit 2d66570

Browse files
committed
feat(etl-uvicorn): declare blame in failure responses instead of encoding it in status codes
Status codes carry transport semantics for the immediate caller and cannot also carry business blame: a 422 for a malformed reserved field (composed by the platform) and a 422 for a customer's unreadable file are different faults wearing the same number. Failure responses now say whose fault it is explicitly: - the invoke envelope gains an optional `blame`, set to "user" only when the plugin raised the UserError family — a fault in something the customer owns. Absent means not-the-customer's: an orchestrator must never infer customer fault from the status class alone. - middleware error bodies carry the invocation-settings taxonomy `reason` code alongside `detail`, so an orchestrator can recognize a platform-composed payload failure whatever status answered the hop.
1 parent a17268c commit 2d66570

5 files changed

Lines changed: 74 additions & 11 deletions

File tree

test/api/test_api.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class InvokeResponse(BaseModel):
2424
status_code: int
2525
filedata_meta: FileDataMeta
2626
status_code_text: Optional[str] = None
27+
blame: Optional[str] = None
2728
output: Optional[Any] = None
2829
file_data: Optional[Union[FileData, BatchFileData]] = None
2930

@@ -220,6 +221,38 @@ def test_http_exception_handling(file_data):
220221
assert invoke_response.status_code_text == "Not found"
221222

222223

224+
@pytest.mark.parametrize(
225+
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
226+
)
227+
def test_user_error_declares_user_blame(file_data):
228+
"""Only the UserError family may claim the failure is the customer's to fix."""
229+
from test.assets.exception_status_code import function_raises_user_error as test_fn
230+
231+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
232+
233+
resp = client.post("/invoke", json={"file_data": file_data.model_dump()})
234+
invoke_response = InvokeResponse.model_validate(resp.json())
235+
236+
assert invoke_response.status_code >= 400
237+
assert invoke_response.blame == "user"
238+
239+
240+
@pytest.mark.parametrize(
241+
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
242+
)
243+
def test_non_user_failures_declare_no_blame(file_data):
244+
"""Anything undeclared is not the customer's: an orchestrator must not infer customer fault
245+
from the status code, which also carries transport semantics."""
246+
from test.assets.exception_status_code import function_raises_provider_error as test_fn
247+
248+
client = TestClient(wrap_in_fastapi(func=test_fn, plugin_id="mock_plugin"))
249+
250+
resp = client.post("/invoke", json={"file_data": file_data.model_dump()})
251+
invoke_response = InvokeResponse.model_validate(resp.json())
252+
253+
assert invoke_response.blame is None
254+
255+
223256
@pytest.mark.parametrize(
224257
"file_data", mock_file_data, ids=[type(fd).__name__ for fd in mock_file_data]
225258
)

test/api/test_invocation_middleware.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,9 @@ def test_non_dict_reserved_field_is_rejected(self):
224224

225225
assert downstream.body is None
226226
assert sent[0]["status"] == 422
227-
assert b"invocation_settings" in sent[1]["body"]
227+
body = json.loads(sent[1]["body"])
228+
assert "invocation_settings" in body["detail"]
229+
assert body["reason"] == "malformed_envelope"
228230

229231
def test_context_with_an_unreadable_schema_version_fails_as_platform_error(self):
230232
# Absence means "older caller, use the boot settings"; a context this plugin cannot read
@@ -236,8 +238,10 @@ def test_context_with_an_unreadable_schema_version_fails_as_platform_error(self)
236238

237239
assert downstream.body is None
238240
assert sent[0]["status"] == 500
239-
assert b"invocation_context" in sent[1]["body"]
240-
assert b"UnsupportedContextVersionError" in sent[1]["body"]
241+
body = json.loads(sent[1]["body"])
242+
assert "invocation_context" in body["detail"]
243+
assert "UnsupportedContextVersionError" in body["detail"]
244+
assert body["reason"] == "unsupported_context_version"
241245

242246
def test_malformed_context_is_rejected(self):
243247
downstream, sent = _run_middleware(

test/assets/exception_status_code.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,15 @@ async def async_gen_function_raises_unstructured_ingest_error_with_none_status_c
137137
error = UnstructuredIngestError("Async gen test UnstructuredIngestError with None status_code")
138138
error.status_code = None
139139
raise error
140+
141+
142+
def function_raises_user_error() -> None:
143+
from unstructured_ingest.error import UserError
144+
145+
raise UserError("Customer-owned resource rejected the request")
146+
147+
148+
def function_raises_provider_error() -> None:
149+
from unstructured_ingest.error import ProviderError
150+
151+
raise ProviderError("Upstream provider failed")

unstructured_platform_plugins/etl_uvicorn/api_generator.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from starlette.responses import RedirectResponse
1515
from typing_extensions import deprecated
1616
from unstructured_ingest.data_types.file_data import BatchFileData, FileData, file_data_from_dict
17-
from unstructured_ingest.error import UnstructuredIngestError
17+
from unstructured_ingest.error import UnstructuredIngestError, UserError
1818
from uvicorn.config import LOG_LEVELS
1919
from uvicorn.importer import import_from_string
2020

@@ -164,6 +164,11 @@ class InvokeResponse(BaseModel):
164164
file_data: Optional[FileDataType] = None
165165
filedata_meta: Optional[filedata_meta_model] = None
166166
status_code_text: Optional[str] = None
167+
# Who must act on a failure: "user" only when the plugin raised the UserError family —
168+
# a fault in something the customer owns (their file, their credentials, their provider).
169+
# Absent means not-the-customer's: an orchestrator must never infer customer fault from
170+
# the status code alone, which also carries transport semantics.
171+
blame: Optional[str] = None
167172
output: Optional[response_type] = None
168173
message_channels: MessageChannels = Field(default_factory=MessageChannels)
169174

@@ -258,6 +263,7 @@ async def _stream_response():
258263
filedata_meta=filedata_meta_model.model_validate(filedata_meta.model_dump()),
259264
status_code=exc.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR,
260265
status_code_text=str(exc),
266+
blame="user" if isinstance(exc, UserError) else None,
261267
file_data=request_dict.get("file_data", None),
262268
)
263269
except Exception as invoke_error:

unstructured_platform_plugins/invocation_settings.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
RESERVED_ENVELOPE_KEY,
3434
InvocationContext,
3535
InvocationSettingsError,
36+
MalformedEnvelopeError,
3637
extract_context,
3738
http_status_for,
3839
resolve_invocation_settings,
@@ -196,7 +197,14 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
196197
if isinstance(parsed, dict):
197198
raw_settings = parsed.get(RESERVED_ENVELOPE_KEY)
198199
if RESERVED_ENVELOPE_KEY in parsed and not isinstance(raw_settings, dict):
199-
await _send_json(send, 422, {"detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}"})
200+
await _send_json(
201+
send,
202+
422,
203+
{
204+
"detail": f"Invalid field: {RESERVED_ENVELOPE_KEY}",
205+
"reason": MalformedEnvelopeError.reason,
206+
},
207+
)
200208
return
201209
# Resolved even when the field — or the whole JSON object — is absent:
202210
# resolve_invocation_settings owns the FF_REQUIRE_INVOKE_WITH_SEALED_DAG_NODE_SETTINGS
@@ -210,11 +218,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
210218
# Class name only — never envelope contents, and never the exception's own message,
211219
# which can embed request-controlled values.
212220
logger.warning("unusable %s payload: %s", RESERVED_ENVELOPE_KEY, type(exc).__name__)
213-
await _send_json(
214-
send,
215-
http_status_for(exc),
216-
{"detail": f"Unusable invocation settings: {type(exc).__name__}"},
217-
)
221+
body = {"detail": f"Unusable invocation settings: {type(exc).__name__}"}
222+
reason = getattr(exc, "reason", None)
223+
if isinstance(reason, str):
224+
body["reason"] = reason
225+
await _send_json(send, http_status_for(exc), body)
218226
return
219227
if isinstance(parsed, dict):
220228
try:
@@ -232,7 +240,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
232240
if status == 422
233241
else f"Unusable {RESERVED_CONTEXT_KEY}: {type(exc).__name__}"
234242
)
235-
await _send_json(send, status, {"detail": detail})
243+
await _send_json(send, status, {"detail": detail, "reason": exc.reason})
236244
return
237245

238246
# The joined body and its parsed tree can be tens of MB and are not needed past this point;

0 commit comments

Comments
 (0)