Skip to content

FRD: Rate-Limiting Library

Document Summary

FieldDetails
Feature NameRate-Limiting Library
StatusDraft
OwnerDavid Holmes
ContributorsEngineering
Target Releasev2.0.0 (P2)
Related LinksRoadmap item #75, ADR-027 (Default Tech Stack), ADR-014 (Open Source First)
Last Updated2026-06-02
Open Source Librariesioredis
Documentationioredis Docs · Redis Rate Limiting Patterns · Redis Commands (INCR, EXPIRE)

Introduction

Overview

Per ADR-014 (Open Source First), this package designates ioredis as the platform standard for Redis-backed sliding-window rate limiting and ships @dmwd-io/rate-limit as a thin re-export that makes the choice official and documents platform conventions (env var names, defaults). The value delivered is standards and drift prevention — not a custom abstraction layered on top of the library. No custom provider interface is built; ioredis’s own API is the interface.

Goals

  • Designate ioredis as the platform standard for Redis-backed rate limiting per ADR-014.
  • Re-export ioredis through @dmwd-io/rate-limit so teams have a single, blessed import path.
  • Document platform env var conventions (REDIS_URL) so connection configuration is consistent across services.
  • Define a standard error response shape (429 Too Many Requests) with Retry-After metadata.
  • Document recommended usage patterns for sliding-window rate limiting with ioredis Lua scripts.

Non-Goals

  • Building a custom provider interface or adapter layer — the library’s own API is the interface (per ADR-014).
  • SQLite storage adapter (not needed when Valkey is the platform cache default per ADR-027 §4).
  • Building rate-limit dashboard or analytics UI.
  • Providing distributed rate-limiting coordination beyond what ioredis supports natively.
  • Rate-limiting at the CDN or reverse-proxy layer (Cloudflare, nginx).

Scope

In Scope

AreaDescription
Policy modelTyped RateLimitPolicy supporting fixed-window, sliding-window, and token-bucket
Rate limiterRateLimiter class with check(key, policy) and consume(key, policy) methods
Storage adapter interfaceRateLimitStore interface for pluggable storage backends
In-memory adapterMemoryRateLimitStore for dev and testing
Error responseStandard RateLimitError type with status code, retryAfter, and limit metadata
HTTP helpersUtility to set Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining headers
Middleware helperFramework-agnostic middleware factory for common HTTP frameworks
Unit testsFull coverage of all algorithms, store adapter, and error formatting
valkey-rate-limit-store.tsValkey/Redis storage adapter using ioredis; the default production store per ADR-027 §4
DocumentationStorybook MDX docs with usage examples

Out of Scope

AreaReason
Redis adapterShipped as a separate package
SQLite adapterShipped as a separate package
Distributed coordinationRequires consensus protocol; out of scope for a library
Admin UI / dashboardBackend concern
IP-based geo-blockingDifferent concern; not rate-limiting

Users and Pain Points

User Groups

UserDescriptionNeeds
Backend developersEngineers implementing API rate limitsA declarative policy model and consistent evaluation API
Platform engineersEngineers deploying and monitoring rate limitsStandard error responses and headers for observability
QA engineersTesters verifying rate-limit behaviorAn in-memory store that allows controlled testing of limit scenarios

Pain Points

UserPain PointImpact
Backend developersEach service implements rate-limiting differently with ad-hoc countersInconsistent behavior; some endpoints unprotected
Backend developersNo standard error response for rate-limited requestsClients cannot reliably detect or handle rate limits
Platform engineersRate-limit headers are missing or inconsistent across servicesMonitoring and alerting cannot be standardized
QA engineersTesting rate limits requires waiting for real time windowsTests are slow or skip rate-limit verification

Definitions

TermDefinition
Rate-limit policyA declarative configuration specifying the algorithm, window size, and maximum requests
Fixed windowCounts requests in discrete time windows (e.g., 100 requests per minute, resetting at minute boundaries)
Sliding windowCounts requests in a rolling window that moves with the current time
Token bucketAllows bursts up to a capacity, refilling tokens at a steady rate
Storage adapterAn object implementing RateLimitStore that persists counters (in-memory, Redis, etc.)
Retry-AfterHTTP header indicating how many seconds the client should wait before retrying

