Eventually Assertion Helper

Poll an async predicate until it succeeds or times out, with abort support and the last error preserved as the cause.

TypescriptResilience

Code

snippet
export interface EventuallyOptions {
  timeoutMs?: number;
  intervalMs?: number;
  signal?: AbortSignal;
}

export async function eventually<T>(
  predicate: () => T | Promise<T>,
  options: EventuallyOptions = {},
): Promise<T> {
  const { timeoutMs = 2000, intervalMs = 50, signal } = options;
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
    throw new RangeError("timeoutMs must be a non-negative finite number");
  }
  if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
    throw new RangeError("intervalMs must be a positive finite number");
  }
  const deadline = Date.now() + timeoutMs;
  let lastError: unknown;
  while (true) {
    if (signal?.aborted) {
      const aborted = new Error("eventually aborted");
      aborted.name = "AbortError";
      throw aborted;
    }
    try {
      const value = await predicate();
      if (value) return value;
    } catch (error) {
      lastError = error;
    }
    if (Date.now() >= deadline) {
      const message = "eventually timed out after " + timeoutMs + "ms";
      throw lastError ? new Error(message, { cause: lastError }) : new Error(message);
    }
    const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
    await new Promise<void>((resolve) => setTimeout(resolve, wait));
  }
}