app: extract ReconnectManager to reconnect.py
Self-contained reconnection state machine with threading.Event for instant, thread-safe cancellation. Removes ~50 lines and all reconnect state from TuimbleApp.
This commit is contained in:
93
src/tuimble/reconnect.py
Normal file
93
src/tuimble/reconnect.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Reconnection manager with exponential backoff."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INITIAL_DELAY = 2
|
||||
MAX_DELAY = 30
|
||||
MAX_RETRIES = 10
|
||||
|
||||
|
||||
class ReconnectManager:
|
||||
"""Thread-safe reconnection with exponential backoff.
|
||||
|
||||
The manager runs a blocking loop in a worker thread. Cancellation
|
||||
is signalled via ``threading.Event``, making it both thread-safe and
|
||||
instantly responsive (no polling sleep).
|
||||
|
||||
Args:
|
||||
connect_fn: Called to attempt a reconnection. Should raise on
|
||||
failure; exceptions with a ``retryable`` attribute set to
|
||||
``False`` cause immediate abort.
|
||||
on_attempt: ``(attempt, delay)`` -- called before each wait.
|
||||
on_success: Called after a successful reconnection.
|
||||
on_failure: ``(attempt, error_msg)`` -- called after each failed
|
||||
attempt.
|
||||
on_exhausted: Called when all retries are spent.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connect_fn: Callable[[], None],
|
||||
on_attempt: Callable[[int, float], None],
|
||||
on_success: Callable[[], None],
|
||||
on_failure: Callable[[int, str], None],
|
||||
on_exhausted: Callable[[], None],
|
||||
):
|
||||
self._connect = connect_fn
|
||||
self._on_attempt = on_attempt
|
||||
self._on_success = on_success
|
||||
self._on_failure = on_failure
|
||||
self._on_exhausted = on_exhausted
|
||||
self._cancel = threading.Event()
|
||||
self._attempt = 0
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return not self._cancel.is_set() and self._attempt > 0
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
return self._attempt
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Signal the loop to stop. Safe to call from any thread."""
|
||||
self._cancel.set()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Blocking reconnect loop -- run in a worker thread."""
|
||||
self._cancel.clear()
|
||||
self._attempt = 0
|
||||
|
||||
while not self._cancel.is_set():
|
||||
self._attempt += 1
|
||||
delay = min(INITIAL_DELAY * (2 ** (self._attempt - 1)), MAX_DELAY)
|
||||
self._on_attempt(self._attempt, delay)
|
||||
|
||||
if self._cancel.wait(timeout=delay):
|
||||
break
|
||||
|
||||
try:
|
||||
self._connect()
|
||||
self._attempt = 0
|
||||
self._on_success()
|
||||
return
|
||||
except Exception as exc:
|
||||
retryable = getattr(exc, "retryable", True)
|
||||
self._on_failure(self._attempt, str(exc))
|
||||
if not retryable:
|
||||
self._attempt = 0
|
||||
self._on_exhausted()
|
||||
return
|
||||
|
||||
if self._attempt >= MAX_RETRIES:
|
||||
self._attempt = 0
|
||||
self._on_exhausted()
|
||||
return
|
||||
|
||||
self._attempt = 0
|
||||
Reference in New Issue
Block a user