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:
Username
2026-02-24 16:32:29 +01:00
parent 216a4be4fd
commit 0cf3702c8f

93
src/tuimble/reconnect.py Normal file
View 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