| 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 |
|