Skip to content

Commit 35adea4

Browse files
fix(client-python): retry pipe open() on transient connect-refused (#2117)
* fix(client-python): retry pipe open() on transient connect-refused A just-registered pipeline's per-pipe data listener can occasionally still be binding when open() reaches it - under heavy concurrent load (e.g. many pipelines opened at once) this surfaces as a transient ECONNREFUSED ("Connect call failed") that is indistinguishable from a real "pipeline isn't running" failure to the caller. Retry a few times with a short backoff when the failure message matches this specific transient pattern; any other failure still raises immediately on the first attempt, so genuine errors (bad token, wrong MIME type, terminated pipeline) are not masked or delayed. Surfaced by CI flakiness on unrelated PRs: the same [Errno 111] Connect call failed error hit two different, unrelated tests (nodes/test/guardrails/test_lane_forward_once.py and nodes/test/test_dynamic.py) on consecutive runs, each on a different ephemeral port - consistent with this race rather than a test bug. * fix(client-python): address review — narrow retry, recalibrate, rebase fix Nihal's review on #2117: - Rebase onto develop needed a deliberate resolution: #2127 (merged after this branch was cut) rewrote the same open() failure hunk to split the server message from a `hint` field and add `code`. Keep that split; only the retry loop wraps it now. - The predicate also matched "Connection refused", which a misconfigured `remote` node raises for a permanent failure the engine's inner connect retry never runs for — retrying it only added latency. Narrow to the actual race-condition signature, "Connect call failed". - The retry budget undersold itself: the engine already retries this same connect internally (10x, 150ms apart, ~1.5s worst case) before it reports failure, so each SDK-level attempt re-runs that whole loop. Drop from 3 attempts to 2 (one retry) and document the ~3.2s worst case instead of silently multiplying it further. Also pins the rebase resolution with tests for the narrowed predicate and the message/hint split, and updates the co-located SDK doc's retry note. * fix(client-python): correct the documented pipe-open retry latency CodeRabbit on #2117: the retry's added delay is the 0.25s SDK backoff plus the engine's own ~1.5s internal connect-retry cycle, so ~1.75s - not the ~1.5s the docstring and doc claimed. Also reworded the doc's PipeException description to "past that retry budget" rather than "genuinely could not be opened" (the earlier module comment already gets this right). * fix(client-python): guard pipe-open retry predicate against a non-string message CodeRabbit on #2117: response.get('message') or '' passes through a truthy non-string value (e.g. a malformed server response with an int message) unchanged, so _is_transient_pipe_open_error()'s `'Connect call failed' in message` raises TypeError before PipeException can even be raised. Coerce to str right after extraction so the retry classification and the exception it may raise both stay safe. * fix(client-python): don't let a falsey non-string message fall back to generic CodeRabbit on #2117: response.get('message') or '' runs before the just-added isinstance check, so a real-but-falsey message like 0 or False was already flattened to '' by the time it got there - PipeException then showed the generic "Failed to open a data pipe." instead of the server's actual value. Check for None explicitly instead of using `or`. --------- Co-authored-by: madhumithachandrasekaran31 <madhumitha2110549@ssn.edu.in>
1 parent 81f6338 commit 35adea4

3 files changed

Lines changed: 221 additions & 14 deletions

File tree

packages/client-python/docs/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,11 @@ Returned by `await client.pipe(...)`. One streaming upload: **open** -> **write*
463463
| `__aenter__` | `async def __aenter__(self)` | `self` | Enters context; calls `open()`. |
464464
| `__aexit__` | `async def __aexit__(self, exc_type, exc_val, exc_tb)` | - | Exits context; calls `close()`. |
465465

466+
`open()` retries once automatically if it hits a transient "Connect call
467+
failed" while the pipeline's data listener is still starting up (worst case
468+
adds ~1.75s); a `PipeException` from `open()` means it kept failing past that
469+
retry budget.
470+
466471
---
467472

468473
## Question

packages/client-python/src/rocketride/mixins/data.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,31 @@
6060
from ..core import DAPClient, PipeException
6161
from ..types import PIPELINE_RESULT, UPLOAD_RESULT
6262

