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-schedulerwas implemented (archived 2026-05-12),executeJobcould not delegate to@suaveplan/retry.retry()because the retry primitive'ssleep()andwithTimeout()use rawsetTimeout/clearTimeout. UnderFakeClock-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/typesexposesClock,MonotonicClock,Rng, etc. — but no timer-pair contract.@suaveplan/crondefinesSchedulerTimerslocally insrc/scheduler/cron-scheduler.types.ts. This is the de-facto contract that should move to@suaveplan/types.@suaveplan/retrycallssetTimeout/clearTimeoutdirectly insrc/retry/retry.ts(sleepandwithTimeout).RetryConfighasclock?andrng?but notimers?.@suaveplan/job-schedulerhas an inline retry loop insrc/execution/job-execution.tsdriven by the scheduler's injectedtimers. The@suaveplan/retryworkspace 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:SchedulerTimerskeeps its name as a type alias; consumers importing it from@suaveplan/cronsee no behavioral or type change.job-scheduler:JobSchedulerpublic 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-breakerextracted from retry, or workflow-tier scheduler peers). - Slightly increases
@suaveplan/typespayload (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.