From fe8462a558916e3eb04f6c4d5d840fa4aa0658db Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:19:45 -0400 Subject: [PATCH 1/3] fix: don't retry a response whose body declares retryable false --- CHANGELOG.md | 5 + .../unit/test_request_utils.py | 37 +++++ .../unit/test_retries.py | 150 ++++++++++++++++++ src/unstructured_client/_version.py | 4 +- src/unstructured_client/utils/retries.py | 29 +++- 5 files changed, 219 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c6a13f..73e2c031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.46.4 + +### Fixes +* Return API errors immediately when their JSON body declares `"retryable": false`, instead of retrying them until the retry budget expires. Applies to sync and async requests, including split-PDF chunks. Other errors keep the existing status-based retry behavior. + ## 0.46.3 ### Fixes diff --git a/_test_unstructured_client/unit/test_request_utils.py b/_test_unstructured_client/unit/test_request_utils.py index 8f1d734f..eabd404d 100644 --- a/_test_unstructured_client/unit/test_request_utils.py +++ b/_test_unstructured_client/unit/test_request_utils.py @@ -74,3 +74,40 @@ def test_create_pdf_chunk_request_params(input_form_data, page_number, expected_ assert form_data == expected_form_data + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cached", [False, True]) +@pytest.mark.parametrize("status_code", [500, 503]) +async def test_declared_non_retryable_chunk_sent_once(tmp_path, cached, status_code): + import asyncio + import io + + from unstructured_client._hooks.custom.split_pdf_hook import SplitPdfHook + from unstructured_client.utils import BackoffStrategy, RetryConfig + + body = {"detail": "unsupported chunk", "retryable": False} + attempts = [] + + def respond(request): + attempts.append(request) + return httpx.Response(status_code, json=body) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + response = await SplitPdfHook().call_api_partial( + pdf_chunk_request=httpx.Request( + "POST", "http://test.invalid/general/v0/general", + headers={"Accept": "application/x-ndjson" if cached else "application/json"}, + content=b"pdf chunk", + ), + pdf_chunk_file=io.BytesIO(b"pdf chunk"), + limiter=asyncio.Semaphore(1), + _operation_id="declared-error", chunk_index=0, page_number=1, + async_client=client, + retry_config=RetryConfig("backoff", BackoffStrategy(1, 1, 1, 2), True), + cache_tmp_data_feature=cached, temp_dir_path=str(tmp_path), + ) + assert len(attempts) == 1 + assert response.status_code == status_code + assert response.json() == body + assert list(tmp_path.iterdir()) == [] diff --git a/_test_unstructured_client/unit/test_retries.py b/_test_unstructured_client/unit/test_retries.py index 2a1c89f5..15420a46 100644 --- a/_test_unstructured_client/unit/test_retries.py +++ b/_test_unstructured_client/unit/test_retries.py @@ -555,3 +555,153 @@ async def func(): with pytest.raises(ValueError, match="permanent"): asyncio.run(retry_async(func, retries_config)) assert call_count == 1 + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("status_code", [500, 503]) +@pytest.mark.parametrize("status_codes", [["5xx"], ["500", "503"]]) +@pytest.mark.parametrize( + "body,content_type,stop", + [ + (b'{"detail":"unsupported","retryable":false}', "application/json", True), + (b'{"retryable":false}', "application/problem+json; charset=utf-8", True), + (b'{"retryable":true}', "application/json", False), + (b'{"detail":"Internal server error"}', "application/json", False), + (b'{"retryable":"false"}', "application/json", False), + (b'{"retryable":0}', "application/json", False), + (b'{"retryable":null}', "application/json", False), + (b'[{"retryable":false}]', "application/json", False), + (b'false', "application/json", False), + (b'{broken', "application/json", False), + (b'upstream error', "text/plain", False), + (b'{"retryable":false}', "text/plain", True), + (b'{"retryable":false}', "", True), + ], +) +def test_declared_retry_verdict( + fake_clock, async_mode, status_code, status_codes, body, content_type, stop +): + attempts = [] + + def respond(request): + attempts.append(request) + if len(attempts) == 1: + return httpx.Response(status_code, content=body, headers={"Content-Type": content_type}) + return httpx.Response(200, json=[]) + + config = _retries(status_codes=status_codes, initial_interval=1, max_interval=1) + transport = httpx.MockTransport(respond) + if async_mode: + async def run(): + async with httpx.AsyncClient(transport=transport) as client: + return await retry_async(lambda: client.get("http://test.invalid"), config) + result = asyncio.run(run()) + else: + with httpx.Client(transport=transport) as client: + result = retry(lambda: client.get("http://test.invalid"), config) + + assert len(attempts) == (1 if stop else 2) + assert result.status_code == (status_code if stop else 200) + if stop: + assert result.content == body + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("verdict", [False, True]) +def test_declared_verdict_does_not_retry_non_matching_status(fake_clock, async_mode, verdict): + attempts = [] + + def respond(request): + attempts.append(request) + return httpx.Response(400, json={"retryable": verdict}) + + config = _retries(status_codes=["5xx"]) + transport = httpx.MockTransport(respond) + if async_mode: + async def run(): + async with httpx.AsyncClient(transport=transport) as client: + return await retry_async(lambda: client.get("http://test.invalid"), config) + response = asyncio.run(run()) + else: + with httpx.Client(transport=transport) as client: + response = retry(lambda: client.get("http://test.invalid"), config) + assert response.status_code == 400 + assert len(attempts) == 1 + + +@pytest.mark.parametrize("async_mode", [False, True]) +def test_unread_stream_is_not_consumed(fake_clock, async_mode): + class SyncStream(httpx.SyncByteStream): + def __iter__(self): + pytest.fail("retry inspected an unread stream") + yield b"" + + class AsyncStream(httpx.AsyncByteStream): + async def __aiter__(self): + pytest.fail("retry inspected an unread stream") + yield b"" + + attempts = [] + + def respond(request): + attempts.append(request) + if len(attempts) == 1: + return httpx.Response( + 503, headers={"Content-Type": "application/json"}, + stream=AsyncStream() if async_mode else SyncStream(), + ) + return httpx.Response(200) + + config = _retries(status_codes=["5xx"]) + transport = httpx.MockTransport(respond) + if async_mode: + async def run(): + async with httpx.AsyncClient(transport=transport) as client: + return await retry_async( + lambda: client.send(client.build_request("GET", "http://test.invalid"), stream=True), config + ) + result = asyncio.run(run()) + else: + with httpx.Client(transport=transport) as client: + result = retry( + lambda: client.send(client.build_request("GET", "http://test.invalid"), stream=True), config + ) + assert result.status_code == 200 + assert len(attempts) == 2 + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("status_code", [500, 503]) +def test_public_partition_preserves_declared_error(fake_clock, async_mode, status_code): + from unstructured_client import UnstructuredClient + from unstructured_client.models.errors import ServerError + + body = {"detail": "unsupported document", "retryable": False} + attempts = [] + + def respond(request): + attempts.append(request) + return httpx.Response(status_code, json=body) + + async def run(): + transport = httpx.MockTransport(respond) + with httpx.Client(transport=transport) as client: + async with httpx.AsyncClient(transport=transport) as async_client: + sdk = UnstructuredClient( + server_url="http://test.invalid", client=client, async_client=async_client + ) + request = {"partition_parameters": { + "files": {"content": b"hello", "file_name": "test.txt"}, + "split_pdf_page": False, + }} + config = _retries(initial_interval=1, max_interval=1, max_elapsed_time=2).config + with pytest.raises(ServerError) as exc: + if async_mode: + await sdk.general.partition_async(request=request, retries=config) + else: + sdk.general.partition(request=request, retries=config) + assert exc.value.raw_response.json() == body + assert "unsupported document" in str(exc.value) + + asyncio.run(run()) + assert len(attempts) == 1 diff --git a/src/unstructured_client/_version.py b/src/unstructured_client/_version.py index a4d5118a..43ed3386 100644 --- a/src/unstructured_client/_version.py +++ b/src/unstructured_client/_version.py @@ -3,10 +3,10 @@ import importlib.metadata __title__: str = "unstructured-client" -__version__: str = "0.46.3" +__version__: str = "0.46.4" __openapi_doc_version__: str = "1.2.31" __gen_version__: str = "2.680.0" -__user_agent__: str = "speakeasy-sdk/python 0.46.3 2.680.0 1.2.31 unstructured-client" +__user_agent__: str = "speakeasy-sdk/python 0.46.4 2.680.0 1.2.31 unstructured-client" try: if __package__ is not None: diff --git a/src/unstructured_client/utils/retries.py b/src/unstructured_client/utils/retries.py index 9e06c648..60e1fd04 100644 --- a/src/unstructured_client/utils/retries.py +++ b/src/unstructured_client/utils/retries.py @@ -87,6 +87,15 @@ def __init__(self, inner: Exception): self.inner = inner +def _declares_non_retryable(response: httpx.Response) -> bool: + # Inspect buffered error bodies without consuming unread response streams. + try: + body = response.json() + except (ValueError, httpx.ResponseNotRead): + return False + return isinstance(body, dict) and body.get("retryable") is False + + def retry(func, retries: Retries): if retries.config.strategy == "backoff": @@ -101,12 +110,18 @@ def do_request() -> httpx.Response: status_major = res.status_code / 100 - if code_range <= status_major < code_range + 1: + if ( + code_range <= status_major < code_range + 1 + and not _declares_non_retryable(res) + ): raise TemporaryError(res) else: parsed_code = int(code) - if res.status_code == parsed_code: + if ( + res.status_code == parsed_code + and not _declares_non_retryable(res) + ): raise TemporaryError(res) except httpx.TransportError as exception: if retries.config.retry_connection_errors: @@ -147,12 +162,18 @@ async def do_request() -> httpx.Response: status_major = res.status_code / 100 - if code_range <= status_major < code_range + 1: + if ( + code_range <= status_major < code_range + 1 + and not _declares_non_retryable(res) + ): raise TemporaryError(res) else: parsed_code = int(code) - if res.status_code == parsed_code: + if ( + res.status_code == parsed_code + and not _declares_non_retryable(res) + ): raise TemporaryError(res) except httpx.TransportError as exception: if retries.config.retry_connection_errors: From 57ed2f210669c497e745760f83555d16c8ca53a6 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:26:59 -0400 Subject: [PATCH 2/3] fix: keep status retries when the error body can't be parsed --- _test_unstructured_client/unit/test_retries.py | 6 ++++++ src/unstructured_client/utils/retries.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/_test_unstructured_client/unit/test_retries.py b/_test_unstructured_client/unit/test_retries.py index 15420a46..cc286976 100644 --- a/_test_unstructured_client/unit/test_retries.py +++ b/_test_unstructured_client/unit/test_retries.py @@ -573,6 +573,12 @@ async def func(): (b'[{"retryable":false}]', "application/json", False), (b'false', "application/json", False), (b'{broken', "application/json", False), + pytest.param( + b'{"retryable":false,"detail":' + b"[" * 100_000 + b"]" * 100_000 + b"}", + "application/json", + False, + id="deeply-nested", + ), (b'upstream error', "text/plain", False), (b'{"retryable":false}', "text/plain", True), (b'{"retryable":false}', "", True), diff --git a/src/unstructured_client/utils/retries.py b/src/unstructured_client/utils/retries.py index 60e1fd04..b6e17ea5 100644 --- a/src/unstructured_client/utils/retries.py +++ b/src/unstructured_client/utils/retries.py @@ -91,7 +91,7 @@ def _declares_non_retryable(response: httpx.Response) -> bool: # Inspect buffered error bodies without consuming unread response streams. try: body = response.json() - except (ValueError, httpx.ResponseNotRead): + except (ValueError, RecursionError, httpx.ResponseNotRead): return False return isinstance(body, dict) and body.get("retryable") is False From c691503da8ce23204086839018409bf2149fcb95 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:36:57 -0400 Subject: [PATCH 3/3] refactor: share the retry status match --- .../unit/test_request_utils.py | 37 --------- .../unit/test_retries.py | 80 ++++++++++--------- .../unit/test_split_pdf_hook.py | 37 ++++++++- src/unstructured_client/utils/retries.py | 54 ++++--------- 4 files changed, 94 insertions(+), 114 deletions(-) diff --git a/_test_unstructured_client/unit/test_request_utils.py b/_test_unstructured_client/unit/test_request_utils.py index eabd404d..8f1d734f 100644 --- a/_test_unstructured_client/unit/test_request_utils.py +++ b/_test_unstructured_client/unit/test_request_utils.py @@ -74,40 +74,3 @@ def test_create_pdf_chunk_request_params(input_form_data, page_number, expected_ assert form_data == expected_form_data - - -@pytest.mark.asyncio -@pytest.mark.parametrize("cached", [False, True]) -@pytest.mark.parametrize("status_code", [500, 503]) -async def test_declared_non_retryable_chunk_sent_once(tmp_path, cached, status_code): - import asyncio - import io - - from unstructured_client._hooks.custom.split_pdf_hook import SplitPdfHook - from unstructured_client.utils import BackoffStrategy, RetryConfig - - body = {"detail": "unsupported chunk", "retryable": False} - attempts = [] - - def respond(request): - attempts.append(request) - return httpx.Response(status_code, json=body) - - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: - response = await SplitPdfHook().call_api_partial( - pdf_chunk_request=httpx.Request( - "POST", "http://test.invalid/general/v0/general", - headers={"Accept": "application/x-ndjson" if cached else "application/json"}, - content=b"pdf chunk", - ), - pdf_chunk_file=io.BytesIO(b"pdf chunk"), - limiter=asyncio.Semaphore(1), - _operation_id="declared-error", chunk_index=0, page_number=1, - async_client=client, - retry_config=RetryConfig("backoff", BackoffStrategy(1, 1, 1, 2), True), - cache_tmp_data_feature=cached, temp_dir_path=str(tmp_path), - ) - assert len(attempts) == 1 - assert response.status_code == status_code - assert response.json() == body - assert list(tmp_path.iterdir()) == [] diff --git a/_test_unstructured_client/unit/test_retries.py b/_test_unstructured_client/unit/test_retries.py index cc286976..6419b9d4 100644 --- a/_test_unstructured_client/unit/test_retries.py +++ b/_test_unstructured_client/unit/test_retries.py @@ -5,6 +5,8 @@ import httpx import pytest +from unstructured_client import UnstructuredClient +from unstructured_client.models.errors import ServerError from unstructured_client.utils import retries as retries_module from unstructured_client.utils.retries import ( BackoffStrategy, @@ -557,9 +559,24 @@ async def func(): assert call_count == 1 +def _send_with_retry(respond, config, async_mode, *, stream=False): + transport = httpx.MockTransport(respond) + if async_mode: + async def run(): + async with httpx.AsyncClient(transport=transport) as client: + return await retry_async( + lambda: client.send(client.build_request("GET", "http://test.invalid"), stream=stream), + config, + ) + return asyncio.run(run()) + with httpx.Client(transport=transport) as client: + return retry( + lambda: client.send(client.build_request("GET", "http://test.invalid"), stream=stream), + config, + ) + + @pytest.mark.parametrize("async_mode", [False, True]) -@pytest.mark.parametrize("status_code", [500, 503]) -@pytest.mark.parametrize("status_codes", [["5xx"], ["500", "503"]]) @pytest.mark.parametrize( "body,content_type,stop", [ @@ -585,8 +602,9 @@ async def func(): ], ) def test_declared_retry_verdict( - fake_clock, async_mode, status_code, status_codes, body, content_type, stop + fake_clock, async_mode, body, content_type, stop ): + status_code = 503 attempts = [] def respond(request): @@ -595,16 +613,8 @@ def respond(request): return httpx.Response(status_code, content=body, headers={"Content-Type": content_type}) return httpx.Response(200, json=[]) - config = _retries(status_codes=status_codes, initial_interval=1, max_interval=1) - transport = httpx.MockTransport(respond) - if async_mode: - async def run(): - async with httpx.AsyncClient(transport=transport) as client: - return await retry_async(lambda: client.get("http://test.invalid"), config) - result = asyncio.run(run()) - else: - with httpx.Client(transport=transport) as client: - result = retry(lambda: client.get("http://test.invalid"), config) + config = _retries(status_codes=["5xx"], initial_interval=1, max_interval=1) + result = _send_with_retry(respond, config, async_mode) assert len(attempts) == (1 if stop else 2) assert result.status_code == (status_code if stop else 200) @@ -622,15 +632,8 @@ def respond(request): return httpx.Response(400, json={"retryable": verdict}) config = _retries(status_codes=["5xx"]) - transport = httpx.MockTransport(respond) - if async_mode: - async def run(): - async with httpx.AsyncClient(transport=transport) as client: - return await retry_async(lambda: client.get("http://test.invalid"), config) - response = asyncio.run(run()) - else: - with httpx.Client(transport=transport) as client: - response = retry(lambda: client.get("http://test.invalid"), config) + response = _send_with_retry(respond, config, async_mode) + assert response.status_code == 400 assert len(attempts) == 1 @@ -659,19 +662,8 @@ def respond(request): return httpx.Response(200) config = _retries(status_codes=["5xx"]) - transport = httpx.MockTransport(respond) - if async_mode: - async def run(): - async with httpx.AsyncClient(transport=transport) as client: - return await retry_async( - lambda: client.send(client.build_request("GET", "http://test.invalid"), stream=True), config - ) - result = asyncio.run(run()) - else: - with httpx.Client(transport=transport) as client: - result = retry( - lambda: client.send(client.build_request("GET", "http://test.invalid"), stream=True), config - ) + result = _send_with_retry(respond, config, async_mode, stream=True) + assert result.status_code == 200 assert len(attempts) == 2 @@ -679,9 +671,6 @@ async def run(): @pytest.mark.parametrize("async_mode", [False, True]) @pytest.mark.parametrize("status_code", [500, 503]) def test_public_partition_preserves_declared_error(fake_clock, async_mode, status_code): - from unstructured_client import UnstructuredClient - from unstructured_client.models.errors import ServerError - body = {"detail": "unsupported document", "retryable": False} attempts = [] @@ -711,3 +700,18 @@ async def run(): asyncio.run(run()) assert len(attempts) == 1 + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("status_code", [500, 503]) +@pytest.mark.parametrize("status_codes", [["5xx"], ["500", "503"]]) +def test_declared_verdict_stops_matching_status(fake_clock, async_mode, status_code, status_codes): + attempts = [] + + def respond(request): + attempts.append(request) + return httpx.Response(status_code, json={"retryable": False}) + + response = _send_with_retry(respond, _retries(status_codes=status_codes), async_mode) + assert response.status_code == status_code + assert len(attempts) == 1 diff --git a/_test_unstructured_client/unit/test_split_pdf_hook.py b/_test_unstructured_client/unit/test_split_pdf_hook.py index be9ca472..527e6f0e 100644 --- a/_test_unstructured_client/unit/test_split_pdf_hook.py +++ b/_test_unstructured_client/unit/test_split_pdf_hook.py @@ -2186,4 +2186,39 @@ def _chunk_paths_side_effect(*args, **kwargs): assert hook.cache_tmp_data_feature == {} assert hook.cache_tmp_data_dir == {} tempdir.cleanup.assert_called_once() - executor.shutdown.assert_not_called() \ No newline at end of file + executor.shutdown.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("cached", [False, True]) +@pytest.mark.parametrize("status_code", [500, 503]) +async def test_declared_non_retryable_chunk_sent_once(tmp_path, cached, status_code): + body = {"detail": "unsupported chunk", "retryable": False} + attempts = [] + + def respond(request): + attempts.append(request) + return httpx.Response(status_code, json=body) + + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client: + response = await SplitPdfHook().call_api_partial( + pdf_chunk_request=httpx.Request( + "POST", + "http://test.invalid/general/v0/general", + headers={"Accept": "application/x-ndjson" if cached else "application/json"}, + content=b"pdf chunk", + ), + pdf_chunk_file=io.BytesIO(b"pdf chunk"), + limiter=asyncio.Semaphore(1), + _operation_id="declared-error", + chunk_index=0, + page_number=1, + async_client=client, + retry_config=RetryConfig("backoff", BackoffStrategy(1, 1, 1, 2), True), + cache_tmp_data_feature=cached, + temp_dir_path=str(tmp_path), + ) + assert len(attempts) == 1 + assert response.status_code == status_code + assert response.json() == body + assert list(tmp_path.iterdir()) == [] diff --git a/src/unstructured_client/utils/retries.py b/src/unstructured_client/utils/retries.py index b6e17ea5..d268fefb 100644 --- a/src/unstructured_client/utils/retries.py +++ b/src/unstructured_client/utils/retries.py @@ -96,6 +96,18 @@ def _declares_non_retryable(response: httpx.Response) -> bool: return isinstance(body, dict) and body.get("retryable") is False +def _is_retry_status(response: httpx.Response, status_codes: List[str]) -> bool: + for code in status_codes: + if "X" in code.upper(): + code_range = int(code[0]) + status_major = response.status_code / 100 + if code_range <= status_major < code_range + 1: + return True + elif response.status_code == int(code): + return True + return False + + def retry(func, retries: Retries): if retries.config.strategy == "backoff": @@ -104,25 +116,8 @@ def do_request() -> httpx.Response: try: res = func() - for code in retries.status_codes: - if "X" in code.upper(): - code_range = int(code[0]) - - status_major = res.status_code / 100 - - if ( - code_range <= status_major < code_range + 1 - and not _declares_non_retryable(res) - ): - raise TemporaryError(res) - else: - parsed_code = int(code) - - if ( - res.status_code == parsed_code - and not _declares_non_retryable(res) - ): - raise TemporaryError(res) + if _is_retry_status(res, retries.status_codes) and not _declares_non_retryable(res): + raise TemporaryError(res) except httpx.TransportError as exception: if retries.config.retry_connection_errors: raise @@ -156,25 +151,8 @@ async def do_request() -> httpx.Response: try: res = await func() - for code in retries.status_codes: - if "X" in code.upper(): - code_range = int(code[0]) - - status_major = res.status_code / 100 - - if ( - code_range <= status_major < code_range + 1 - and not _declares_non_retryable(res) - ): - raise TemporaryError(res) - else: - parsed_code = int(code) - - if ( - res.status_code == parsed_code - and not _declares_non_retryable(res) - ): - raise TemporaryError(res) + if _is_retry_status(res, retries.status_codes) and not _declares_non_retryable(res): + raise TemporaryError(res) except httpx.TransportError as exception: if retries.config.retry_connection_errors: raise