Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0b993bf
feat(rate-limit/unstable): add rate limiting module
tomas-zijdemans Mar 23, 2026
23d80de
fix example
tomas-zijdemans Mar 23, 2026
67e6447
fix examples
tomas-zijdemans Mar 23, 2026
c048174
Merge remote-tracking branch 'upstream/main' into rate-limit
tomas-zijdemans Mar 25, 2026
4af2841
Clarify behaviour
tomas-zijdemans Mar 25, 2026
a3c0d7c
Merge branch 'main' into rate-limit
tomas-zijdemans Mar 26, 2026
4f32f3b
feat(cache/unstable): add sliding expiration to `TtlCache` (#7046)
tomas-zijdemans Mar 27, 2026
3c62b72
implement store concept
tomas-zijdemans Mar 30, 2026
2906045
implement store concept
tomas-zijdemans Mar 30, 2026
0beabeb
feat(cache/unstable): add `peek()` to `TtlCache` (#7070)
tomas-zijdemans Mar 30, 2026
7dd20c4
Merge remote-tracking branch 'upstream/main' into rate-limit
tomas-zijdemans Mar 30, 2026
26556aa
Add Redis Store
tomas-zijdemans Mar 30, 2026
4e8a043
Add Redis Store
tomas-zijdemans Mar 30, 2026
a790dcf
refactor
tomas-zijdemans Mar 30, 2026
b3f1c34
fix leak
tomas-zijdemans Mar 31, 2026
02f92b8
use encodeHex
tomas-zijdemans Mar 31, 2026
3472877
fix browser compatible comments
tomas-zijdemans Mar 31, 2026
53d22fb
fix browser compat, take 2
tomas-zijdemans Mar 31, 2026
d96c02a
Merge branch 'main' into rate-limit
tomas-zijdemans Apr 9, 2026
d7c198f
remove replenish from AlgorithmOps
tomas-zijdemans Apr 19, 2026
5877449
coverage
tomas-zijdemans Apr 19, 2026
7785bbd
Merge branch 'main' into rate-limit
tomas-zijdemans Apr 19, 2026
3c9bb67
Merge upstream/main into rate-limit
tomas-zijdemans Apr 22, 2026
1458fa7
Merge branch 'main' into rate-limit
tomas-zijdemans Apr 22, 2026
da8de54
Merge remote-tracking branch 'refs/remotes/origin/rate-limit' into ra…
tomas-zijdemans Apr 22, 2026
ac7f983
stable deque
tomas-zijdemans Apr 22, 2026
29a89a0
Merge branch 'main' into rate-limit
tomas-zijdemans Apr 22, 2026
09c7593
Merge branch 'main' into rate-limit
tomas-zijdemans Apr 26, 2026
ef21f77
Merge branch 'main' into rate-limit
tomas-zijdemans May 6, 2026
2e33362
Merge branch 'main' into rate-limit
tomas-zijdemans May 26, 2026
6146ef7
Merge branch 'main' into rate-limit
tomas-zijdemans Jun 7, 2026
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
3 changes: 3 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ path:
random:
- changed-files:
- any-glob-to-any-file: random/**
rate-limit:
- changed-files:
- any-glob-to-any-file: rate_limit/**
regexp:
- changed-files:
- any-glob-to-any-file: regexp/**
Expand Down
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"./net",
"./path",
"./random",
"./rate_limit",
"./regexp",
"./semver",
"./streams",
Expand Down
1 change: 1 addition & 0 deletions import_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@std/path": "jsr:@std/path@^1.1.5",
"@std/regexp": "jsr:@std/regexp@^1.0.2",
"@std/random": "jsr:@std/random@^0.1.5",
"@std/rate-limit": "jsr:@std/rate-limit@^0.1.0",
"@std/semver": "jsr:@std/semver@^1.0.8",
"@std/streams": "jsr:@std/streams@^1.1.1",
"@std/tar": "jsr:@std/tar@^0.1.10",
Expand Down
312 changes: 312 additions & 0 deletions rate_limit/_algorithms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.

import { RollingCounter } from "@std/data-structures/unstable-rolling-counter";
import { assertPositiveFinite, assertPositiveInteger } from "./_validation.ts";

/**
* Result returned by algorithm operations. All fields are always present
* regardless of whether the request was allowed.
*
* **Metadata semantics vary by algorithm:**
*
* - `retryAfter` is the *minimum* delay before capacity *may* free up. For
* sliding-window this is the time until the next segment rotation, which may
* not free enough permits for a high-cost request. For token-bucket and GCRA
* the value accounts for the requested cost.
* - `resetAt` is the timestamp of the next replenishment event (segment
* rotation, window boundary, or refill cycle). For sliding-window and
* token-bucket this is *not* necessarily when full capacity is restored.
*/
export interface AlgorithmResult {
readonly ok: boolean;
readonly remaining: number;
readonly resetAt: number;
readonly retryAfter: number;
readonly limit: number;
}

/**
* Pure state machine for a rate limit algorithm. No Map, no timers, no keys.
* Used by both the keyed layer (Map + eviction) and the primitives (queue +
* timer).
*/
export interface AlgorithmOps<S> {
/** Create initial state for a new key or new instance. */
create(now: number): S;
/** Advance time (rotate segments, refill tokens, reset window). Mutates state. */
advance(state: S, now: number): void;
/** Try to consume `cost` permits. Returns true and mutates state if allowed. */
tryConsume(state: S, cost: number, now: number): boolean;
/** Return whether a request of `cost` would be allowed without mutating state. */
wouldAllow(state: S, cost: number, now: number): boolean;
/** Compute result metadata (remaining, resetAt, retryAfter). */
result(state: S, ok: boolean, cost: number, now: number): AlgorithmResult;
/** Compute the retry delay for a denied request without allocating a result object. */
computeRetryAfter(state: S, cost: number, now: number): number;
/** The configured permit limit. */
readonly limit: number;
}

// --- Fixed Window ---

/** State for the fixed-window algorithm: count in current window and window start time. */
export interface FixedWindowState {
count: number;
windowStart: number;
}

/**
* Creates ops for the fixed-window algorithm. Callers must pass valid parameters.
*
* @param limit Maximum permits per window. Must be a positive integer.
* @param window Window duration in milliseconds. Must be a positive finite number.
* @returns Algorithm ops for fixed-window rate limiting.
*/
export function createFixedWindowOps(
limit: number,
window: number,
): AlgorithmOps<FixedWindowState> {
const context = "fixed window";
assertPositiveInteger(context, "limit", limit);
assertPositiveFinite(context, "window", window);
return {
limit,
create(now) {
return { count: 0, windowStart: now };
},
advance(state, now) {
if (now - state.windowStart >= window) {
state.count = 0;
state.windowStart = state.windowStart +
Math.floor((now - state.windowStart) / window) * window;
}
},
tryConsume(state, cost, _now) {
if (state.count + cost > limit) return false;
state.count += cost;
return true;
},
wouldAllow(state, cost, _now) {
return state.count + cost <= limit;
},
result(state, ok, cost, now) {
return {
ok,
remaining: Math.max(0, limit - state.count),
resetAt: state.windowStart + window,
retryAfter: ok ? 0 : this.computeRetryAfter(state, cost, now),
limit,
};
},
computeRetryAfter(state, _cost, now) {
return state.windowStart + window - now;
},
};
}

// --- Sliding Window ---

/** State for the sliding-window algorithm: segment counter and current segment start time. */
export interface SlidingWindowState {
counter: RollingCounter;
segmentStart: number;
}

/**
* Creates ops for the sliding-window algorithm. Callers must pass valid parameters.
*
* @param limit Maximum permits per window. Must be a positive integer.
* @param window Window duration in milliseconds. Must be a positive finite number.
* @param segmentsPerWindow Number of segments. Must be an integer >= 2.
* @returns Algorithm ops for sliding-window rate limiting.
*/
export function createSlidingWindowOps(
limit: number,
window: number,
segmentsPerWindow: number,
): AlgorithmOps<SlidingWindowState> {
const context = "sliding window";
assertPositiveInteger(context, "limit", limit);
assertPositiveFinite(context, "window", window);
if (!Number.isInteger(segmentsPerWindow) || segmentsPerWindow < 2) {
throw new RangeError(
`Cannot create ${context}: 'segmentsPerWindow' must be an integer >= 2, received ${segmentsPerWindow}`,
);
}
if (window % segmentsPerWindow !== 0) {
throw new RangeError(
`Cannot create ${context}: 'window' (${window}) must be evenly divisible by 'segmentsPerWindow' (${segmentsPerWindow})`,
);
}
const segmentDuration = window / segmentsPerWindow;

return {
limit,
create(now) {
return {
counter: new RollingCounter(segmentsPerWindow),
segmentStart: now,
};
},
advance(state, now) {
const elapsed = now - state.segmentStart;
if (elapsed >= segmentDuration) {
const rotations = Math.floor(elapsed / segmentDuration);
state.counter.rotate(rotations);
state.segmentStart += rotations * segmentDuration;
}
},
tryConsume(state, cost, _now) {
if (state.counter.total + cost > limit) return false;
state.counter.increment(cost);
return true;
},
wouldAllow(state, cost, _now) {
return state.counter.total + cost <= limit;
},
result(state, ok, cost, now) {
return {
ok,
remaining: Math.max(0, limit - state.counter.total),
resetAt: state.segmentStart + segmentDuration,
retryAfter: ok ? 0 : this.computeRetryAfter(state, cost, now),
limit,
};
},
computeRetryAfter(state, _cost, now) {
return state.segmentStart + segmentDuration - now;
},
};
}

// --- Token Bucket ---

/** State for the token-bucket algorithm: current tokens and last refill time. */
export interface TokenBucketState {
tokens: number;
lastRefill: number;
}

/**
* Creates ops for the token-bucket algorithm. Callers must pass valid parameters.
*
* @param limit Maximum tokens (bucket capacity). Must be a positive integer.
* @param window Refill cycle duration in milliseconds. Must be a positive finite number.
* @param tokensPerPeriod Tokens added per replenishment period. Must be a positive integer.
* @returns Algorithm ops for token-bucket rate limiting.
*/
export function createTokenBucketOps(
limit: number,
window: number,
tokensPerPeriod: number,
): AlgorithmOps<TokenBucketState> {
const context = "token bucket";
assertPositiveInteger(context, "limit", limit);
assertPositiveFinite(context, "window", window);
assertPositiveInteger(context, "tokensPerPeriod", tokensPerPeriod);
return {
limit,
create(now) {
return { tokens: limit, lastRefill: now };
},
advance(state, now) {
const elapsed = now - state.lastRefill;
if (elapsed >= window) {
const cycles = Math.floor(elapsed / window);
state.tokens = Math.min(limit, state.tokens + cycles * tokensPerPeriod);
state.lastRefill += cycles * window;
}
},
tryConsume(state, cost, _now) {
if (state.tokens < cost) return false;
state.tokens -= cost;
return true;
},
wouldAllow(state, cost, _now) {
return state.tokens >= cost;
},
result(state, ok, cost, now) {
const remaining = Math.max(0, Math.floor(state.tokens));
return {
ok,
remaining,
resetAt: state.lastRefill + window,
retryAfter: ok ? 0 : this.computeRetryAfter(state, cost, now),
limit,
};
},
computeRetryAfter(state, cost, now) {
const deficit = cost - state.tokens;
const cycles = Math.ceil(deficit / tokensPerPeriod);
return Math.max(0, cycles * window - (now - state.lastRefill));
},
};
}

// --- GCRA (Generic Cell Rate Algorithm) ---

/** State for GCRA: theoretical arrival time (tat) of the last request. */
export interface GcraState {
tat: number;
}

/**
* Creates ops for the GCRA (Generic Cell Rate Algorithm). Callers must pass valid parameters.
*
* @param limit Maximum permits per window. Must be a positive integer.
* @param window Window (tau) in milliseconds. Must be a positive finite number.
* @returns Algorithm ops for GCRA rate limiting.
*/
export function createGcraOps(
limit: number,
window: number,
): AlgorithmOps<GcraState> {
const context = "gcra";
assertPositiveInteger(context, "limit", limit);
assertPositiveFinite(context, "window", window);
const emissionInterval = window / limit;
const tau = window;

function remaining(state: GcraState, now: number): number {
const diff = tau - (state.tat - now);
return Math.min(limit, Math.max(0, Math.floor(diff / emissionInterval)));
}

return {
limit,
create(now) {
return { tat: now };
},
advance(_state, _now) {},
tryConsume(state: GcraState, cost: number, now: number) {
const allowAt = state.tat - tau;
if (now < allowAt) return false;
const newTat = Math.max(state.tat, now) + emissionInterval * cost;
if (newTat - now > tau) return false;
state.tat = newTat;
return true;
},
wouldAllow(state: GcraState, cost: number, now: number) {
const allowAt = state.tat - tau;
if (now < allowAt) return false;
const newTat = Math.max(state.tat, now) + emissionInterval * cost;
return newTat - now <= tau;
},
result(state, ok, cost, now) {
return {
ok,
remaining: remaining(state, now),
resetAt: state.tat,
retryAfter: ok ? 0 : this.computeRetryAfter(state, cost, now),
limit,
};
},
computeRetryAfter(state, cost, now) {
const allowAt = state.tat - tau;
if (now < allowAt) return allowAt - now;
const newTat = Math.max(state.tat, now) + emissionInterval * cost;
return Math.max(0, newTat - tau - now);
},
};
}
Loading
Loading