Skip to content

Commit c1b153f

Browse files
authored
feat: expose the API's declared error verdict as typed attributes (#360)
## What HTTP errors from the client now carry the verdict the API declared, as typed attributes. Callers no longer have to re-parse `err.body`. ```python # Response: 503 {"detail": "OCR timed out", "retryable": false, # "error_type": "timeout", "error_reason": "ocr_timeout", "audience": "platform"} # Before err.retryable # AttributeError json.loads(err.body).get("retryable") # False, but every caller re-implements the parsing # After err.retryable # False err.error_type # "timeout" err.error_reason # "ocr_timeout" err.audience # "platform" ``` | Error body | `retryable` / `error_type` / `error_reason` / `audience` | |---|---| | Declares all four fields | the declared values | | `{"detail": "Internal server error"}` (declares nothing) | all `None` | | A field with the wrong type, e.g. `"retryable": "false"` | that field is `None` | | Not JSON, or not a JSON object | all `None` | The attributes are on `UnstructuredClientError`, so they work for every HTTP error class at any status (`SDKError` for 4xx, `ServerError`, `HTTPValidationError`). That includes the response a failed split-PDF chunk returns. ## Why The API publishes `retryable`, `error_type`, `error_reason` and `audience` next to `detail` on errors it raises deliberately. The client's error models only expose `detail`, so any caller that wants to act on the verdict has to parse the raw body itself. Each caller then re-implements the same type checks and tends to get the edge cases slightly different. The retry loop already reads `retryable` to stop retrying a declared non-retryable error (0.46.4). The loop and the error attributes now share one parser, so they cannot disagree about what a body declares. Both decode the response bytes, so a UTF-8 BOM or a UTF-16 body is read the same way `response.json()` reads it. ## Notes for consumers - Additive only. No existing attribute, message or retry decision changes. - A `ResponseValidationError` raised on a 2xx response always reads `None`, and its (possibly large) body is not parsed again. - An unread streamed response is never consumed to read the verdict. ## Validation - Unit suite: 403 passed, 1 xfailed. `make lint` is clean (pylint 10/10, mypy). - New tests cover the parser's type rules, each error class with UTF-8, UTF-8-BOM and UTF-16 bodies, the unread-stream fallback, 2xx validation errors, and public `partition` / `partition_async` (400 and 503) through a mocked transport. - Four rounds of Codex review. Rounds 2 and 3 found that decoding text instead of bytes lost JSON encoding detection, in the retry check and then in the error attributes. Both are fixed with tests. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/Unstructured-IO/unstructured-python-client/pull/360?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="View guided diff" src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent 27553e2 commit c1b153f

7 files changed

Lines changed: 236 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## 0.47.0
22

33
### Features
4+
* Expose the verdict the API declares on an error as typed attributes on every HTTP error: `retryable`, `error_type`, `error_reason` and `audience`. Errors the API raises deliberately publish these fields next to `detail`, eg a 503 OCR timeout with `"retryable": false`. Callers no longer need to parse `err.body` to read them. Each attribute is `None` when the body is not JSON, the field is absent, or its value has the wrong type. The retry loop reads `retryable` with the same parser.
45
* Add `skip_preflight` to `CreateWorkflow` and `UpdateWorkflow`. Set it to `True` to skip the job preflight check for a workflow's jobs; on update, leaving it unset keeps the current value. `WorkflowInformation.skip_preflight` reports the workflow's current setting. For a one-off job, set `"skip_preflight": true` in the `request_data` JSON passed to `jobs.create_job`.
56

