diff --git a/CHANGELOG.md b/CHANGELOG.md index 5353696a..c0f7d729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.42.11 + +### Enhancements + +### Features + +### Fixes +* Retry on `httpx.RemoteProtocolError` (e.g. "Server disconnected without sending a response") when `retry_connection_errors=True`. Previously, mid-request server crashes were treated as permanent errors and not retried. + ## 0.42.5 ### Enhancements diff --git a/RELEASES.md b/RELEASES.md index 80c317db..c0bd096c 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1190,4 +1190,14 @@ Based on: ### Generated - [python v0.42.10] . ### Releases -- [PyPI v0.42.10] https://pypi.org/project/unstructured-client/0.42.10 - . \ No newline at end of file +- [PyPI v0.42.10] https://pypi.org/project/unstructured-client/0.42.10 - . + +## 2026-03-25 16:00:00 +### Changes +Based on: +- OpenAPI Doc +- Speakeasy CLI 1.601.0 (2.680.0) https://github.com/speakeasy-api/speakeasy +### Generated +- [python v0.42.11] . +### Releases +- [PyPI v0.42.11] https://pypi.org/project/unstructured-client/0.42.11 - . \ No newline at end of file diff --git a/_test_unstructured_client/unit/test_retries.py b/_test_unstructured_client/unit/test_retries.py new file mode 100644 index 00000000..f1f4c571 --- /dev/null +++ b/_test_unstructured_client/unit/test_retries.py @@ -0,0 +1,129 @@ +"""Tests for retry logic, specifically covering RemoteProtocolError retry behavior.""" + +import asyncio +from unittest.mock import MagicMock + +import httpx +import pytest + +from unstructured_client.utils.retries import ( + BackoffStrategy, + PermanentError, + Retries, + RetryConfig, + retry, + retry_async, +) + + +def _make_retries(retry_connection_errors: bool) -> Retries: + return Retries( + config=RetryConfig( + strategy="backoff", + backoff=BackoffStrategy( + initial_interval=100, + max_interval=200, + exponent=1.5, + max_elapsed_time=5000, + ), + retry_connection_errors=retry_connection_errors, + ), + status_codes=[], + ) + + +class TestRemoteProtocolErrorRetry: + """Test that RemoteProtocolError (e.g. 'Server disconnected without sending a response') + is retried when retry_connection_errors=True.""" + + def test_remote_protocol_error_retried_when_enabled(self): + """RemoteProtocolError should be retried and succeed on subsequent attempt.""" + retries_config = _make_retries(retry_connection_errors=True) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + + call_count = 0 + + def func(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise httpx.RemoteProtocolError( + "Server disconnected without sending a response." + ) + return mock_response + + result = retry(func, retries_config) + assert result.status_code == 200 + assert call_count == 2 + + def test_remote_protocol_error_not_retried_when_disabled(self): + """RemoteProtocolError should raise PermanentError when retry_connection_errors=False.""" + retries_config = _make_retries(retry_connection_errors=False) + + def func(): + raise httpx.RemoteProtocolError( + "Server disconnected without sending a response." + ) + + with pytest.raises(httpx.RemoteProtocolError): + retry(func, retries_config) + + def test_connect_error_still_retried(self): + """Existing ConnectError retry behavior should be preserved.""" + retries_config = _make_retries(retry_connection_errors=True) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + + call_count = 0 + + def func(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise httpx.ConnectError("Connection refused") + return mock_response + + result = retry(func, retries_config) + assert result.status_code == 200 + assert call_count == 2 + + +class TestRemoteProtocolErrorRetryAsync: + """Async versions of the RemoteProtocolError retry tests.""" + + def test_remote_protocol_error_retried_async(self): + """Async: RemoteProtocolError should be retried when retry_connection_errors=True.""" + retries_config = _make_retries(retry_connection_errors=True) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + + call_count = 0 + + async def func(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise httpx.RemoteProtocolError( + "Server disconnected without sending a response." + ) + return mock_response + + result = asyncio.run(retry_async(func, retries_config)) + assert result.status_code == 200 + assert call_count == 2 + + def test_remote_protocol_error_not_retried_async_when_disabled(self): + """Async: RemoteProtocolError should not be retried when retry_connection_errors=False.""" + retries_config = _make_retries(retry_connection_errors=False) + + async def func(): + raise httpx.RemoteProtocolError( + "Server disconnected without sending a response." + ) + + with pytest.raises(httpx.RemoteProtocolError): + asyncio.run(retry_async(func, retries_config)) diff --git a/gen.yaml b/gen.yaml index 42197469..d2237fd4 100644 --- a/gen.yaml +++ b/gen.yaml @@ -23,7 +23,7 @@ generation: schemas: allOfMergeStrategy: shallowMerge python: - version: 0.42.10 + version: 0.42.11 additionalDependencies: dev: deepdiff: '>=6.0' diff --git a/pyproject.toml b/pyproject.toml index 4ac72016..9b14f828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "unstructured-client" -version = "0.42.10" +version = "0.42.11" description = "Python Client SDK for Unstructured API" authors = [{ name = "Unstructured" },] readme = "README-PYPI.md" diff --git a/src/unstructured_client/_version.py b/src/unstructured_client/_version.py index e17f20f9..4d52e6b0 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.42.10" +__version__: str = "0.42.11" __openapi_doc_version__: str = "1.2.31" __gen_version__: str = "2.680.0" -__user_agent__: str = "speakeasy-sdk/python 0.42.10 2.680.0 1.2.31 unstructured-client" +__user_agent__: str = "speakeasy-sdk/python 0.42.11 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 4d608671..f8a4b7ed 100644 --- a/src/unstructured_client/utils/retries.py +++ b/src/unstructured_client/utils/retries.py @@ -88,6 +88,11 @@ def do_request() -> httpx.Response: if retries.config.retry_connection_errors: raise + raise PermanentError(exception) from exception + except httpx.RemoteProtocolError as exception: + if retries.config.retry_connection_errors: + raise + raise PermanentError(exception) from exception except httpx.TimeoutException as exception: if retries.config.retry_connection_errors: @@ -137,6 +142,11 @@ async def do_request() -> httpx.Response: if retries.config.retry_connection_errors: raise + raise PermanentError(exception) from exception + except httpx.RemoteProtocolError as exception: + if retries.config.retry_connection_errors: + raise + raise PermanentError(exception) from exception except httpx.TimeoutException as exception: if retries.config.retry_connection_errors: