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