67
### Fixes

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ with UnstructuredClient(
156156
| `err.body` | `str` | HTTP body. Can be empty string if no body is returned. |
157157
| `err.raw_response` | `httpx.Response` | Raw HTTP response |
158158
| `err.data` | | Optional. Some errors may contain structured data. [See Error Classes](#error-classes). |
159+
| `err.retryable` | `Optional[bool]` | Whether the API declared that sending the same request again can succeed. `None` if it declared nothing. |
160+
| `err.error_type` | `Optional[str]` | The kind of failure the API declared, eg `timeout`. `None` if it declared nothing. |
161+
| `err.error_reason` | `Optional[str]` | A stable name for the failure the API declared. `None` if it declared nothing. |
162+
| `err.audience` | `Optional[str]` | Whose fault the API declared the failure to be, `user` or `platform`. `None` if it declared nothing. |
159163

160164
### Example
161165
```python
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import asyncio
2+
import json
3+
4+
import httpx
5+
import pytest
6+
7+
from unstructured_client import UnstructuredClient
8+
from unstructured_client.models.errors import (
9+
HTTPValidationError,
10+
HTTPValidationErrorData,
11+
ResponseValidationError,
12+
SDKError,
13+
ServerError,
14+
ServerErrorData,
15+
)
16+
from unstructured_client.utils import BackoffStrategy, RetryConfig
17+
from unstructured_client.utils.declared_errors import (
18+
DeclaredErrorFields,
19+
parse_declared_error_fields,
20+
)
21+
22+
DECLARED_BODY = {
23+
"detail": "OCR timed out",
24+
"retryable": False,
25+
"error_type": "timeout",
26+
"error_reason": "ocr_timeout",
27+
"audience": "platform",
28+
}
29+
DECLARED_FIELDS = DeclaredErrorFields(
30+
retryable=False, error_type="timeout", error_reason="ocr_timeout", audience="platform"
31+
)
32+
33+
34+
def _response(status_code: int, **kwargs) -> httpx.Response:
35+
return httpx.Response(
36+
status_code,
37+
request=httpx.Request("POST", "http://test.invalid/general/v0/general"),
38+
**kwargs,
39+
)
40+
41+
42+
def _declared(error) -> DeclaredErrorFields:
43+
return DeclaredErrorFields(
44+
retryable=error.retryable,
45+
error_type=error.error_type,
46+
error_reason=error.error_reason,
47+
audience=error.audience,
48+
)
49+
50+
51+
@pytest.mark.parametrize(
52+
"text,expected",
53+
[
54+
('{"detail":"x","retryable":false,"error_type":"timeout",'
55+
'"error_reason":"ocr_timeout","audience":"platform"}', DECLARED_FIELDS),
56+
('{"retryable":true}', DeclaredErrorFields(retryable=True)),
57+
('{"detail":"x","retryable":false,"audience":null}', DeclaredErrorFields(retryable=False)),
58+
('{"detail":"Internal server error"}', DeclaredErrorFields()),
59+
('{"retryable":"false","error_type":5,"error_reason":" ","audience":[]}',
60+
DeclaredErrorFields()),
61+
('{"retryable":0}', DeclaredErrorFields()),
62+
('[{"retryable":false}]', DeclaredErrorFields()),
63+
("false", DeclaredErrorFields()),
64+
("{broken", DeclaredErrorFields()),
65+
("upstream error", DeclaredErrorFields()),
66+
("", DeclaredErrorFields()),
67+
(None, DeclaredErrorFields()),
68+
pytest.param(
69+
'{"retryable":false,"detail":' + "[" * 100_000 + "]" * 100_000 + "}",
70+
DeclaredErrorFields(),
71+
id="deeply-nested",
72+
),
73+
],
74+
)
75+
def test_parse_declared_error_fields(text, expected):
76+
assert parse_declared_error_fields(text) == expected
77+
78+
79+
@pytest.mark.parametrize(
80+
"build",
81+
[
82+
lambda r: ServerError(ServerErrorData(detail="OCR timed out"), r),
83+
lambda r: SDKError("API error occurred", r, r.text),
84+
lambda r: HTTPValidationError(HTTPValidationErrorData(detail="OCR timed out"), r),
85+
],
86+
ids=["ServerError", "SDKError", "HTTPValidationError"],
87+
)
88+
@pytest.mark.parametrize(
89+
"content",
90+
[
91+
json.dumps(DECLARED_BODY).encode(),
92+
b"\xef\xbb\xbf" + json.dumps(DECLARED_BODY).encode(),
93+
json.dumps(DECLARED_BODY).encode("utf-16"),
94+
],
95+
ids=["utf-8", "utf-8-bom", "utf-16"],
96+
)
97+
def test_error_classes_expose_declared_fields(build, content):
98+
response = _response(503, content=content, headers={"Content-Type": "application/json"})
99+
100+
assert _declared(build(response)) == DECLARED_FIELDS
101+
102+
103+
def test_unread_stream_falls_back_to_body():
104+
class Stream(httpx.SyncByteStream):
105+
def __iter__(self):
106+
pytest.fail("error construction read an unread stream")
107+
yield b""
108+
109+
response = _response(503, stream=Stream())
110+
error = SDKError("API error occurred", response, json.dumps(DECLARED_BODY))
111+
112+
assert _declared(error) == DECLARED_FIELDS
113+
114+
115+
def test_success_response_declares_nothing():
116+
response = _response(200, json=DECLARED_BODY)
117+
error = ResponseValidationError("Response validation failed", response, ValueError())
118+
119+
assert _declared(error) == DeclaredErrorFields()
120+
121+
122+
@pytest.mark.parametrize("async_mode", [False, True])
123+
@pytest.mark.parametrize("status_code,error_class", [(400, SDKError), (503, ServerError)])
124+
def test_public_partition_raises_declared_fields(async_mode, status_code, error_class):
125+
def respond(request):
126+
return httpx.Response(status_code, json=DECLARED_BODY)
127+
128+
transport = httpx.MockTransport(respond)
129+
request = {"partition_parameters": {
130+
"files": {"content": b"hello", "file_name": "test.txt"},
131+
"split_pdf_page": False,
132+
}}
133+
retries = RetryConfig("none", BackoffStrategy(1, 1, 1.0, 1), False)
134+
135+
async def run_async():
136+
async with httpx.AsyncClient(transport=transport) as async_client:
137+
sdk = UnstructuredClient(server_url="http://test.invalid", async_client=async_client)
138+
await sdk.general.partition_async(request=request, retries=retries)
139+
140+
with pytest.raises(error_class) as exc:
141+
if async_mode:
142+
asyncio.run(run_async())
143+
else:
144+
with httpx.Client(transport=transport) as client:
145+
sdk = UnstructuredClient(server_url="http://test.invalid", client=client)
146+
sdk.general.partition(request=request, retries=retries)
147+
148+
assert _declared(exc.value) == DECLARED_FIELDS

‎_test_unstructured_client/unit/test_retries.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,8 @@ async def run():
599599
(b'upstream error', "text/plain", False),
600600
(b'{"retryable":false}', "text/plain", True),
601601
(b'{"retryable":false}', "", True),
602+
(b'\xef\xbb\xbf{"retryable":false}', "application/json", True),
603+
('{"retryable":false}'.encode("utf-16"), "application/json", True),
602604
],
603605
)
604606
def test_declared_retry_verdict(

‎src/unstructured_client/models/errors/unstructuredclienterror.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
22

33
import httpx
4-
from typing import Optional
4+
from typing import Optional, Union
5+
6+
from unstructured_client.utils.declared_errors import (
7+
DeclaredErrorFields,
8+
parse_declared_error_fields,
9+
)
510

611

712
class UnstructuredClientError(Exception):
@@ -12,6 +17,10 @@ class UnstructuredClientError(Exception):
1217
body: str
1318
headers: httpx.Headers
1419
raw_response: httpx.Response
20+
retryable: Optional[bool]
21+
error_type: Optional[str]
22+
error_reason: Optional[str]
23+
audience: Optional[str]
1524

1625
def __init__(
1726
self, message: str, raw_response: httpx.Response, body: Optional[str] = None
@@ -22,5 +31,24 @@ def __init__(
2231
self.headers = raw_response.headers
2332
self.raw_response = raw_response
2433

34+
# A success response that failed validation can carry a large body that
35+
# declares nothing, so only error statuses are parsed.
36+
declared = (
37+
parse_declared_error_fields(self._raw_body())
38+
if self.status_code >= 400
39+
else DeclaredErrorFields()
40+
)
41+
self.retryable = declared.retryable
42+
self.error_type = declared.error_type
43+
self.error_reason = declared.error_reason
44+
self.audience = declared.audience
45+
46+
def _raw_body(self) -> Union[str, bytes]:
47+
# The response bytes keep the JSON encoding detection that decoded text loses.
48+
try:
49+
return self.raw_response.content
50+
except httpx.ResponseNotRead:
51+
return self.body
52+
2553
def __str__(self):
2654
return self.message
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""The verdict the Unstructured API declares beside ``detail`` in an error body.
2+
3+
Errors the API raises deliberately carry ``retryable``, ``error_type``,
4+
``error_reason`` and ``audience`` next to ``detail``. Errors it did not classify
5+
carry only ``detail``, and every field reads as ``None``.
6+
"""
7+
8+
import json
9+
from dataclasses import dataclass
10+
from typing import Any, Optional, Union
11+
12+
13+
@dataclass(frozen=True)
14+
class DeclaredErrorFields:
15+
retryable: Optional[bool] = None
16+
error_type: Optional[str] = None
17+
error_reason: Optional[str] = None
18+
audience: Optional[str] = None
19+
20+
21+
def _non_empty_str(value: Any) -> Optional[str]:
22+
return value if isinstance(value, str) and value.strip() else None
23+
24+
25+
def _declared_error_fields(body: Any) -> DeclaredErrorFields:
26+
if not isinstance(body, dict):
27+
return DeclaredErrorFields()
28+
retryable = body.get("retryable")
29+
return DeclaredErrorFields(
30+
retryable=retryable if isinstance(retryable, bool) else None,
31+
error_type=_non_empty_str(body.get("error_type")),
32+
error_reason=_non_empty_str(body.get("error_reason")),
33+
audience=_non_empty_str(body.get("audience")),
34+
)
35+
36+
37+
def parse_declared_error_fields(raw: Union[str, bytes, None]) -> DeclaredErrorFields:
38+
"""Read the declared fields from a raw body, dropping any of the wrong type.
39+
40+
Bytes are decoded as JSON, which detects UTF-8, UTF-16 and UTF-32 and skips a
41+
UTF-8 byte order mark. A body that is not a JSON object declares nothing.
42+
"""
43+
try:
44+
body = json.loads(raw) if raw else None
45+
except (ValueError, TypeError, RecursionError):
46+
return DeclaredErrorFields()
47+
return _declared_error_fields(body)

‎src/unstructured_client/utils/retries.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import httpx
99

10+
from unstructured_client.utils.declared_errors import parse_declared_error_fields
11+
1012

1113
class BackoffStrategy:
1214
initial_interval: int
@@ -90,10 +92,10 @@ def __init__(self, inner: Exception):
9092
def _declares_non_retryable(response: httpx.Response) -> bool:
9193
# Inspect buffered error bodies without consuming unread response streams.
9294
try:
93-
body = response.json()
94-
except (ValueError, RecursionError, httpx.ResponseNotRead):
95+
content = response.content
96+
except httpx.ResponseNotRead:
9597
return False
96-
return isinstance(body, dict) and body.get("retryable") is False
98+
return parse_declared_error_fields(content).retryable is False
9799

98100

99101
def _is_retry_status(response: httpx.Response, status_codes: List[str]) -> bool:

0 commit comments

Comments
 (0)