Current State

Existing Behavior

No shared rate-limiting library exists. Services implement rate limits using ad-hoc middleware or inline counter logic.

Current Limitations

  • No typed policy model; rate limits are configured with magic numbers in middleware.
  • No standard error response shape; some services return 429 with a plain text body, others return JSON.
  • No shared storage abstraction; some services use Redis directly, others use in-memory counters.
  • Rate-limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After) are inconsistently applied.

Existing Workarounds

  • Developers implement custom rate-limit middleware per service.
  • Tests manipulate time or use very high limits to avoid hitting rate limits during test execution.

Proposed Solution

Summary

@dmwd-io/rate-limit re-exports ioredis as the platform-standard Redis client for rate limiting. No custom interface is built — the library’s API is the interface. The package adds platform conventions on top: the REDIS_URL env var for connection configuration and documented patterns for PII-safe key construction.

import { Redis } from '@dmwd-io/rate-limit';
const client = new Redis(process.env.REDIS_URL);

Platform conventions documented by this package:

  • REDIS_URL — connection string for the Valkey/Redis instance (required in production).
  • Key construction pattern: rl:<service>:<userId> — no raw PII in keys.
  • Recommended Lua script pattern for atomic sliding-window increments.

No custom adapter or factory is shipped. Teams use ioredis directly via this re-export.

User Experience

Not applicable (library, no UI).

Developer Experience

Developers import from @dmwd-io/rate-limit, connect using REDIS_URL, and use ioredis directly for rate-limit counter operations. The package provides documented examples for sliding-window Lua scripts and platform key conventions rather than wrapping the library in a custom abstraction.


Requirements

IDRequirementPriorityNotes
FR-001The library must export a RateLimitPolicy typeMustSupports fixed-window, sliding-window, token-bucket
FR-002The library must export a RateLimiter classMustCore evaluation logic
FR-003The library must export a RateLimitStore interfaceMustPluggable storage
FR-004The library must ship a MemoryRateLimitStoreMustFor dev and testing
FR-005The library must export a RateLimitResult typeMustUniform result shape
FR-006The library must export HTTP header formatting utilitiesMustStandard headers
FR-007The library should export a middleware factoryShouldCommon use case

Priority Definitions

PriorityMeaning
MustRequired for this feature to ship.
ShouldImportant, but can be deferred if needed.
CouldNice to have. Not required for initial release.

Functional Requirements

