2222 fail_after ,
2323 sleep ,
2424)
25+ from anyio .abc import SocketStream
2526from anyio .from_thread import BlockingPortal
2627from grpc .aio import AioRpcError , Channel
2728from 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
0 commit comments