Files
suaveplan/openspec/archive/2026-05-18-timing-completion/proposal.md
2026-05-27 23:29:53 +01:00

9.6 KiB

Timing completion — rateLimit / once / times helpers

Package: @suaveplan/timing Status: PROPOSED Type: feature

Why

The 2026-04-29 timing-spec-conformance change deliberately archived the more elaborate timing constructs (createHighPrecisionStopwatch, createHighConcurrencyScheduler, VectorClockManager) on architectural grounds — those landed in higher-tier packages or were superseded by composable primitives. However, three smaller helpers from the legacy throttle/throttle.ts module never got the same treatment: rateLimit, once, and times. They are not architecturally suspect, they are not duplicated elsewhere on genesis, and they fall well within the scope CLAUDE.md describes for @suaveplan/timing. They were simply missed.

The 2026-05-02 audit found no replacement for any of the three:

  • rateLimit(fn, maxCalls, windowMs) — different from throttle (which spaces calls evenly across an interval). rateLimit enforces N calls per sliding window and lets callers burst inside the window. This is the contract REST clients, webhook deliverers, and feed pollers need; throttle is the wrong tool. Genesis has no equivalent.
  • once(fn) — wraps a function so it executes exactly once and caches the result. Used by initialization paths where a check-and-set guard is too verbose (e.g. lazy DB connection pool warm-up). Genesis has no equivalent. The closest pattern is wrapping in Promise.resolve(...) and stashing the promise, which loses the synchronous return signature for synchronous functions.
  • times(fn, maxCalls) — wraps a function so it runs for the first n calls and becomes a no-op afterward. Useful for "log this warning the first 5 times then stop" patterns; the alternative — manual counters threaded through every call site — is clutter genesis already has too much of.

All three are 15-30 line implementations with a small surface and clear semantics. Adding them to the existing throttle/ feature module (alongside throttle and debounce) is the lowest-friction path; renaming the module to rate-control/ would have the same outcome but disturbs the existing tree-shake boundary. Keep throttle/ and add the three siblings.

What changes

@suaveplan/timing

  • ADDED: rateLimit<TArgs, TReturn>(fn, options): (...args: TArgs) => TReturn | RateLimitedSentinel — bounded calls per window. options.maxCalls: number, options.windowMs: number, options.clock?: Clock (injectable; defaults to system clock). When the window cap is exceeded, the wrapped function returns RATE_LIMITED (the exported sentinel) instead of invoking fn. The sliding window is implemented as a FIFO of timestamps; entries older than windowMs are evicted on every call.
  • ADDED: RATE_LIMITED constant — the sentinel symbol returned by rateLimit when the cap is reached. Exported alongside rateLimit.
  • ADDED: RateLimitedSentinel type alias — typeof RATE_LIMITED.
  • ADDED: once<TArgs, TReturn>(fn): (...args: TArgs) => TReturn — wrapped function that runs fn exactly once and caches the result. Subsequent calls return the cached value with the same TReturn type (no | undefined widening). Throws of fn are not cached — a thrown call counts as not-yet-run, allowing retry.
  • ADDED: times<TArgs, TReturn>(fn, maxCalls): (...args: TArgs) => TReturn | undefined — wrapped function that runs fn for the first maxCalls invocations and returns undefined thereafter.
  • ADDED: subpath export ./throttle already exists; the three helpers live in src/throttle/rate-limit/rate-limit.ts, src/throttle/once/once.ts, src/throttle/times/times.ts. Each gets its own subpath: ./throttle/rate-limit, ./throttle/once, ./throttle/times.
  • ADDED: OTel counter timing.rate_limit.rejected (no labels) incremented every time rateLimit returns the sentinel. Counter timing.once.invocation and timing.times.invocation similarly emitted for observability parity with throttle / debounce.
  • ADDED: error code TIMING.RATE_LIMIT_INVALID_WINDOW — thrown by the rateLimit factory when maxCalls < 1 or windowMs <= 0.

Out of scope

  • createHighPrecisionStopwatch, createHighConcurrencyScheduler, VectorClockManager — these were intentionally archived in 2026-04-29-timing-spec-conformance and stay archived. This change does not revisit that decision.
  • Async rateLimit with queue-and-retry semantics. The legacy implementation drops over-cap calls; genesis matches that behaviour. A queueing variant (await-until-slot-available) is a separate proposal — it has different cancellation, abort-signal, and back-pressure semantics and warrants its own design.
  • Distributed rate limiting. The wrapped function is process-local. Cluster-wide rate limiting goes through @suaveplan/idempotency or an external token bucket; this change does not address it.
  • Memoization with eviction. once is intentionally simple — no LRU, no TTL, no key-based memoization. Callers that need keyed memoization compose once per key OR reach for @suaveplan/idempotency.
  • Re-introducing Date.now() at module scope. All three helpers accept an injectable Clock from @suaveplan/types; the default is a () => Date.now() clock injected at construction time, not module scope. This complies with CLAUDE.md determinism rules.

