diff --git a/src/tuimble/reconnect.py b/src/tuimble/reconnect.py new file mode 100644 index 0000000..345ebb3 --- /dev/null +++ b/src/tuimble/reconnect.py @@ -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