Skip to main content

max / makenotwork

4.2 KB · 120 lines History Blame Raw
1 /**
2 * Reconnect pacing for the chat stream.
3 *
4 * Every deploy is a symlink swap and a service restart with no connection
5 * draining, so every open chat drops at once and every client tries to come
6 * back at the same moment. Pacing that is the whole job of this module.
7 *
8 * Ported from synckit-client's `reconnect_delay` (MNW/shared/synckit-client,
9 * src/client/subscribe.rs): 1s, 2s, 4s and so on, capped, with jitter so a fleet
10 * does not reconnect in lockstep, and a consecutive-failure ceiling so a
11 * permanently broken server is not retried silently forever.
12 *
13 * Pure on purpose. No timers, no network, no DOM: the caller supplies the clock
14 * and the randomness, which is what makes the pacing testable without waiting
15 * real seconds for it.
16 */
17
18 /** Cap on the backoff delay, in milliseconds. */
19 export const MAX_RECONNECT_DELAY_MS = 60_000;
20
21 /**
22 * Consecutive failed attempts before giving up and telling the user.
23 *
24 * A single success resets the count, so this only fires when the server has been
25 * unreachable for a sustained stretch. At the 60s cap that is several minutes,
26 * after which a visible "reconnect" affordance beats retrying into the void.
27 */
28 export const MAX_RECONNECT_ATTEMPTS = 10;
29
30 /**
31 * Exponent ceiling. 2^6 = 64s already exceeds the delay cap, so growing the
32 * exponent past this only risks overflow for no behavioral difference.
33 */
34 const MAX_EXPONENT = 6;
35
36 /** Jitter spread, as a fraction of the base delay. */
37 const JITTER_FRACTION = 0.2;
38
39 /**
40 * Delay before attempt number `attempt` (zero-based).
41 *
42 * `random` returns a value in [0, 1) and is injected so tests can pin the
43 * jitter. Defaults to `Math.random`.
44 */
45 export function reconnectDelayMs(
46 attempt: number,
47 random: () => number = Math.random,
48 ): number {
49 const exponent = Math.min(Math.max(attempt, 0), MAX_EXPONENT);
50 const base = Math.min(1000 * 2 ** exponent, MAX_RECONNECT_DELAY_MS);
51 const span = base * JITTER_FRACTION;
52 // Uniform across [base - span, base + span]. Jitter is added after the cap, so
53 // clamp the result back down: +20% on a capped base would otherwise return ~72s,
54 // above the documented MAX_RECONNECT_DELAY_MS.
55 const delta = random() * 2 * span - span;
56 const jittered = Math.round(base + delta);
57 return Math.min(MAX_RECONNECT_DELAY_MS, Math.max(0, jittered));
58 }
59
60 /** What the caller should do next. */
61 export type ReconnectAction =
62 | { kind: "retry"; delayMs: number; attempt: number }
63 | { kind: "give-up" };
64
65 /**
66 * Tracks consecutive failures and hands back the next move.
67 *
68 * Deliberately not a timer. It says when to reconnect, never performs one, so
69 * the same object drives both the real client and its tests.
70 */
71 export class ReconnectPolicy {
72 #attempt = 0;
73 readonly #random: () => number;
74
75 // An explicit field rather than a constructor parameter property: Node's
76 // type stripping erases types but cannot transform syntax, and a parameter
77 // property is a transform.
78 constructor(random: () => number = Math.random) {
79 this.#random = random;
80 }
81
82 /** Consecutive failures since the last success. */
83 get attempt(): number {
84 return this.#attempt;
85 }
86
87 /** Record a failed connection and get the next move. */
88 fail(): ReconnectAction {
89 if (this.#attempt >= MAX_RECONNECT_ATTEMPTS) {
90 return { kind: "give-up" };
91 }
92 const delayMs = reconnectDelayMs(this.#attempt, this.#random);
93 const attempt = ++this.#attempt;
94 return { kind: "retry", delayMs, attempt };
95 }
96
97 /**
98 * Record a successful connection.
99 *
100 * Resets the streak, so a client that reconnects fine every deploy never
101 * accumulates its way to the give-up ceiling over a long-lived tab.
102 */
103 succeed(): void {
104 this.#attempt = 0;
105 }
106
107 /**
108 * Drop the accumulated backoff and allow an immediate attempt.
109 *
110 * For the cases where waiting out the backoff is obviously wrong: the tab
111 * became visible again, or the browser reported the network came back. The
112 * delay exists to avoid hammering a struggling server, and neither of those
113 * events says anything about the server, so honoring a 60s wait would just
114 * make chat look broken for a minute after the laptop opens.
115 */
116 resetForImmediateRetry(): void {
117 this.#attempt = 0;
118 }
119 }
120