Parity Checklist

Capability Upstream Migrated Notes
rateLimit(func, maxCalls, windowMs, _options) ✅ ⬆️ Enhanced: signature changed to (fn, options) (object) for ergonomic parity with throttle / debounce. Injectable Clock. Returns RATE_LIMITED sentinel instead of undefined for clarity.
once(func) ✅ ⬆️ Enhanced: exception-aware — if fn throws, the wrapper re-throws and stays "not yet run" so retries are possible (legacy cached the throw via the side-effecting called = true; result = func(...) order, which silently re-returned undefined on retry).
times(func, maxCalls) ✅ ✅ Verbatim semantics.

Source mapping

This change derives from ../suaveplan/packages/universal/timing/src/throttle/throttle.ts.

Requirement Source file Source lines Notes
rateLimit(fn, maxCalls, windowMs) semantics packages/universal/timing/src/throttle/throttle.ts L278-L309 Verbatim semantics; signature reshaped to (fn, options) and return value changed to a sentinel.
once(fn) semantics packages/universal/timing/src/throttle/throttle.ts L314-L328 Enhanced: throw-and-retry behaviour added (legacy cached the call attempt regardless).
times(fn, maxCalls) semantics packages/universal/timing/src/throttle/throttle.ts L333-L345 Verbatim.

Requirements with no source line

Requirement Justification
RATE_LIMITED sentinel symbol Genesis-wide preference for explicit sentinels over undefined returns — undefined ambiguously overlaps with functions whose TReturn is void. The legacy signature returned TReturn | undefined and could not distinguish "rate-limited" from "function returned undefined".
Object-shaped options parameter for rateLimit Genesis-wide ergonomic rule — every primitive helper in @suaveplan/timing (throttle, debounce) takes (fn, options). rateLimit matching that shape is the lowest-friction call site.
Injectable Clock for rateLimit window math Genesis-wide determinism rule — every time-dependent helper accepts a Clock from @suaveplan/types.
OTel counters timing.rate_limit.rejected, timing.once.invocation, timing.times.invocation Genesis-wide observability rule.
Error code TIMING.RATE_LIMIT_INVALID_WINDOW Genesis-wide policy that every thrown error route through @suaveplan/error with a stable code. Legacy threw nothing — it accepted 0 / negative inputs silently and returned a wrapper that always rate-limited.
Throw-and-retry semantics for once Legacy called = true runs before result = func(...), so a throw locks the wrapper out forever. Genesis fixes this — throwing leaves called at false so a subsequent call retries. Documented in once.md.

Verification approach

  • src/throttle/rate-limit/rate-limit.test.ts — bursts inside window, eviction across window boundary, sentinel returned at cap, injectable clock advances drive eviction.
  • src/throttle/rate-limit/rate-limit.spec-conformance.test.ts — RATE_LIMITED is a unique symbol; OTel counter increments on rejection; invalid maxCalls / windowMs throws TIMING.RATE_LIMIT_INVALID_WINDOW.
  • src/throttle/once/once.test.ts — runs once, caches return, throw-and-retry flow.
  • src/throttle/once/once.spec-conformance.test.ts — counter increments on first invocation only; type-level TReturn (not TReturn | undefined) on the wrapper.
  • src/throttle/times/times.test.ts — runs n times, returns undefined thereafter, counter behaviour.
  • src/throttle/times/times.spec-conformance.test.ts — counter increments per invocation up to maxCalls.

Impact

Affected dependents

@suaveplan/timing currently has zero downstream packages on genesis (verified by bunx turbo ls --filter=...@suaveplan/timing returning only the package itself).

  • No dependents (verified by grep -rln "@suaveplan/timing" /home/ubuntu/web/genesis/packages/ --include=package.json returning only packages/timing/timing/package.json).

When new consumers land they import @suaveplan/timing/throttle/rate-limit, etc. The change is purely additive — no existing public surface is removed or renamed.

Migration impact

None — additive only. throttle and debounce already exist at the same subpath structure; the three new helpers slot in alongside them.