Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions packages/gittensory-engine/src/governor/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {
rankOpportunities,
type OpportunityRankInput,
} from "./opportunity-ranker.js";
export * from "./governor/rate-limit.js";
144 changes: 144 additions & 0 deletions test/unit/governor-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading