102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
import {
|
|
ExponentialBackoff,
|
|
type ExponentialBackoffOptions,
|
|
} from './exponential-backoff';
|
|
import { Timer } from './timer';
|
|
|
|
// Expand a plain `number` (fixed delay in seconds) into the equivalent
|
|
// `ExponentialBackoffOptions`: base = max so growth flattens, jitter pinned to
|
|
// 1.0 so the delay is exactly N every time.
|
|
const convertToBackoffOptions = (
|
|
options: ExponentialBackoffOptions | number,
|
|
): ExponentialBackoffOptions => {
|
|
if (typeof options === 'number') {
|
|
return {
|
|
baseSeconds: options,
|
|
maxSeconds: options,
|
|
jitterMin: 1,
|
|
jitterMax: 1,
|
|
};
|
|
}
|
|
return options;
|
|
};
|
|
|
|
/**
|
|
* Pairs an `ExponentialBackoff` with a `Timer` for retry scheduling. Each
|
|
* `schedule(...)` call fires after the current backoff delay; `advance()`
|
|
* bumps the counter for next time.
|
|
*
|
|
* Constructor and `setOptions` accept either `ExponentialBackoffOptions`
|
|
* (growth + jitter) or a plain `number` (fixed delay in seconds, no growth,
|
|
* no jitter). Internally a number expands to `{ baseSeconds: N, maxSeconds:
|
|
* N, jitterMin: 1, jitterMax: 1 }` so all methods behave uniformly -- callers
|
|
* never branch.
|
|
*
|
|
* Typical patterns:
|
|
* - "Failure happened, retry later, count this failure": `schedule(cb)`.
|
|
* - "Retry deferred for an unrelated reason; don't compound":
|
|
* `schedule(cb, { advance: false })` (re-arms at the current delay).
|
|
* - "Attempt happened, count it separately from scheduling": `advance()`.
|
|
* - "Operation succeeded": `reset()`.
|
|
* - "Caller going away": `cancel()`.
|
|
*/
|
|
export class RetryTimer {
|
|
private readonly _backoff: ExponentialBackoff;
|
|
private readonly _timer = new Timer();
|
|
|
|
constructor(options: ExponentialBackoffOptions | number) {
|
|
this._backoff = new ExponentialBackoff(convertToBackoffOptions(options));
|
|
}
|
|
|
|
/**
|
|
* Replace the backoff configuration. Cheap and idempotent: doesn't cancel
|
|
* pending callbacks or reset the attempt counter, so it's safe to call on
|
|
* every scheduling pass regardless of whether the options actually changed.
|
|
* Call `reset()` separately if zeroing the counter is desired (e.g. on a
|
|
* semantically distinct mode switch).
|
|
*/
|
|
public setOptions(options: ExponentialBackoffOptions | number): void {
|
|
this._backoff.setOptions(convertToBackoffOptions(options));
|
|
}
|
|
|
|
/**
|
|
* Schedule `callback` to fire after the current delay, then advance the
|
|
* attempt counter so the next schedule uses a longer delay. Pass
|
|
* `{ advance: false }` to re-arm at the current delay without counting it
|
|
* (e.g. a retry that may be gated and re-scheduled). Any pending callback
|
|
* is canceled before scheduling.
|
|
*/
|
|
public schedule(callback: () => void, options?: { advance?: boolean }): void {
|
|
this._timer.start(this._backoff.peek(), callback);
|
|
if (options?.advance !== false) {
|
|
this._backoff.next();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Bump the attempt counter without scheduling. For flows where the schedule
|
|
* call and the "attempt happened, count it" event are separate (e.g. the
|
|
* scheduled callback may or may not actually retry, depending on a gate).
|
|
*/
|
|
public advance(): void {
|
|
this._backoff.next();
|
|
}
|
|
|
|
public cancel(): void {
|
|
this._timer.stop();
|
|
}
|
|
|
|
public reset(): void {
|
|
this._timer.stop();
|
|
this._backoff.reset();
|
|
}
|
|
|
|
public isRunning(): boolean {
|
|
return this._timer.isRunning();
|
|
}
|
|
|
|
public getAttempts(): number {
|
|
return this._backoff.getAttempts();
|
|
}
|
|
}
|