diff --git a/packages/gittensory-engine/src/governor/rate-limit.ts b/packages/gittensory-engine/src/governor/rate-limit.ts new file mode 100644 index 0000000000..3cff8f961d --- /dev/null +++ b/packages/gittensory-engine/src/governor/rate-limit.ts @@ -0,0 +1,101 @@ +// Governor local rate-limit + jittered backoff (pure). +// Deterministic, side-effect-free bucket math for the local Governor. Given a rolling-window bucket and a +// clock reading it decides whether an event is allowed and, when blocked, how long to wait; and it computes a +// jittered exponential backoff from an INJECTED random source (never Math.random) so it stays fully unit- +// testable. This module computes numbers only — it does NOT store state, schedule, or gate any write action; +// that enforcement wiring is a separate, maintainer-owned concern. The vocabulary mirrors the server-side +// `RateLimitConfig`/`RateLimitDecision` in src/auth/rate-limit.ts (that one is a Cloudflare Durable Object and +// is not reusable in the fully-local miner), but this variant is millisecond-based and state-free. + +export type LocalRateLimitConfig = { + /** Maximum number of events permitted within one window. */ + limit: number; + /** Rolling window length in milliseconds. */ + windowMs: number; +}; + +export type LocalRateBucket = { + /** Events already counted in the current window. */ + count: number; + /** Start of the current window as a millisecond epoch. */ + windowStartMs: number; +}; + +export type LocalRateLimitDecision = { + /** Whether an event at `nowMs` is permitted under the bucket + config. */ + allowed: boolean; + /** The configured limit, echoed for callers that render the decision. */ + limit: number; + /** Events still permitted in the effective window AFTER this one (0 when blocked). */ + remaining: number; + /** When the effective window resets, as a millisecond epoch. */ + resetAtMs: number; + /** Milliseconds to wait before the next permitted attempt (0 when allowed). */ + retryAfterMs: number; +}; + +// Cap the backoff exponent so `2 ** attempt` cannot overflow into Infinity for a pathological attempt count; +// beyond this the delay is already saturated at its ceiling anyway. +const MAX_BACKOFF_EXPONENT = 30; + +// Normalize any numeric input to a non-negative integer (a non-finite or negative value becomes 0), so counts, +// limits, and window lengths can never make a decision NaN, fractional, or negative. +function finiteNonNegativeInt(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +/** + * Decide whether an event at `nowMs` is allowed for a rolling-window bucket. Pure: it reads the bucket and + * clock and returns a decision without mutating anything. A window that has fully elapsed is treated as reset, + * so a stale bucket at its limit is permitted again in the new window. Every numeric input is normalized first, + * so a non-finite, fractional, or negative count/limit/window can never produce a NaN or negative decision. + */ +export function evaluateLocalRateLimit( + bucket: LocalRateBucket, + config: LocalRateLimitConfig, + nowMs: number, +): LocalRateLimitDecision { + const limit = finiteNonNegativeInt(config.limit); + const windowMs = finiteNonNegativeInt(config.windowMs); + const count = finiteNonNegativeInt(bucket.count); + const windowStartMs = Number.isFinite(bucket.windowStartMs) ? bucket.windowStartMs : 0; + const now = Number.isFinite(nowMs) ? nowMs : 0; + + const windowElapsed = now - windowStartMs >= windowMs; + const effectiveCount = windowElapsed ? 0 : count; + const effectiveWindowStart = windowElapsed ? now : windowStartMs; + const resetAtMs = effectiveWindowStart + windowMs; + + const allowed = effectiveCount < limit; + const remaining = allowed ? limit - effectiveCount - 1 : 0; + const retryAfterMs = allowed ? 0 : Math.max(0, resetAtMs - now); + + return { allowed, limit, remaining, resetAtMs, retryAfterMs }; +} + +/** + * Compute a jittered exponential backoff in milliseconds for a retry `attempt` (0-based). The exponential base + * is `baseMs * 2 ** attempt` (attempt clamped to a non-negative, bounded range), scaled by a multiplicative + * jitter factor drawn from `randomFn` (expected to return a value in [0, 1), like `Math.random`, but injected + * so tests stay deterministic): the factor lands in [0.5, 1.5), and the final delay is that product rounded to + * the nearest integer (so at the top of the band a delay may round up to the `1.5 * base` value). The result is + * always a non-negative integer. + */ +export function jitteredBackoffMs(baseMs: number, attempt: number, randomFn: () => number): number { + // Normalize non-finite inputs so the result can never be NaN or Infinity — it is always a non-negative integer, + // matching this function's documented contract. A non-finite base is treated as 0; a NaN attempt means no + // backoff growth, while a huge or Infinity attempt saturates at the capped exponent. + const safeBase = Number.isFinite(baseMs) ? Math.max(0, baseMs) : 0; + const exponent = Number.isNaN(attempt) ? 0 : Math.min(MAX_BACKOFF_EXPONENT, Math.max(0, Math.floor(attempt))); + const exponential = safeBase * 2 ** exponent; + // Clamp the random draw into [0, 1) so an out-of-contract source cannot push the factor outside [0.5, 1.5). + // A non-finite draw (e.g. NaN) is treated as 0 so the delay never becomes NaN. + const rawDraw = randomFn(); + const draw = Number.isFinite(rawDraw) ? Math.min(0.999999, Math.max(0, rawDraw)) : 0; + const jitterFactor = 0.5 + draw; + // Round to an integer AND guard finiteness on the return itself: a fractional base yields a rounded integer, + // and an extreme (but finite) base that overflows the multiplication to Infinity falls back to a finite max — + // so the result is always a non-negative integer for any input, per the documented contract. + const rawDelay = exponential * jitterFactor; + return Number.isFinite(rawDelay) ? Math.max(0, Math.round(rawDelay)) : Number.MAX_SAFE_INTEGER; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index c6458fbc46..6d380e9d34 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -9,3 +9,4 @@ export { rankOpportunities, type OpportunityRankInput, } from "./opportunity-ranker.js"; +export * from "./governor/rate-limit.js"; diff --git a/test/unit/governor-rate-limit.test.ts b/test/unit/governor-rate-limit.test.ts new file mode 100644 index 0000000000..4711c457d5 --- /dev/null +++ b/test/unit/governor-rate-limit.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { evaluateLocalRateLimit, jitteredBackoffMs, type LocalRateBucket, type LocalRateLimitConfig } from "../../packages/gittensory-engine/src/governor/rate-limit"; + +const config: LocalRateLimitConfig = { limit: 10, windowMs: 60_000 }; + +describe("evaluateLocalRateLimit", () => { + it("permits an event when the bucket is under the limit", () => { + const bucket: LocalRateBucket = { count: 3, windowStartMs: 1_000 }; + const d = evaluateLocalRateLimit(bucket, config, 5_000); + expect(d.allowed).toBe(true); + expect(d.remaining).toBe(6); // 10 - 3 - 1 + expect(d.retryAfterMs).toBe(0); + expect(d.resetAtMs).toBe(61_000); // windowStart 1000 + 60000 + expect(d.limit).toBe(10); + }); + + it("blocks and reports retry timing when the bucket is at the limit", () => { + const bucket: LocalRateBucket = { count: 10, windowStartMs: 1_000 }; + const d = evaluateLocalRateLimit(bucket, config, 5_000); + expect(d.allowed).toBe(false); + expect(d.remaining).toBe(0); + expect(d.resetAtMs).toBe(61_000); + expect(d.retryAfterMs).toBe(56_000); // 61000 - 5000 + }); + + it("treats a fully elapsed window as reset, permitting a previously maxed bucket", () => { + const bucket: LocalRateBucket = { count: 10, windowStartMs: 1_000 }; + const d = evaluateLocalRateLimit(bucket, config, 61_000); // exactly one window later → elapsed (>=) + expect(d.allowed).toBe(true); + expect(d.remaining).toBe(9); // fresh window: 10 - 0 - 1 + expect(d.resetAtMs).toBe(121_000); // new window starts at now + expect(d.retryAfterMs).toBe(0); + }); + + it("permits within a window that has not yet elapsed", () => { + const bucket: LocalRateBucket = { count: 10, windowStartMs: 1_000 }; + const d = evaluateLocalRateLimit(bucket, config, 60_999); // 1ms before elapse → still blocked window + expect(d.allowed).toBe(false); + expect(d.retryAfterMs).toBe(1); // 61000 - 60999 + }); + + it("clamps a negative stored count to zero", () => { + const bucket: LocalRateBucket = { count: -5, windowStartMs: 1_000 }; + const d = evaluateLocalRateLimit(bucket, config, 5_000); + expect(d.allowed).toBe(true); + expect(d.remaining).toBe(9); // treated as count 0 + }); + + it("blocks when the stored count exceeds the limit", () => { + const bucket: LocalRateBucket = { count: 15, windowStartMs: 1_000 }; + const d = evaluateLocalRateLimit(bucket, config, 5_000); + expect(d.allowed).toBe(false); + expect(d.remaining).toBe(0); + }); + + it("floors a fractional stored count instead of leaking a fractional remaining", () => { + const d = evaluateLocalRateLimit({ count: 3.9, windowStartMs: 1_000 }, config, 5_000); + expect(d.remaining).toBe(6); // 3.9 floored to 3 → 10 - 3 - 1 + expect(Number.isInteger(d.remaining)).toBe(true); + }); + + it("normalizes non-finite numeric inputs so the decision is never NaN or negative", () => { + const d = evaluateLocalRateLimit({ count: NaN, windowStartMs: NaN }, { limit: NaN, windowMs: NaN }, NaN); + for (const value of [d.limit, d.remaining, d.resetAtMs, d.retryAfterMs]) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + } + expect(typeof d.allowed).toBe("boolean"); + }); + + it("clamps a non-positive window to a defined always-reset decision", () => { + // windowMs -10 clamps to 0 → the window is always elapsed → a fresh window opens at now, permitting the event. + const d = evaluateLocalRateLimit({ count: 0, windowStartMs: 1_000 }, { limit: 5, windowMs: -10 }, 5_000); + expect(d).toEqual({ allowed: true, limit: 5, remaining: 4, resetAtMs: 5_000, retryAfterMs: 0 }); + }); +}); + +describe("jitteredBackoffMs", () => { + it("returns the base delay at attempt 0 with a mid jitter draw", () => { + expect(jitteredBackoffMs(100, 0, () => 0.5)).toBe(100); // 100 * 2^0 * (0.5 + 0.5) + }); + + it("grows exponentially with the attempt count", () => { + expect(jitteredBackoffMs(100, 3, () => 0.5)).toBe(800); // 100 * 2^3 * 1.0 + }); + + it("keeps the jitter factor within the [0.5, 1.5) band across a swept attempt range", () => { + for (let attempt = 0; attempt <= 12; attempt++) { + const exp = 100 * 2 ** attempt; + const low = jitteredBackoffMs(100, attempt, () => 0); + const high = jitteredBackoffMs(100, attempt, () => 0.999999); + expect(low).toBe(Math.round(exp * 0.5)); + expect(high).toBeGreaterThanOrEqual(Math.round(exp * 0.5)); + expect(high).toBeLessThan(Math.round(exp * 1.5) + 1); + } + }); + + it("clamps a negative attempt to zero", () => { + expect(jitteredBackoffMs(100, -4, () => 0.5)).toBe(100); + }); + + it("floors a fractional attempt", () => { + expect(jitteredBackoffMs(100, 2.9, () => 0.5)).toBe(400); // floor(2.9) = 2 → 2^2 + }); + + it("caps the exponent so a pathological attempt cannot overflow to Infinity", () => { + const capped = jitteredBackoffMs(1, 1000, () => 0.5); + expect(Number.isFinite(capped)).toBe(true); + expect(capped).toBe(Math.round(2 ** 30)); // 1 * 2^30 * 1.0 + }); + + it("treats a negative base as zero", () => { + expect(jitteredBackoffMs(-100, 5, () => 0.9)).toBe(0); + }); + + it("clamps an out-of-contract random draw into the band", () => { + expect(jitteredBackoffMs(100, 0, () => 5)).toBe(Math.round(100 * (0.5 + 0.999999))); // draw clamped to 0.999999 + expect(jitteredBackoffMs(100, 0, () => -3)).toBe(50); // draw clamped to 0 → factor 0.5 + }); + + it("treats a non-finite random draw as zero so the delay never becomes NaN", () => { + expect(jitteredBackoffMs(100, 0, () => NaN)).toBe(50); // NaN draw → 0 → factor 0.5 + expect(jitteredBackoffMs(100, 2, () => Number.POSITIVE_INFINITY)).toBe(200); // Infinity draw → 0 → 400 * 0.5 + }); + + it("treats a non-finite base or attempt as zero so the result stays a non-negative integer", () => { + expect(jitteredBackoffMs(NaN, 0, () => 0.5)).toBe(0); // base NaN → 0 + expect(jitteredBackoffMs(Number.POSITIVE_INFINITY, 0, () => 0.5)).toBe(0); // base Infinity → 0 + expect(jitteredBackoffMs(100, NaN, () => 0.5)).toBe(100); // attempt NaN → exponent 0 → 100 + expect(jitteredBackoffMs(100, Number.POSITIVE_INFINITY, () => 0.5)).toBe(Math.round(2 ** 30 * 100)); // attempt ∞ → capped exponent + }); + + it("rounds a fractional base to an integer delay", () => { + expect(jitteredBackoffMs(0.1, 0, () => 0.5)).toBe(0); // round(0.1 * 1) = 0 + expect(jitteredBackoffMs(1.9, 0, () => 0.5)).toBe(2); // round(1.9 * 1) = 2 + expect(Number.isInteger(jitteredBackoffMs(2.5, 3, () => 0.7))).toBe(true); + }); + + it("clamps an overflowing result to a finite integer instead of returning Infinity", () => { + const result = jitteredBackoffMs(Number.MAX_VALUE, 30, () => 0.5); // MAX_VALUE * 2^30 overflows to Infinity + expect(Number.isFinite(result)).toBe(true); + expect(result).toBe(Number.MAX_SAFE_INTEGER); + }); +});