IDRequirementUser BenefitPriority
FUNC-001check(key, policy) returns a RateLimitResult without consuming a requestCallers can preview rate-limit stateMust
FUNC-002consume(key, policy) decrements the counter and returns a RateLimitResultCallers enforce rate limits per requestMust
FUNC-003Fixed-window algorithm resets counters at window boundariesSimple, predictable rate limitingMust
FUNC-004Sliding-window algorithm uses a weighted average of current and previous windowSmoother rate limiting without hard resetsShould
FUNC-005Token-bucket algorithm refills tokens at a configured rate up to a capacitySupports controlled burstsShould
FUNC-006RateLimitResult includes allowed, remaining, resetAt, and retryAfterCallers have all info needed for response headersMust
FUNC-007formatRateLimitHeaders(result) returns { "Retry-After", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset" }Standard HTTP headersMust
FUNC-008MemoryRateLimitStore automatically expires stale entries to prevent memory leaksSafe for long-running processesMust
FUNC-009Middleware factory accepts a key-extraction function (request) => string and a policy mapFramework-agnostic middlewareShould

Non-Functional Requirements

IDRequirementCategoryPriority
NFR-001Zero runtime dependenciesMaintainabilityMust
NFR-002All public types exported from package entry pointMaintainabilityMust
NFR-003MemoryRateLimitStore must be safe for concurrent async operations (no race conditions in counter updates)CorrectnessMust
NFR-004check and consume must complete in O(1) time for fixed-window and token-bucketPerformanceMust
NFR-005Bundle size under 4 KB minified + gzippedPerformanceShould
NFR-006The library must not log request keys (may contain user identifiers) by defaultSecurityMust

API / Interface Requirements

Public API

NameTypeDescriptionRequired
RateLimitPolicytype{ algorithm: 'fixed-window' | 'sliding-window' | 'token-bucket'; windowMs: number; maxRequests: number; tokensPerInterval?: number }Yes
RateLimiterclassconstructor(store: RateLimitStore); methods: check, consumeYes
RateLimitStoreinterfaceget(key), increment(key, windowMs), reset(key)Yes
MemoryRateLimitStoreclassIn-memory implementation with TTL-based cleanupYes
RateLimitResulttype{ allowed: boolean; remaining: number; resetAt: number; retryAfter?: number; limit: number }Yes
formatRateLimitHeadersfunction(result: RateLimitResult) => Record&lt;string, string&gt;Yes
createRateLimitMiddlewarefunction(store, policies, keyExtractor) => MiddlewareFunctionNo

Example Usage

import { RateLimiter, MemoryRateLimitStore, formatRateLimitHeaders } from "@dmwd/rate-limit";
import type { RateLimitPolicy } from "@dmwd/rate-limit";
const store = new MemoryRateLimitStore();
const limiter = new RateLimiter(store);
const apiPolicy: RateLimitPolicy = {
algorithm: "fixed-window",
windowMs: 60_000,
maxRequests: 100,
};
// In a request handler
const key = `user:${userId}`;
const result = await limiter.consume(key, apiPolicy);
if (!result.allowed) {
const headers = formatRateLimitHeaders(result);
return new Response("Too Many Requests", { status: 429, headers });
}

API Notes

  • check is read-only; consume is the write operation that decrements the counter.
  • MemoryRateLimitStore runs a cleanup interval (configurable, default 60s) to evict expired entries.
  • The middleware factory returns a function with the signature (request, next) => Response to work with Astro, Hono, or Express-style handlers.
  • RateLimitStore methods are async to support network-backed stores (Redis, database).

Accessibility Requirements

IDRequirementNotes
A11Y-001Not directly applicable; headless libraryNo UI surface

Checklist

  • Keyboard support is defined. (N/A — no UI)
  • Focus behavior is defined. (N/A — no UI)
  • Screen reader behavior is defined. (N/A — no UI)
  • Color contrast requirements are met. (N/A — no UI)
  • Reduced motion behavior is considered. (N/A — no UI)
  • Semantic HTML expectations are documented. (N/A — no UI)
  • ARIA usage is defined only where needed. (N/A — no UI)

Content and Documentation Requirements

IDRequirementLocationPriority
DOC-001API reference with all exported types, classes, and functionsStorybook MDXMust
DOC-002Quick start guide with fixed-window exampleStorybook MDXMust
DOC-003Algorithm comparison guide (fixed-window vs. sliding-window vs. token-bucket)Storybook MDXMust
DOC-004Custom storage adapter guideStorybook MDXShould
DOC-005Middleware integration guide for Astro and HonoStorybook MDXShould
DOC-006Testing guide showing MemoryRateLimitStore with time manipulationStorybook MDXMust

Documentation Should Include

  • Overview and when to use each algorithm
  • Installation and import
  • Basic usage with in-memory store
  • Advanced usage: sliding-window, token-bucket, custom key extraction
  • HTTP header formatting
  • Middleware integration
  • Building a Redis or SQLite adapter
  • Testing patterns
  • Common mistakes (e.g., using in-memory store in multi-instance production)

Dependencies

DependencyTypeOwnerStatusNotes
TypeScript 5.xEngineeringDavid HolmesReadyBuild toolchain
VitestEngineeringDavid HolmesReadyTest runner
None (runtime)ReadyZero runtime dependencies
ADR-027ArchitectureEngineeringReadyDesignates Valkey as the platform cache/queue default (§4); ioredis is the recommended client

Risks and Tradeoffs

Risk / TradeoffImpactMitigation
In-memory store does not work in multi-instance deploymentsRate limits are per-instance, not globalDocument this clearly; ship Redis adapter separately
Three algorithm options increase implementation and testing surfaceMore code to maintainEach algorithm is independent; can defer sliding-window or token-bucket
Middleware factory may not fit all framework conventionsSome frameworks have different middleware signaturesFactory returns a generic function; provide framework-specific wrappers in docs
Automatic cleanup interval in MemoryRateLimitStoreInterval must be cleared on shutdown to avoid leaked timersProvide a dispose() method; document cleanup in long-running processes

Open Questions

IDQuestionOwnerStatusResolution
Q-001Should the RateLimitStore interface support atomic check-and-consume in a single call for Redis?David HolmesOpen
Q-002Should we support composite keys (e.g., user:${id}:endpoint:${path}) with a helper function?David HolmesOpen
Q-003Should the middleware factory support per-route policy overrides?David HolmesOpen

Acceptance Criteria

IDCriteriaRelated Requirement
AC-001RateLimitPolicy type supports fixed-window, sliding-window, and token-bucket algorithmsFR-001
AC-002RateLimiter.consume(key, policy) returns { allowed: false } after maxRequests calls within a windowFUNC-002
AC-003RateLimiter.check(key, policy) does not consume a requestFUNC-001
AC-004formatRateLimitHeaders(result) returns all four standard headersFUNC-007
AC-005MemoryRateLimitStore evicts expired entries on cleanup intervalFUNC-008
AC-006Fixed-window counter resets at window boundaryFUNC-003
AC-007All public types re-exported from package indexNFR-002
AC-008Unit tests pass covering all three algorithms and edge cases (window boundary, burst, empty store)FR-002
AC-009Storybook MDX docs render without errorsDOC-001

LLM Handoff Instructions

Expected LLM Behavior

  • Follow the requirements and acceptance criteria in this document.
  • Do not expand scope beyond the In Scope section.
  • Respect the Out of Scope section.
  • Ask for clarification only when a requirement cannot be safely interpreted.
  • Create the package under the packages/ directory if it does not already exist.
  • Create a package.json listing ioredis as a peer dependency.
  • Create src/index.ts with export * from 'ioredis' as the re-export.
  • Document the REDIS_URL env var and platform key conventions in the package README and Storybook MDX docs.
  • Run pnpm typecheck before declaring the task complete.

LLM Should Not

  • Build a custom RateLimitStore interface or adapter layer — ioredis’s API is the interface (ADR-014).
  • Add new dependencies beyond ioredis without justification.
  • Change unrelated components.
  • Log request keys or user identifiers.

Decision Log

DateDecisionReasonOwner
2026-05-26Support three algorithms (fixed-window, sliding-window, token-bucket)Covers common use cases; each is independently usefulDavid Holmes
2026-05-26Zero runtime dependenciesLibrary must be lightweight and deployable anywhereDavid Holmes
2026-05-26Async store interface even for in-memory adapterEnables network-backed stores without interface changesDavid Holmes
2026-05-26Separate check and consume methodsRead-only preview is useful for UI hints and monitoring without affecting countersDavid Holmes
2026-05-26Valkey designated as the production rate-limit storeADR-027 §4 designates Valkey (OSS Redis fork) as the platform cache default; ioredis is the recommended clientDavid Holmes
2026-06-02Reframed per ADR-014 (Open Source First): adopt ioredis as thin re-export rather than building a custom provider interface. Library API is the interface.ADR-014 prohibits custom abstraction layers over mature OSS librariesDavid Holmes

Document History

DateAuthorChange
2026-05-26David HolmesInitial draft
2026-06-02David HolmesReframed per ADR-014: ship as thin re-export of ioredis, drop custom interface.