Skip to content

Commit 7eddab3

Browse files
fxdgearclaude
andauthored
fix: retry on RemoteProtocolError (server disconnect) (#332)
## Summary - Adds `httpx.RemoteProtocolError` to the list of retriable exceptions in `retries.py` (both sync and async paths) - When `retry_connection_errors=True`, server disconnects mid-request (e.g. "Server disconnected without sending a response") are now retried with backoff, matching the existing behavior for `ConnectError` and `TimeoutException` - Previously, `RemoteProtocolError` fell through to the catch-all `Exception` handler and was wrapped as `PermanentError`, immediately failing without retry ## Context When a server crashes mid-request (e.g. SIGSEGV from thread-unsafe native library access), the client receives an `httpx.RemoteProtocolError("Server disconnected without sending a response.")`. Despite `retry_connection_errors=True` being configured, this error was not retried because the SDK only handled `ConnectError` and `TimeoutException` as retriable transport errors. The httpx exception hierarchy is: ``` RemoteProtocolError → ProtocolError → TransportError → RequestError → HTTPError ConnectError → TransportError → RequestError → HTTPError (already retried) TimeoutException → TransportError → RequestError → HTTPError (already retried) ``` `RemoteProtocolError` is the same class of transient transport error as the already-retried exceptions. ## Test plan - [x] Added unit tests for sync and async retry paths - [ ] `RemoteProtocolError` retried when `retry_connection_errors=True` — succeeds on 2nd attempt - [ ] `RemoteProtocolError` raises immediately when `retry_connection_errors=False` - [ ] Existing `ConnectError` retry behavior preserved 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes retry behavior for mid-request disconnects, which can increase duplicate-request risk for non-idempotent operations when `retry_connection_errors=True`. Scope is limited to transport-error handling and covered by new unit tests for sync/async paths. > > **Overview** > **Retries now treat `httpx.RemoteProtocolError` as a retriable transport failure** when `retry_connection_errors=True`, aligning it with existing `ConnectError`/`TimeoutException` handling in both `retry` and `retry_async`. > > Adds unit tests validating the new sync/async retry behavior (and the disabled case), and bumps the SDK version to `0.42.11` with corresponding changelog/release entries and user-agent/version updates. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a7dc972. 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 fbd80fd commit 7eddab3

File tree

7 files changed

+163
-5
lines changed

7 files changed

+163
-5
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.11
2+
3+
### Enhancements
4+
5+
### Features
6+
7+
### Fixes
8+
* 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.
9+
110
## 0.42.5
211

312
### Enhancements

RELEASES.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,4 +1190,14 @@ Based on:
11901190
### Generated
11911191
- [python v0.42.10] .
11921192
### Releases
1193-
- [PyPI v0.42.10] https://pypi.org/project/unstructured-client/0.42.10 - .
1193+
- [PyPI v0.42.10] https://pypi.org/project/unstructured-client/0.42.10 - .
1194+
1195+
## 2026-03-25 16:00:00
1196+
### Changes
1197+
Based on:
1198+
- OpenAPI Doc
1199+
- Speakeasy CLI 1.601.0 (2.680.0) https://github.com/speakeasy-api/speakeasy
1200+
### Generated
1201+
- [python v0.42.11] .
1202+
### Releases
1203+
- [PyPI v0.42.11] https://pypi.org/project/unstructured-client/0.42.11 - .
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Tests for retry logic, specifically covering RemoteProtocolError retry behavior."""
2+
3+
import asyncio
4+
from unittest.mock import MagicMock
5+
6+
import httpx
7+
import pytest
8+
9+
from unstructured_client.utils.retries import (
10+
BackoffStrategy,
11+
PermanentError,
12+
Retries,
13+
RetryConfig,
14+
retry,
15+
retry_async,
16+
)
17+
18+
19+
def _make_retries(retry_connection_errors: bool) -> Retries:
20+
return Retries(
21+
config=RetryConfig(
22+
strategy="backoff",
23+
backoff=BackoffStrategy(
24+
initial_interval=100,
25+
max_interval=200,
26+
exponent=1.5,
27+
max_elapsed_time=5000,
28+
),
29+
retry_connection_errors=retry_connection_errors,
30+
),
31+
status_codes=[],
32+
)
33+
34+
35+
class TestRemoteProtocolErrorRetry:
36+
"""Test that RemoteProtocolError (e.g. 'Server disconnected without sending a response')
37+
is retried when retry_connection_errors=True."""
38+
39+
def test_remote_protocol_error_retried_when_enabled(self):
40+
"""RemoteProtocolError should be retried and succeed on subsequent attempt."""
41+
retries_config = _make_retries(retry_connection_errors=True)
42+
43+
mock_response = MagicMock(spec=httpx.Response)
44+
mock_response.status_code = 200
45+
46+
call_count = 0
47+
48+
def func():
49+
nonlocal call_count
50+
call_count += 1
51+
if call_count == 1:
52+
raise httpx.RemoteProtocolError(
53+
"Server disconnected without sending a response."
54+
)
55+
return mock_response
56+
57+
result = retry(func, retries_config)
58+
assert result.status_code == 200
59+
assert call_count == 2
60+
61+
def test_remote_protocol_error_not_retried_when_disabled(self):
62+
"""RemoteProtocolError should raise PermanentError when retry_connection_errors=False."""
63+
retries_config = _make_retries(retry_connection_errors=False)
64+
65+
def func():
66+
raise httpx.RemoteProtocolError(
67+
"Server disconnected without sending a response."
68+
)
69+
70+
with pytest.raises(httpx.RemoteProtocolError):
71+
retry(func, retries_config)
72+
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+
93+
94+
class TestRemoteProtocolErrorRetryAsync:
95+
"""Async versions of the RemoteProtocolError retry tests."""
96+
97+
def test_remote_protocol_error_retried_async(self):
98+
"""Async: RemoteProtocolError should be retried when retry_connection_errors=True."""
99+
retries_config = _make_retries(retry_connection_errors=True)
100+
101+
mock_response = MagicMock(spec=httpx.Response)
102+
mock_response.status_code = 200
103+
104+
call_count = 0
105+
106+
async def func():
107+
nonlocal call_count
108+
call_count += 1
109+
if call_count == 1:
110+
raise httpx.RemoteProtocolError(
111+
"Server disconnected without sending a response."
112+
)
113+
return mock_response
114+
115+
result = asyncio.run(retry_async(func, retries_config))
116+
assert result.status_code == 200
117+
assert call_count == 2
118+
119+
def test_remote_protocol_error_not_retried_async_when_disabled(self):
120+
"""Async: RemoteProtocolError should not be retried when retry_connection_errors=False."""
121+
retries_config = _make_retries(retry_connection_errors=False)
122+
123+
async def func():
124+
raise httpx.RemoteProtocolError(
125+
"Server disconnected without sending a response."
126+
)
127+
128+
with pytest.raises(httpx.RemoteProtocolError):
129+
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.10
26+
version: 0.42.11
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.10"
4+
version = "0.42.11"
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.10"
6+
__version__: str = "0.42.11"
77
__openapi_doc_version__: str = "1.2.31"
88
__gen_version__: str = "2.680.0"
9-
__user_agent__: str = "speakeasy-sdk/python 0.42.10 2.680.0 1.2.31 unstructured-client"
9+
__user_agent__: str = "speakeasy-sdk/python 0.42.11 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ def do_request() -> httpx.Response:
8888
if retries.config.retry_connection_errors:
8989
raise
9090

91+
raise PermanentError(exception) from exception
92+
except httpx.RemoteProtocolError as exception:
93+
if retries.config.retry_connection_errors:
94+
raise
95+
9196
raise PermanentError(exception) from exception
9297
except httpx.TimeoutException as exception:
9398
if retries.config.retry_connection_errors:
@@ -137,6 +142,11 @@ async def do_request() -> httpx.Response:
137142
if retries.config.retry_connection_errors:
138143
raise
139144

145+
raise PermanentError(exception) from exception
146+
except httpx.RemoteProtocolError as exception:
147+
if retries.config.retry_connection_errors:
148+
raise
149+
140150
raise PermanentError(exception) from exception
141151
except httpx.TimeoutException as exception:
142152
if retries.config.retry_connection_errors:

0 commit comments

Comments
 (0)