Skip to content

Commit 0571f93

Browse files
fxdgearclaude
andauthored
fix: retry on all TransportError subclasses (ReadError, WriteError, etc.) (#334)
## Summary - Replaces individual `except` blocks for `ConnectError`, `RemoteProtocolError`, and `TimeoutException` with a single catch for their parent class `httpx.TransportError` - This covers `ReadError` (TCP connection reset mid-response with empty message), `WriteError`, and all other transport-level failures - Previously, `ReadError` fell through to the catch-all `Exception` handler and was wrapped as `PermanentError`, failing immediately without retry ## Context Follow-up to #332. After deploying the `RemoteProtocolError` fix, we observed `httpx.ReadError` (empty message) failures when api pods crashed mid-response. The TCP connection was reset during the response read phase, which httpx classifies as `ReadError` rather than `RemoteProtocolError`. The httpx exception hierarchy: ``` TransportError ├── ConnectError (was retried) ├── RemoteProtocolError (was retried since #332) ├── ReadError (was NOT retried — now fixed) ├── WriteError (was NOT retried — now fixed) ├── PoolTimeout (was NOT retried — now fixed) └── ... TimeoutException (was retried, subclass of TransportError) ├── ConnectTimeout ├── ReadTimeout ├── WriteTimeout └── PoolTimeout ``` Catching `TransportError` is the correct level — all transport errors are transient and should be retried when `retry_connection_errors=True`. ## Test plan - [x] Parametrized tests for all TransportError subclasses (sync + async) - [ ] Each subclass retried when `retry_connection_errors=True` - [ ] Each subclass raises immediately when `retry_connection_errors=False` 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Expands which network failures are treated as retryable, which can change error/latency behavior for callers and potentially mask persistent transport issues until backoff is exhausted. > > **Overview** > **Broadened retry handling for transport failures.** The retry wrapper now catches `httpx.TransportError` in both sync and async paths, so additional transport-level errors (e.g. `ReadError`, `WriteError`, and timeout subclasses) are retried when `retry_connection_errors=True` instead of being treated as permanent. > > Tests were updated to parameterize across multiple `TransportError` subclasses for both sync and async retry behavior, and the package version/release notes were bumped to `0.42.12`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bdd403c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 38d5a49 commit 0571f93

File tree

7 files changed

+55
-74
lines changed

7 files changed

+55
-74
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 0.42.12
2+
3+
### Enhancements
4+
5+
### Features
6+
7+
### Fixes
8+
* Retry on all `httpx.TransportError` subclasses (including `ReadError`, `WriteError`, `ConnectError`, `RemoteProtocolError`, and all timeout types) when `retry_connection_errors=True`. Previously only `ConnectError`, `RemoteProtocolError`, and `TimeoutException` were retried — `ReadError` (TCP connection reset mid-response) was treated as permanent.
9+
110
## 0.42.11
211

312
### Enhancements

RELEASES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,3 +1201,13 @@ Based on:
12011201
- [python v0.42.11] .
12021202
### Releases
12031203
- [PyPI v0.42.11] https://pypi.org/project/unstructured-client/0.42.11 - .
1204+
1205+
## 2026-03-25 20:30:00
1206+
### Changes
1207+
Based on:
1208+
- OpenAPI Doc
1209+
- Speakeasy CLI 1.601.0 (2.680.0) https://github.com/speakeasy-api/speakeasy
1210+
### Generated
1211+
- [python v0.42.12] .
1212+
### Releases
1213+
- [PyPI v0.42.12] https://pypi.org/project/unstructured-client/0.42.12 - .
Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Tests for retry logic, specifically covering RemoteProtocolError retry behavior."""
1+
"""Tests for retry logic covering all TransportError subclasses."""
22

33
import asyncio
44
from unittest.mock import MagicMock
@@ -32,12 +32,22 @@ def _make_retries(retry_connection_errors: bool) -> Retries:
3232
)
3333

3434

35-
class TestRemoteProtocolErrorRetry:
36-
"""Test that RemoteProtocolError (e.g. 'Server disconnected without sending a response')
37-
is retried when retry_connection_errors=True."""
35+
# All TransportError subclasses that should be retried
36+
TRANSPORT_ERRORS = [
37+
(httpx.ConnectError, "Connection refused"),
38+
(httpx.RemoteProtocolError, "Server disconnected without sending a response."),
39+
(httpx.ReadError, ""),
40+
(httpx.WriteError, ""),
41+
(httpx.ConnectTimeout, "Timed out"),
42+
(httpx.ReadTimeout, "Timed out"),
43+
]
3844

39-
def test_remote_protocol_error_retried_when_enabled(self):
40-
"""RemoteProtocolError should be retried and succeed on subsequent attempt."""
45+
46+
class TestTransportErrorRetry:
47+
"""All httpx.TransportError subclasses should be retried when retry_connection_errors=True."""
48+
49+
@pytest.mark.parametrize("exc_class,msg", TRANSPORT_ERRORS)
50+
def test_transport_error_retried_when_enabled(self, exc_class, msg):
4151
retries_config = _make_retries(retry_connection_errors=True)
4252

4353
mock_response = MagicMock(spec=httpx.Response)
@@ -49,53 +59,29 @@ def func():
4959
nonlocal call_count
5060
call_count += 1
5161
if call_count == 1:
52-
raise httpx.RemoteProtocolError(
53-
"Server disconnected without sending a response."
54-
)
62+
raise exc_class(msg)
5563
return mock_response
5664

5765
result = retry(func, retries_config)
5866
assert result.status_code == 200
5967
assert call_count == 2
6068

61-
def test_remote_protocol_error_not_retried_when_disabled(self):
62-
"""RemoteProtocolError should raise PermanentError when retry_connection_errors=False."""
69+
@pytest.mark.parametrize("exc_class,msg", TRANSPORT_ERRORS)
70+
def test_transport_error_not_retried_when_disabled(self, exc_class, msg):
6371
retries_config = _make_retries(retry_connection_errors=False)
6472

6573
def func():
66-
raise httpx.RemoteProtocolError(
67-
"Server disconnected without sending a response."
68-
)
74+
raise exc_class(msg)
6975

70-
with pytest.raises(httpx.RemoteProtocolError):
76+
with pytest.raises(exc_class):
7177
retry(func, retries_config)
7278

73-
def test_connect_error_still_retried(self):
74-
"""Existing ConnectError retry behavior should be preserved."""
75-
retries_config = _make_retries(retry_connection_errors=True)
76-
77-
mock_response = MagicMock(spec=httpx.Response)
78-
mock_response.status_code = 200
79-
80-
call_count = 0
81-
82-
def func():
83-
nonlocal call_count
84-
call_count += 1
85-
if call_count == 1:
86-
raise httpx.ConnectError("Connection refused")
87-
return mock_response
88-
89-
result = retry(func, retries_config)
90-
assert result.status_code == 200
91-
assert call_count == 2
92-
9379

94-
class TestRemoteProtocolErrorRetryAsync:
95-
"""Async versions of the RemoteProtocolError retry tests."""
80+
class TestTransportErrorRetryAsync:
81+
"""Async: All httpx.TransportError subclasses should be retried."""
9682

97-
def test_remote_protocol_error_retried_async(self):
98-
"""Async: RemoteProtocolError should be retried when retry_connection_errors=True."""
83+
@pytest.mark.parametrize("exc_class,msg", TRANSPORT_ERRORS)
84+
def test_transport_error_retried_async(self, exc_class, msg):
9985
retries_config = _make_retries(retry_connection_errors=True)
10086

10187
mock_response = MagicMock(spec=httpx.Response)
@@ -107,23 +93,19 @@ async def func():
10793
nonlocal call_count
10894
call_count += 1
10995
if call_count == 1:
110-
raise httpx.RemoteProtocolError(
111-
"Server disconnected without sending a response."
112-
)
96+
raise exc_class(msg)
11397
return mock_response
11498

11599
result = asyncio.run(retry_async(func, retries_config))
116100
assert result.status_code == 200
117101
assert call_count == 2
118102

119-
def test_remote_protocol_error_not_retried_async_when_disabled(self):
120-
"""Async: RemoteProtocolError should not be retried when retry_connection_errors=False."""
103+
@pytest.mark.parametrize("exc_class,msg", TRANSPORT_ERRORS)
104+
def test_transport_error_not_retried_async_when_disabled(self, exc_class, msg):
121105
retries_config = _make_retries(retry_connection_errors=False)
122106

123107
async def func():
124-
raise httpx.RemoteProtocolError(
125-
"Server disconnected without sending a response."
126-
)
108+
raise exc_class(msg)
127109

128-
with pytest.raises(httpx.RemoteProtocolError):
110+
with pytest.raises(exc_class):
129111
asyncio.run(retry_async(func, retries_config))

gen.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ generation:
2323
schemas:
2424
allOfMergeStrategy: shallowMerge
2525
python:
26-
version: 0.42.11
26+
version: 0.42.12
2727
additionalDependencies:
2828
dev:
2929
deepdiff: '>=6.0'

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
[project]
33
name = "unstructured-client"
4-
version = "0.42.11"
4+
version = "0.42.12"
55
description = "Python Client SDK for Unstructured API"
66
authors = [{ name = "Unstructured" },]
77
readme = "README-PYPI.md"

src/unstructured_client/_version.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import importlib.metadata
44

55
__title__: str = "unstructured-client"
6-
__version__: str = "0.42.11"
6+
__version__: str = "0.42.12"
77
__openapi_doc_version__: str = "1.2.31"
88
__gen_version__: str = "2.680.0"
9-
__user_agent__: str = "speakeasy-sdk/python 0.42.11 2.680.0 1.2.31 unstructured-client"
9+
__user_agent__: str = "speakeasy-sdk/python 0.42.12 2.680.0 1.2.31 unstructured-client"
1010

1111
try:
1212
if __package__ is not None:

src/unstructured_client/utils/retries.py

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,7 @@ def do_request() -> httpx.Response:
8484

8585
if res.status_code == parsed_code:
8686
raise TemporaryError(res)
87-
except httpx.ConnectError as exception:
88-
if retries.config.retry_connection_errors:
89-
raise
90-
91-
raise PermanentError(exception) from exception
92-
except httpx.RemoteProtocolError as exception:
93-
if retries.config.retry_connection_errors:
94-
raise
95-
96-
raise PermanentError(exception) from exception
97-
except httpx.TimeoutException as exception:
87+
except httpx.TransportError as exception:
9888
if retries.config.retry_connection_errors:
9989
raise
10090

@@ -138,17 +128,7 @@ async def do_request() -> httpx.Response:
138128

139129
if res.status_code == parsed_code:
140130
raise TemporaryError(res)
141-
except httpx.ConnectError as exception:
142-
if retries.config.retry_connection_errors:
143-
raise
144-
145-
raise PermanentError(exception) from exception
146-
except httpx.RemoteProtocolError as exception:
147-
if retries.config.retry_connection_errors:
148-
raise
149-
150-
raise PermanentError(exception) from exception
151-
except httpx.TimeoutException as exception:
131+
except httpx.TransportError as exception:
152132
if retries.config.retry_connection_errors:
153133
raise
154134

0 commit comments

Comments
 (0)