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 fromthrottle(which spaces calls evenly across an interval).rateLimitenforces 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 inPromise.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 firstncalls 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 returnsRATE_LIMITED(the exported sentinel) instead of invokingfn. The sliding window is implemented as a FIFO of timestamps; entries older thanwindowMsare evicted on every call. - ADDED:
RATE_LIMITEDconstant — the sentinel symbol returned byrateLimitwhen the cap is reached. Exported alongsiderateLimit. - ADDED:
RateLimitedSentineltype alias —typeof RATE_LIMITED. - ADDED:
once<TArgs, TReturn>(fn): (...args: TArgs) => TReturn— wrapped function that runsfnexactly once and caches the result. Subsequent calls return the cached value with the sameTReturntype (no| undefinedwidening). Throws offnare 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 runsfnfor the firstmaxCallsinvocations and returnsundefinedthereafter. - ADDED: subpath export
./throttlealready exists; the three helpers live insrc/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 timerateLimitreturns the sentinel. Countertiming.once.invocationandtiming.times.invocationsimilarly emitted for observability parity withthrottle/debounce. - ADDED: error code
TIMING.RATE_LIMIT_INVALID_WINDOW— thrown by therateLimitfactory whenmaxCalls < 1orwindowMs <= 0.
Out of scope
createHighPrecisionStopwatch,createHighConcurrencyScheduler,VectorClockManager— these were intentionally archived in2026-04-29-timing-spec-conformanceand stay archived. This change does not revisit that decision.- Async
rateLimitwith 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/idempotencyor an external token bucket; this change does not address it. - Memoization with eviction.
onceis intentionally simple — no LRU, no TTL, no key-based memoization. Callers that need keyed memoization composeonceper key OR reach for@suaveplan/idempotency. - Re-introducing
Date.now()at module scope. All three helpers accept an injectableClockfrom@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_LIMITEDis a unique symbol; OTel counter increments on rejection; invalidmaxCalls/windowMsthrowsTIMING.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-levelTReturn(notTReturn | undefined) on the wrapper.src/throttle/times/times.test.ts— runsntimes, returnsundefinedthereafter, counter behaviour.src/throttle/times/times.spec-conformance.test.ts— counter increments per invocation up tomaxCalls.
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.jsonreturning onlypackages/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.