Skip to content

Commit f906fa2

Browse files
Ambient Code Botclaude
andcommitted
fix: address review feedback on retry logic
- Bound channel_ready_timeout by remaining dial_timeout deadline - Change FAILED_PRECONDITION timeout from raise to return for consistency - Extract _retry_delay() to deduplicate backoff computation - Add type annotations to _dial_and_connect and handle_async - Trim restating docstrings and comments - Add tests for FAILED_PRECONDITION retry/timeout, PERMISSION_DENIED flag, and _retry_delay helper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6c9f1db commit f906fa2

2 files changed

Lines changed: 113 additions & 32 deletions

File tree

python/packages/jumpstarter/jumpstarter/client/lease.py

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
fail_after,
2323
sleep,
2424
)
25+
from anyio.abc import SocketStream
2526
from anyio.from_thread import BlockingPortal
2627
from grpc.aio import AioRpcError, Channel
2728
from jumpstarter_protocol import jumpstarter_pb2, jumpstarter_pb2_grpc
@@ -312,7 +313,8 @@ def __contextmanager__(self) -> Generator[Self]:
312313
with self.portal.wrap_async_context_manager(self) as value:
313314
yield value
314315

315-
# gRPC status codes that indicate transient network failures worth retrying.
316+
# DEADLINE_EXCEEDED and CANCELLED are excluded: they indicate client-side
317+
# timeout or cancellation, not server/network transients worth retrying.
316318
_TRANSIENT_GRPC_CODES = frozenset({
317319
grpc.StatusCode.UNAVAILABLE,
318320
grpc.StatusCode.RESOURCE_EXHAUSTED,
@@ -326,46 +328,53 @@ def __contextmanager__(self) -> Generator[Self]:
326328
# known to occur during tunnel reconnection.
327329
_TRANSIENT_UNKNOWN_MESSAGES = ("watch channel closed",)
328330

329-
async def _dial_and_connect(self, stream):
330-
"""Dial the controller and connect to the router stream.
331+
@staticmethod
332+
def _retry_delay(attempt: int, remaining: float, base: float = 0.3, cap: float = 5.0) -> float:
333+
"""Compute exponential-backoff delay, capped by *cap* and *remaining* time."""
334+
return min(base * (2**attempt), cap, remaining)
331335

332-
Performs a single Dial + router connection attempt. Raises on failure
333-
so the caller can decide whether to retry.
334-
"""
336+
async def _dial_and_connect(
337+
self, stream: SocketStream, channel_ready_timeout: float = 10.0
338+
) -> None:
339+
"""Single attempt; raises on failure for caller-driven retry."""
335340
response = await self.controller.Dial(jumpstarter_pb2.DialRequest(lease_name=self.name))
336341
async with connect_router_stream(
337-
response.router_endpoint, response.router_token, stream, self.tls_config, self.grpc_options
342+
response.router_endpoint,
343+
response.router_token,
344+
stream,
345+
self.tls_config,
346+
self.grpc_options,
347+
channel_ready_timeout=channel_ready_timeout,
338348
):
339349
pass
340350

341-
async def handle_async(self, stream):
351+
async def handle_async(self, stream: SocketStream) -> None:
342352
logger.debug("Connecting to Lease with name %s", self.name)
343-
# Retry Dial + router connection with exponential backoff for transient
344-
# errors. This handles:
345-
# 1. The race condition where the client acquires a lease before the
346-
# exporter has transitioned to LEASE_READY status (FAILED_PRECONDITION).
347-
# 2. Transient network failures where the tunnel to the router drops and
348-
# needs to be re-established (UNAVAILABLE, etc.).
349-
# Uses time-based retry bounded by dial_timeout instead of fixed retry count.
350-
base_delay = 0.3
351-
max_delay = 5.0
353+
# Retry Dial + router connection with exponential backoff.
354+
# Handles FAILED_PRECONDITION (exporter not yet ready), transient
355+
# network errors (tunnel drops), and OSError (unreachable endpoint).
356+
# All error paths return instead of raising because handle_async runs
357+
# inside TemporaryUnixListener.serve's task group -- an unhandled
358+
# exception would crash the listener and terminate sibling connections.
352359
deadline = time.monotonic() + self.dial_timeout
353360
attempt = 0
354361
while True:
362+
remaining = deadline - time.monotonic()
363+
channel_ready_timeout = max(min(10.0, remaining), 0.5)
355364
try:
356-
await self._dial_and_connect(stream)
365+
await self._dial_and_connect(stream, channel_ready_timeout=channel_ready_timeout)
357366
return
358367
except AioRpcError as e:
359368
remaining = deadline - time.monotonic()
360369
if e.code() == grpc.StatusCode.FAILED_PRECONDITION and "not ready" in str(e.details()):
361370
if remaining <= 0:
362-
logger.debug(
371+
logger.warning(
363372
"Exporter not ready and dial timeout (%.1fs) exceeded after %d attempts",
364373
self.dial_timeout,
365374
attempt + 1,
366375
)
367-
raise
368-
delay = min(base_delay * (2**attempt), max_delay, remaining)
376+
return
377+
delay = self._retry_delay(attempt, remaining)
369378
logger.debug(
370379
"Exporter not ready, retrying in %.1fs (attempt %d, %.1fs remaining)",
371380
delay,
@@ -375,9 +384,6 @@ async def handle_async(self, stream):
375384
await sleep(delay)
376385
attempt += 1
377386
continue
378-
# Retry on transient network errors (e.g. tunnel to router dropped).
379-
# Also retry UNKNOWN when the message matches a known transient
380-
# tunnel teardown (e.g. "watch channel closed").
381387
is_transient = e.code() in self._TRANSIENT_GRPC_CODES or (
382388
e.code() == grpc.StatusCode.UNKNOWN
383389
and any(msg in str(e.details()).lower() for msg in self._TRANSIENT_UNKNOWN_MESSAGES)
@@ -390,11 +396,8 @@ async def handle_async(self, stream):
390396
self.dial_timeout,
391397
e.details(),
392398
)
393-
# Return instead of raising: handle_async runs inside
394-
# TemporaryUnixListener.serve's task group, so an
395-
# unhandled exception would crash the listener.
396399
return
397-
delay = min(base_delay * (2**attempt), max_delay, remaining)
400+
delay = self._retry_delay(attempt, remaining)
398401
logger.info(
399402
"Connection failed with %s, retrying in %.1fs (attempt %d, %.1fs remaining): %s",
400403
e.code().name,
@@ -406,7 +409,6 @@ async def handle_async(self, stream):
406409
await sleep(delay)
407410
attempt += 1
408411
continue
409-
# Exporter went offline or lease ended - log and exit gracefully
410412
if "permission denied" in str(e.details()).lower():
411413
self.lease_transferred = True
412414
logger.warning(
@@ -417,10 +419,9 @@ async def handle_async(self, stream):
417419
logger.warning("Connection to exporter lost: %s", e.details())
418420
return
419421
except OSError as e:
420-
# OSError can occur when the router endpoint is unreachable
421422
remaining = deadline - time.monotonic()
422423
if remaining > 0:
423-
delay = min(base_delay * (2**attempt), max_delay, remaining)
424+
delay = self._retry_delay(attempt, remaining)
424425
logger.info(
425426
"Connection failed with OSError, retrying in %.1fs (attempt %d, %.1fs remaining): %s",
426427
delay,
@@ -432,7 +433,6 @@ async def handle_async(self, stream):
432433
attempt += 1
433434
continue
434435
logger.warning("Connection failed: %s", e)
435-
# Return instead of raising: see transient-error comment above.
436436
return
437437

438438
@asynccontextmanager

python/packages/jumpstarter/jumpstarter/client/lease_test.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,87 @@ async def fake_router(*args, **kwargs):
909909
)
910910

911911

912+
@pytest.mark.anyio
913+
async def test_failed_precondition_not_ready_retries_then_succeeds(self):
914+
"""FAILED_PRECONDITION 'not ready' should retry and succeed on next attempt."""
915+
lease = _make_lease_for_handle()
916+
dial_response = Mock(router_endpoint="ep", router_token="tok")
917+
call_count = 0
918+
919+
async def dial_side_effect(req):
920+
nonlocal call_count
921+
call_count += 1
922+
if call_count == 1:
923+
raise _make_aio_rpc_error(
924+
grpc.StatusCode.FAILED_PRECONDITION, "exporter not ready"
925+
)
926+
return dial_response
927+
928+
lease.controller.Dial = AsyncMock(side_effect=dial_side_effect)
929+
930+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
931+
with patch("jumpstarter.client.lease.connect_router_stream") as mock_router:
932+
933+
@asynccontextmanager
934+
async def fake_router(*args, **kwargs):
935+
yield
936+
937+
mock_router.side_effect = fake_router
938+
await lease.handle_async(Mock())
939+
940+
assert call_count == 2
941+
942+
@pytest.mark.anyio
943+
async def test_failed_precondition_returns_after_timeout(self):
944+
"""FAILED_PRECONDITION should return (not raise) when dial_timeout is exceeded."""
945+
lease = _make_lease_for_handle()
946+
lease.dial_timeout = 0.0 # already expired
947+
948+
lease.controller.Dial = AsyncMock(
949+
side_effect=_make_aio_rpc_error(
950+
grpc.StatusCode.FAILED_PRECONDITION, "exporter not ready"
951+
),
952+
)
953+
954+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
955+
# Should return without raising
956+
await lease.handle_async(Mock())
957+
958+
lease.controller.Dial.assert_called_once()
959+
960+
@pytest.mark.anyio
961+
async def test_permission_denied_sets_lease_transferred(self):
962+
"""PERMISSION_DENIED should set lease_transferred = True."""
963+
lease = _make_lease_for_handle()
964+
assert lease.lease_transferred is False
965+
966+
lease.controller.Dial = AsyncMock(
967+
side_effect=_make_aio_rpc_error(
968+
grpc.StatusCode.PERMISSION_DENIED, "permission denied"
969+
),
970+
)
971+
972+
with patch("jumpstarter.client.lease.sleep", new_callable=AsyncMock):
973+
await lease.handle_async(Mock())
974+
975+
assert lease.lease_transferred is True
976+
977+
978+
class TestRetryDelay:
979+
"""Tests for the _retry_delay static method."""
980+
981+
def test_basic_exponential(self):
982+
assert Lease._retry_delay(0, 60.0) == pytest.approx(0.3)
983+
assert Lease._retry_delay(1, 60.0) == pytest.approx(0.6)
984+
assert Lease._retry_delay(2, 60.0) == pytest.approx(1.2)
985+
986+
def test_capped_by_max(self):
987+
assert Lease._retry_delay(10, 60.0) == pytest.approx(5.0)
988+
989+
def test_capped_by_remaining(self):
990+
assert Lease._retry_delay(0, 0.1) == pytest.approx(0.1)
991+
992+
912993
class TestTransientGrpcCodes:
913994
"""Tests for the _TRANSIENT_GRPC_CODES class attribute."""
914995

0 commit comments

Comments
 (0)