🚧 WIP
This commit is contained in:
@@ -4,11 +4,14 @@ States: Connecting → Connected → ReconnectWait → Connecting …
|
||||
Backoff is exponential 1 s → 2 s → … capped at 60 s; every disconnect/reconnect
|
||||
pair is an event (D1/D2) with the outage duration computed and reported.
|
||||
|
||||
The transition/backoff engine lands in Phase 1 (IMPLEMENTATION_PLAN §4.5).
|
||||
The engine is pure: it is fed timestamps and returns transitions plus events,
|
||||
so the poller owns the actual clock/sleeping and tests drive it deterministically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
|
||||
|
||||
@@ -17,4 +20,97 @@ class ConnectionState(Enum):
|
||||
|
||||
CONNECTING = "connecting"
|
||||
CONNECTED = "connected"
|
||||
RECONNECT_WAIT = "reconnect_wait"
|
||||
RECONNECT_WAIT = "reconnect_wait"
|
||||
|
||||
|
||||
#: Backoff constants (SPECIFICATION §10.2).
|
||||
BACKOFF_INITIAL_S = 1.0
|
||||
BACKOFF_MAX_S = 60.0
|
||||
|
||||
#: D3 reconnect flapping: > this many disconnects within the window is CRITICAL.
|
||||
FLAPPING_DISCONNECTS = 3
|
||||
FLAPPING_WINDOW = timedelta(minutes=10)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateEvent:
|
||||
"""One state-machine transition worth reporting (D1/D2/D3)."""
|
||||
|
||||
kind: str # "disconnected" | "reconnected" | "flapping"
|
||||
at: datetime
|
||||
detail: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionStateMachine:
|
||||
"""Tracks connection state, backoff and reconnect accounting (D1–D3).
|
||||
|
||||
The poller calls :meth:`on_connected` / :meth:`on_disconnected` at the
|
||||
moments those things are observed; the machine decides *when* to emit
|
||||
events (D1 with the failure reason, D2 with the outage duration, D3 when
|
||||
disconnects flap) and *how long* to wait before the next attempt.
|
||||
"""
|
||||
|
||||
state: ConnectionState = ConnectionState.CONNECTING
|
||||
backoff_s: float = BACKOFF_INITIAL_S
|
||||
connected_since: datetime | None = None
|
||||
disconnected_since: datetime | None = None
|
||||
reconnects_total: int = 0
|
||||
_disconnect_times: list[datetime] = field(default_factory=list)
|
||||
|
||||
def on_connected(self, at: datetime) -> list[StateEvent]:
|
||||
"""A connect (or reconnect) succeeded at ``at``."""
|
||||
events: list[StateEvent] = []
|
||||
was_outage = self.state is ConnectionState.RECONNECT_WAIT
|
||||
self.state = ConnectionState.CONNECTED
|
||||
self.connected_since = at
|
||||
self.backoff_s = BACKOFF_INITIAL_S # reset on success (spec §10.2)
|
||||
if was_outage and self.disconnected_since is not None:
|
||||
self.reconnects_total += 1
|
||||
events.append(
|
||||
StateEvent(
|
||||
kind="reconnected",
|
||||
at=at,
|
||||
detail={
|
||||
"outage_s": (at - self.disconnected_since).total_seconds(),
|
||||
"reconnects_total": self.reconnects_total,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.disconnected_since = None
|
||||
return events
|
||||
|
||||
def on_disconnected(self, at: datetime, reason: str) -> list[StateEvent]:
|
||||
"""The connection dropped at ``at`` (reason = decoded failure)."""
|
||||
events: list[StateEvent] = []
|
||||
first_drop = self.state is not ConnectionState.RECONNECT_WAIT
|
||||
self.state = ConnectionState.RECONNECT_WAIT
|
||||
self.connected_since = None
|
||||
if first_drop:
|
||||
self.disconnected_since = at
|
||||
self._disconnect_times.append(at)
|
||||
self._disconnect_times = [
|
||||
t for t in self._disconnect_times if at - t <= FLAPPING_WINDOW
|
||||
]
|
||||
events.append(StateEvent(kind="disconnected", at=at, detail={"reason": reason}))
|
||||
if len(self._disconnect_times) > FLAPPING_DISCONNECTS:
|
||||
events.append(
|
||||
StateEvent(
|
||||
kind="flapping",
|
||||
at=at,
|
||||
detail={"disconnects_in_window": len(self._disconnect_times)},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
def next_attempt_delay_s(self) -> float:
|
||||
"""How long to wait before the next connect attempt (exponential backoff).
|
||||
|
||||
Call once per failed attempt; the value doubles up to
|
||||
:data:`BACKOFF_MAX_S` and resets only on a successful connect.
|
||||
"""
|
||||
if self.state is not ConnectionState.RECONNECT_WAIT:
|
||||
return 0.0
|
||||
delay = self.backoff_s
|
||||
self.backoff_s = min(self.backoff_s * 2, BACKOFF_MAX_S)
|
||||
return delay
|
||||
Reference in New Issue
Block a user