63+
# A just-registered pipeline's per-pipe data listener can occasionally still be
64+
# binding when `open()` reaches it - observed under heavy concurrent load (e.g. many
65+
# pipelines opened at once in CI) as a transient "Connect call failed" on the
66+
# freshly assigned port. That's indistinguishable from a real "pipeline isn't
67+
# running" failure to the caller, so give it one short extra retry before
68+
# surfacing it as an error.
69+
#
70+
# The engine already retries this exact connect internally (up to 10 attempts,
71+
# 150ms apart - see task_engine.py's `_connect_data_client`) before it gives up
72+
# and reports "Connect call failed" back to us, so a single SDK-level attempt
73+
# already absorbs ~1.5s of engine-side retrying. Each retry here re-runs that
74+
# whole engine-side loop from scratch, so keep the attempt count low: at
75+
# `_PIPE_OPEN_RETRY_ATTEMPTS = 2` (one retry), worst case is two ~1.5s engine
76+
# cycles plus one short backoff, roughly 3.2s, before `open()` raises.
77+
_PIPE_OPEN_RETRY_ATTEMPTS = 2
78+
_PIPE_OPEN_RETRY_BACKOFF_SECONDS = 0.25
79+
80+
81+
def _is_transient_pipe_open_error(message: str) -> bool:
82+
# Deliberately narrower than "any connection-shaped error": a misconfigured
83+
# `remote` node surfaces a plain "Connection refused" for a permanent failure
84+
# that this retry can't fix (the engine's inner connect loop doesn't run for
85+
# it), so only the exact race-condition signature above is retried.
86+
return 'Connect call failed' in message
87+
6388

