diff --git a/packages/rate-limiter/CHANGELOG.md b/packages/rate-limiter/CHANGELOG.md index 2117778b..851f4763 100644 --- a/packages/rate-limiter/CHANGELOG.md +++ b/packages/rate-limiter/CHANGELOG.md @@ -10,4 +10,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Added support for the `fixed-window` rate-limiter algorithm via the `createFixedWindow` function. The function can be used standalone or integrated with the centralized `createRateLimiter` function. [#185](https://github.com/aura-stack-ts/auth/pull/185) + - Introduced `createRateLimiter` function to create a rate limiter. Currently, the only supported algorithm is `token-bucket`, implemented by the `createTokenBucket` function. [#131](https://github.com/aura-stack-ts/auth/pull/131) diff --git a/packages/rate-limiter/src/algorithms/fixed-window.ts b/packages/rate-limiter/src/algorithms/fixed-window.ts new file mode 100644 index 00000000..63f40cbd --- /dev/null +++ b/packages/rate-limiter/src/algorithms/fixed-window.ts @@ -0,0 +1,64 @@ +import { toContent } from "@/utils.ts" +import { createMemoryStorage } from "@/memory.ts" +import type { FixedWindowRule, RateLimiterAlgorithm, RateLimitResult } from "@/types.ts" + +/** + * Fixed Window Counter + * + * Divides time into discrete, non-overlapping windows of `windowMs` length. + * A counter is incremented for each request within the current window; once + * it hits `limit` the request is rejected until the window flips. + * + * Trade-offs vs sliding window: + * - Cheaper: one storage key per identifier, one atomic increment per request. + * - Allows a burst of up to 2× `limit` at a window boundary (last moment of + * the old window + first moment of the new one). Use sliding-window when + * that burst is unacceptable (e.g. auth endpoints). + * + * Recommended for: coarse-grained public API quotas where a boundary burst + * is acceptable, or anywhere you want the simplest possible semantics. + */ +export const createFixedWindowAlgorithm = ( + rule: FixedWindowRule +): RateLimiterAlgorithm => { + const { limit, windowMs, storage = createMemoryStorage() } = rule + + const boundary = (now: number) => Math.floor(now / windowMs) * windowMs + const windowKey = (baseKey: string, now: number) => `${baseKey}:fw:${boundary(now)}` + const resetAt = (now: number) => boundary(now) + windowMs + + const check = async (request: RequestInit): Promise => { + const now = Date.now() + const reset = resetAt(now) + const key = rule.keyGenerator(request) + const count = await storage.increment(windowKey(key, now), windowMs) + const ok = count <= limit + + return toContent({ + ok, + limit, + remaining: Math.max(0, limit - count), + resetAt: reset, + retryAfter: ok ? 0 : reset - now, + }) + } + + const peek = async (request: RequestInit): Promise => { + const now = Date.now() + const reset = resetAt(now) + const key = rule.keyGenerator(request) + const entry = await storage.get(windowKey(key, now)) + const count = entry?.value ?? 0 + const ok = count < limit + + return toContent({ + ok, + limit, + remaining: Math.max(0, limit - count), + resetAt: reset, + retryAfter: ok ? 0 : reset - now, + }) + } + + return { check, peek } +} diff --git a/packages/rate-limiter/src/algorithms/index.ts b/packages/rate-limiter/src/algorithms/index.ts index 32fd5415..b72ab9b2 100644 --- a/packages/rate-limiter/src/algorithms/index.ts +++ b/packages/rate-limiter/src/algorithms/index.ts @@ -1 +1,2 @@ export { createTokenBucketAlgorithm } from "@/algorithms/token-bucket.ts" +export { createFixedWindowAlgorithm } from "@/algorithms/fixed-window.ts" diff --git a/packages/rate-limiter/src/types.ts b/packages/rate-limiter/src/types.ts index 21ed5a51..48bbb254 100644 --- a/packages/rate-limiter/src/types.ts +++ b/packages/rate-limiter/src/types.ts @@ -77,10 +77,25 @@ export type TokenBucketRule = BaseRule & { capacity: number /** Tokens added per millisecond. */ refillRate: number + /** + * Optional storage instance specific to this rule. + */ storage?: RateLimiterStorage } -export type RateLimiterRule = TokenBucketRule +export type FixedWindowRule = { + algorithm: "fixed-window" + /** Maximum requests allowed per window. */ + limit: number + /** Window duration in milliseconds. Hard resets at each boundary. */ + windowMs: number + /** + * Optional storage instance specific to this rule. + */ + storage?: RateLimiterStorage +} & Omit, "algorithm"> + +export type RateLimiterRule = TokenBucketRule | FixedWindowRule export interface RateLimiterConfig> { storage?: RateLimiterStorage diff --git a/packages/rate-limiter/test/algorithms/fixed-window.test.ts b/packages/rate-limiter/test/algorithms/fixed-window.test.ts new file mode 100644 index 00000000..4e24c3a6 --- /dev/null +++ b/packages/rate-limiter/test/algorithms/fixed-window.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { createMemoryStorage } from "@/memory.ts" +import { createFixedWindowAlgorithm } from "@/algorithms/index.ts" + +interface TestRequest { + key: string +} + +const request = (key: string): TestRequest => ({ key }) + +const createAlgorithm = (limit = 2, windowMs = 1000) => { + return createFixedWindowAlgorithm({ + algorithm: "fixed-window", + limit, + windowMs, + storage: createMemoryStorage(), + keyGenerator: (req) => req.key, + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe("FixedWindowAlgorithm", () => { + test("allows requests up to limit and then blocks until window resets", async () => { + const algorithm = createAlgorithm(2, 1000) + const key = request("ip:1") + + const first = await algorithm.check(key) + const second = await algorithm.check(key) + const third = await algorithm.check(key) + + expect(first.ok).toBe(true) + expect(first.remaining).toBe(1) + + expect(second.ok).toBe(true) + expect(second.remaining).toBe(0) + + expect(third.ok).toBe(false) + expect(third.remaining).toBe(0) + expect(third.retryAfter).toBe(1000) + }) + + test("restores the allowance when the next window begins", async () => { + const algorithm = createAlgorithm(2, 1000) + const key = request("ip:2") + + await algorithm.check(key) + await algorithm.check(key) + + vi.advanceTimersByTime(1000) + + const afterReset = await algorithm.check(key) + + expect(afterReset.ok).toBe(true) + expect(afterReset.remaining).toBe(1) + expect(afterReset.resetAt).toBe(2000) + expect(afterReset.retryAfter).toBe(0) + }) + + test("peek reports the current window without consuming a request", async () => { + const algorithm = createAlgorithm(3, 1000) + const key = request("ip:3") + + const consumed = await algorithm.check(key) + const preview = await algorithm.peek(key) + const next = await algorithm.check(key) + + expect(consumed.ok).toBe(true) + expect(consumed.remaining).toBe(2) + + expect(preview.ok).toBe(true) + expect(preview.remaining).toBe(2) + expect(preview.resetAt).toBe(1000) + expect(preview.retryAfter).toBe(0) + + expect(next.ok).toBe(true) + expect(next.remaining).toBe(1) + }) + + test("keeps counters isolated per key", async () => { + const algorithm = createAlgorithm(1, 1000) + const keyA = request("ip:A") + const keyB = request("ip:B") + + const firstA = await algorithm.check(keyA) + const secondA = await algorithm.check(keyA) + const firstB = await algorithm.check(keyB) + + expect(firstA.ok).toBe(true) + expect(secondA.ok).toBe(false) + expect(firstB.ok).toBe(true) + }) + + test("applies the next window boundary at exact rollover time", async () => { + const algorithm = createAlgorithm(1, 1000) + const key = request("ip:4") + + const first = await algorithm.check(key) + + vi.advanceTimersByTime(1000) + + const second = await algorithm.check(key) + + expect(first.ok).toBe(true) + expect(first.resetAt).toBe(1000) + + expect(second.ok).toBe(true) + expect(second.remaining).toBe(0) + expect(second.resetAt).toBe(2000) + }) +}) diff --git a/packages/rate-limiter/test/index.test.ts b/packages/rate-limiter/test/algorithms/token-bucket.test.ts similarity index 100% rename from packages/rate-limiter/test/index.test.ts rename to packages/rate-limiter/test/algorithms/token-bucket.test.ts