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
2 changes: 2 additions & 0 deletions packages/rate-limiter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
64 changes: 64 additions & 0 deletions packages/rate-limiter/src/algorithms/fixed-window.ts
Original file line number Diff line number Diff line change
@@ -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 = <RequestInit = Request>(
rule: FixedWindowRule<RequestInit>
): RateLimiterAlgorithm<RequestInit> => {
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<RateLimitResult> => {
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<RateLimitResult> => {
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 }
}
1 change: 1 addition & 0 deletions packages/rate-limiter/src/algorithms/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { createTokenBucketAlgorithm } from "@/algorithms/token-bucket.ts"
export { createFixedWindowAlgorithm } from "@/algorithms/fixed-window.ts"
17 changes: 16 additions & 1 deletion packages/rate-limiter/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,25 @@ export type TokenBucketRule<RequestInit = Request> = BaseRule<RequestInit> & {
capacity: number
/** Tokens added per millisecond. */
refillRate: number
/**
* Optional storage instance specific to this rule.
*/
storage?: RateLimiterStorage
}

export type RateLimiterRule<RequestInit = Request> = TokenBucketRule<RequestInit>
export type FixedWindowRule<RequestInit = Request> = {
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<BaseRule<RequestInit>, "algorithm">

export type RateLimiterRule<RequestInit = Request> = TokenBucketRule<RequestInit> | FixedWindowRule<RequestInit>

export interface RateLimiterConfig<Rules extends Record<string, RateLimiterRule>> {
storage?: RateLimiterStorage
Expand Down
118 changes: 118 additions & 0 deletions packages/rate-limiter/test/algorithms/fixed-window.test.ts
Original file line number Diff line number Diff line change
@@ -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<TestRequest>({
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)
})
})
Loading