6489
class DataMixin(DAPClient):
6590
"""
@@ -154,12 +179,19 @@ async def open(self) -> 'DataMixin.DataPipe':
154179
Must be called before writing data. The server assigns a unique
155180
pipe ID and prepares to receive your data.
156181
182+
If the only problem is a transient "Connect call failed" while the
183+
pipeline's data listener is still starting up, this retries once
184+
before giving up - worst case that adds ~1.75s (a short backoff,
185+
then the engine's own internal connect retry runs again on the
186+
retry). Any other failure raises immediately on the first attempt.
187+
157188
Returns:
158189
self: The opened pipe instance for method chaining
159190
160191
Raises:
161192
RuntimeError: If the pipe is already opened.
162-
PipeException: If the server rejects the open request.
193+
PipeException: If the server rejects the open request, or if it
194+
keeps failing with a transient connect error past the retry.
163195
164196
Example:
165197
pipe = await client.pipe(token, mimetype="text/plain")
@@ -169,25 +201,43 @@ async def open(self) -> 'DataMixin.DataPipe':
169201
if self._opened:
170202
raise RuntimeError('Pipe already opened')
171203

172-
request = self._client.build_request(
173-
'rrext_process',
174-
arguments={
175-
'subcommand': 'open',
176-
'object': self._objinfo,
177-
'mimeType': self._mime_type,
178-
'provider': self._provider,
179-
},
180-
token=self._token,
181-
)
204+
response = None
205+
for attempt in range(1, _PIPE_OPEN_RETRY_ATTEMPTS + 1):
206+
request = self._client.build_request(
207+
'rrext_process',
208+
arguments={
209+
'subcommand': 'open',
210+
'object': self._objinfo,
211+
'mimeType': self._mime_type,
212+
'provider': self._provider,
213+
},
214+
token=self._token,
215+
)
182216

183-
response = await self._client.request(request)
217+
response = await self._client.request(request)
218+
219+
if not self._client.did_fail(response):
220+
break
221+
222+
message = response.get('message')
223+
if message is None:
224+
message = ''
225+
elif not isinstance(message, str):
226+
# A well-behaved server always sends a string, but don't let a
227+
# malformed one (e.g. a bare int/bool) blow up the `in` check
228+
# below or the PipeException raised past the retry. `or ''`
229+
# here would also turn falsey-but-real values like `0`/`False`
230+
# into an empty message, so check for absence explicitly.
231+
message = str(message)
232+
if attempt < _PIPE_OPEN_RETRY_ATTEMPTS and _is_transient_pipe_open_error(message):
233+
await asyncio.sleep(_PIPE_OPEN_RETRY_BACKOFF_SECONDS * attempt)
234+
continue
184235

185-
if self._client.did_fail(response):
186236
# The server's message stays the message: an application may show
187237
# it to an end user. The developer checklist rides along as `hint`
188238
# (PipeException.hint), and `code` classifies the failure.
189239
response = dict(response)
190-
response['message'] = response.get('message') or 'Failed to open a data pipe.'
240+
response['message'] = message or 'Failed to open a data pipe.'
191241
response['hint'] = (
192242
'Common causes:\n'
193243
"- Pipeline isn't running (wrong token or task terminated)\n"
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from rocketride.core.exceptions import PipeException
6+
from rocketride.mixins.data import DataMixin, _PIPE_OPEN_RETRY_ATTEMPTS
7+
8+
9+
class ScriptedTransport:
10+
"""Fake transport that answers each `send()` with a scripted response, resolving
11+
the request's future the same way a real server reply would via `on_receive`.
12+
"""
13+
14+
def __init__(self, results):
15+
# results: list of ('ok', body) | ('fail', message) tuples, consumed in send() order.
16+
self._results = list(results)
17+
self.client = None # set after construction, once the client exists
18+
self.send_count = 0
19+
20+
def bind(self, **handlers):
21+
self.handlers = handlers
22+
23+
def is_connected(self):
24+
return True
25+
26+
async def send(self, message):
27+
self.send_count += 1
28+
kind, payload = self._results.pop(0)
29+
response = {'type': 'response', 'request_seq': message['seq']}
30+
if kind == 'ok':
31+
response['success'] = True
32+
response['body'] = payload
33+
else:
34+
response['success'] = False
35+
response['message'] = payload
36+
await self.client.on_receive(response)
37+
38+
39+
def _make_pipe(results):
40+
transport = ScriptedTransport(results)
41+
client = DataMixin(module='TEST', transport=transport)
42+
transport.client = client
43+
pipe = DataMixin.DataPipe(client, token='tok', mime_type='text/plain')
44+
return pipe, transport
45+
46+
47+
def test_open_retries_on_transient_connect_error(monkeypatch):
48+
_real_sleep = asyncio.sleep
49+
monkeypatch.setattr(asyncio, 'sleep', lambda *_a, **_kw: _real_sleep(0))
50+
51+
pipe, transport = _make_pipe(
52+
[
53+
('fail', "Failed to open a data pipe.\n\nConnect call failed ('127.0.0.1', 40006)"),
54+
('ok', {'pipe_id': 7}),
55+
]
56+
)
57+
58+
async def run_test():
59+
await pipe.open()
60+
assert transport.send_count == 2
61+
assert pipe.pipe_id == 7
62+
assert pipe.is_opened
63+
64+
asyncio.run(run_test())
65+
66+
67+
def test_open_gives_up_after_exhausting_retries(monkeypatch):
68+
_real_sleep = asyncio.sleep
69+
monkeypatch.setattr(asyncio, 'sleep', lambda *_a, **_kw: _real_sleep(0))
70+
71+
transient_failure = ('fail', "Connect call failed ('127.0.0.1', 40006)")
72+
pipe, transport = _make_pipe([transient_failure] * (_PIPE_OPEN_RETRY_ATTEMPTS + 1))
73+
74+
async def run_test():
75+
with pytest.raises(PipeException, match='Connect call failed'):
76+
await pipe.open()
77+
assert transport.send_count == _PIPE_OPEN_RETRY_ATTEMPTS
78+
assert not pipe.is_opened
79+
80+
asyncio.run(run_test())
81+
82+
83+
def test_open_does_not_retry_non_transient_failure():
84+
pipe, transport = _make_pipe([('fail', 'No pipeline found for token')])
85+
86+
async def run_test():
87+
with pytest.raises(PipeException, match='No pipeline found'):
88+
await pipe.open()
89+
assert transport.send_count == 1
90+
assert not pipe.is_opened
91+
92+
asyncio.run(run_test())
93+
94+
95+
def test_open_does_not_retry_connection_refused():
96+
# "Connection refused" is a distinct, permanent signature (e.g. a
97+
# misconfigured `remote` node) that the engine's inner connect-retry loop
98+
# never runs for, unlike "Connect call failed" - retrying it would just add
99+
# latency to a failure that was never going to succeed.
100+
pipe, transport = _make_pipe([('fail', "Connection refused ('127.0.0.1', 40006)")])
101+
102+
async def run_test():
103+
with pytest.raises(PipeException, match='Connection refused'):
104+
await pipe.open()
105+
assert transport.send_count == 1
106+
assert not pipe.is_opened
107+
108+
asyncio.run(run_test())
109+
110+
111+
def test_open_does_not_retry_non_string_failure_message():
112+
# A malformed response with a non-string `message` (e.g. a bare int) must
113+
# not blow up the `in` check that classifies transient errors.
114+
pipe, transport = _make_pipe([('fail', 404)])
115+
116+
async def run_test():
117+
with pytest.raises(PipeException, match='404'):
118+
await pipe.open()
119+
assert transport.send_count == 1
120+
assert not pipe.is_opened
121+
122+
asyncio.run(run_test())
123+
124+
125+
def test_open_preserves_falsey_non_string_failure_message():
126+
# `0`/`False` are real messages, not "no message" - they must survive
127+
# normalization instead of being swallowed into the generic fallback.
128+
pipe, transport = _make_pipe([('fail', 0)])
129+
130+
async def run_test():
131+
with pytest.raises(PipeException, match='0'):
132+
await pipe.open()
133+
assert transport.send_count == 1
134+
assert not pipe.is_opened
135+
136+
asyncio.run(run_test())
137+
138+
139+
def test_open_failure_keeps_message_and_carries_hint():
140+
pipe, transport = _make_pipe([('fail', 'No pipeline found for token')])
141+
142+
async def run_test():
143+
with pytest.raises(PipeException) as excinfo:
144+
await pipe.open()
145+
exc = excinfo.value
146+
# The server's message is preserved verbatim (fit to show an end user);
147+
# the developer checklist rides along separately as `hint`.
148+
assert str(exc) == 'No pipeline found for token'
149+
assert exc.hint is not None
150+
assert 'Common causes' in exc.hint
151+
152+
asyncio.run(run_test())

0 commit comments

Comments
 (0)