Files
suaveplan/packages/networking/drive/drive-webrtc/src/client/reconnect/reconnect.ts
2026-05-27 23:47:53 +01:00

93 lines
2.9 KiB
TypeScript

/**
* Reusable bounded-backoff reconnect / retry controller.
*
* Generic helper consumed by {@link WebRTCClient}'s ICE-restart loop and
* available to any other genesis subsystem that needs a deterministic,
* clock-injectable backoff sequence. Plain logic, no WebRTC coupling —
* which keeps it trivial to unit test and reuse.
*/
/**
* Policy describing the backoff sequence and exhaustion bound.
*/
export interface ReconnectPolicy {
/** Backoff delays in milliseconds. The last entry is clamped on overflow. */
readonly backoffMs: readonly number[];
/** Maximum number of attempts before {@link ReconnectController.next} returns null. */
readonly maxAttempts: number;
}
/**
* One advancing step in the backoff sequence.
*/
export interface ReconnectStep {
/** 1-indexed attempt number. */
readonly attempt: number;
/** Delay before this attempt should fire, in milliseconds. */
readonly delayMs: number;
}
/**
* Stateful controller that walks a {@link ReconnectPolicy}'s backoff
* sequence. Calling {@link ReconnectController.next} advances internal
* state; calling {@link ReconnectController.reset} returns to attempt 0.
*/
export class ReconnectController {
private readonly policy: ReconnectPolicy;
private readonly clock: { now: () => number };
private currentAttempts = 0;
private lastEventAt = 0;
public constructor(
policy: ReconnectPolicy,
clock: { now: () => number } = { now: () => Date.now() }
) {
if (policy.backoffMs.length === 0) {
throw new RangeError(
"ReconnectController: backoffMs must contain at least one step"
);
}
if (policy.maxAttempts < 1) {
throw new RangeError(
"ReconnectController: maxAttempts must be >= 1"
);
}
this.policy = policy;
this.clock = clock;
}
/**
* Returns the delay for the next attempt; advances internal state.
* Returns `null` once `maxAttempts` is reached.
*/
public next(): ReconnectStep | null {
if (this.currentAttempts >= this.policy.maxAttempts) {
return null;
}
const idx = Math.min(
this.currentAttempts,
this.policy.backoffMs.length - 1
);
const delayMs = this.policy.backoffMs[idx] as number;
this.currentAttempts += 1;
this.lastEventAt = this.clock.now();
return { attempt: this.currentAttempts, delayMs };
}
/** Reset to attempt 0. */
public reset(): void {
this.currentAttempts = 0;
this.lastEventAt = 0;
}
/** Attempts consumed so far. */
public get attempts(): number {
return this.currentAttempts;
}
/** Timestamp of the last `next()` call, sourced from the injected clock. */
public get lastAttemptAt(): number {
return this.lastEventAt;
}
}