Files
2026-05-27 23:29:53 +01:00

5.7 KiB

OpenSpec Change: retry-injectable-timers

  • Change ID: retry-injectable-timers
  • Packages touched: @suaveplan/types, @suaveplan/retry, @suaveplan/cron, @suaveplan/job-scheduler
  • Category: control-flow / foundation
  • Status: PROPOSED
  • Date: 2026-05-12
  • Motivation: When @suaveplan/job-scheduler was implemented (archived 2026-05-12), executeJob could not delegate to @suaveplan/retry.retry() because the retry primitive's sleep() and withTimeout() use raw setTimeout/clearTimeout. Under FakeClock-driven tests those timers don't advance, so the retry loop hangs and the integration test would never deterministically progress. The job-scheduler shipped with an inline retry loop carrying the comment "switch over when retry's timers become injectable". This change does that.

Summary

Add an injectable TimersLike contract ({ setTimer, clearTimer }) to @suaveplan/types so every package needing test-controllable scheduling can accept it through one shared interface. Extend @suaveplan/retry's RetryConfig with an optional timers?: TimersLike field. Thread the pair through sleep() and withTimeout(). Update @suaveplan/cron's pre-existing SchedulerTimers interface to be a type alias of TimersLike (preserving its public name for backwards compatibility). Once retry's timers are injectable, replace @suaveplan/job-scheduler's inline retry loop with retry.retry() — removing duplicated retry semantics from the scheduler and letting the proven retry primitive own that concern.


Current-state audit

  • @suaveplan/types exposes Clock, MonotonicClock, Rng, etc. — but no timer-pair contract.
  • @suaveplan/cron defines SchedulerTimers locally in src/scheduler/cron-scheduler.types.ts. This is the de-facto contract that should move to @suaveplan/types.
  • @suaveplan/retry calls setTimeout/clearTimeout directly in src/retry/retry.ts (sleep and withTimeout). RetryConfig has clock? and rng? but no timers?.
  • @suaveplan/job-scheduler has an inline retry loop in src/execution/job-execution.ts driven by the scheduler's injected timers. The @suaveplan/retry workspace dep is declared but only as a future hook.

Public API changes

@suaveplan/types

// New, exported from the package root.
export interface TimersLike {
  /** Schedule `callback` to run after `delayMs`. Returns a handle. */
  setTimer(callback: () => void, delayMs: number): number;
  /** Cancel a pending timer by handle. Idempotent for unknown handles. */
  clearTimer(handle: number): void;
}

The shape mirrors globalThis.setTimeout/clearTimeout minus variadic args and Node's Timeout object — the lowest-common-denominator that works across Date.now()-driven production code and FakeClock-driven test code.

@suaveplan/cron

// Was: locally-defined interface.
// Now: type alias of the shared contract; existing import names remain valid.
import type { TimersLike } from "@suaveplan/types";
export type SchedulerTimers = TimersLike;

No behavioral change. The local name is preserved so existing internal call sites compile unchanged.

@suaveplan/retry

import type { Clock, Rng, TimersLike } from "@suaveplan/types";

export interface RetryConfig<_T = unknown> {
  maxAttempts: number;
  delay: RetryStrategy;
  shouldRetry?: (error: unknown, attempt: number) => boolean;
  signal?: AbortSignal;
  clock?: Clock;
  rng?: Rng;
  /** Pluggable timer pair. Defaults to `globalThis.setTimeout`/`clearTimeout`. */
  timers?: TimersLike;
}

// sleep + withTimeout gain an optional final `timers?: TimersLike` argument.
export function sleep(
  ms: number,
  signal?: AbortSignal,
  timers?: TimersLike
): Promise<void>;

export function withTimeout<T>(
  fn: (signal?: AbortSignal) => Promise<T>,
  ms: number,
  attemptNumber: number,
  signal?: AbortSignal,
  timers?: TimersLike
): Promise<T>;

When timers is omitted the implementation falls back to the platform setTimeout/clearTimeout — every existing caller keeps the current behavior.

@suaveplan/job-scheduler

No public-API change. The inline retry loop in src/execution/job-execution.ts is replaced with a single retry() call, threading the scheduler's injected timers and clock through. JobSchedulerOptions.timers stays exactly the same.


Why a shared TimersLike

Per openspec/AGENTS.md §0 step 6: any type shared across ≥2 packages belongs in @suaveplan/types to keep the public contract in one place and prevent circular deps. Two packages (cron shipped, retry after this change) already need it; a third (job-scheduler) consumes it transitively. The alternative — three copies of the same interface — is the kind of drift the rule explicitly exists to prevent.


Backwards compatibility

  • retry: every existing call site works unchanged (new field is optional; new function parameters are optional).
  • cron: SchedulerTimers keeps its name as a type alias; consumers importing it from @suaveplan/cron see no behavioral or type change.
  • job-scheduler: JobScheduler public surface unchanged. Internally swapping retry implementations is a refactor, not an API break.

Impact

  • Removes ~40 lines of duplicated retry logic from job-scheduler/src/execution/job-execution.ts.
  • Unifies the timers contract for future packages (e.g. a future @suaveplan/circuit-breaker extracted from retry, or workflow-tier scheduler peers).
  • Slightly increases @suaveplan/types payload (one tiny interface, zero runtime impact — types-only).

Prerequisites on Verdaccio: @suaveplan/types@0.0.0+, @suaveplan/retry@0.0.0+, @suaveplan/cron@0.0.0+, @suaveplan/job-scheduler@0.0.0+. All already published.