Skip to content

Extract a pluggable, multi-instance-safe RateLimitStore abstraction #307

Description

@Lakes41

Difficulty: Expert
Type: refactor (architecture/scalability)

Background

lib/rate-limit.ts's own doc comment already flags its scaling limitation explicitly: "The bucket state lives in process memory... If you run more than one instance behind a load balancer, each instance keeps its own counters and the effective limit is multiplied by the instance count. For multi-instance or serverless (edge) deployments, replace the Map store with a shared backend (e.g. Redis via @upstash/ratelimit, or a DB-backed counter) — see docs/deployment.md 'Production rate-limiting' for the upgrade path." Today, getBucket()/take() operate directly on the module-level Map, with no seam for swapping in a different backing store.

Problem

The current design hard-codes the storage mechanism into the rate-limiting algorithm itself, so implementing the documented "upgrade path" to a shared backend today would require rewriting lib/rate-limit.ts's core logic rather than just plugging in a new adapter. This also makes the token-bucket algorithm itself hard to unit-test independently of its storage, and blocks any future multi-instance/edge deployment of this app without a disruptive rewrite at that time.

Expected Outcome

The token-bucket algorithm in lib/rate-limit.ts is decoupled from its storage via a small, explicit RateLimitStore interface. The default export continues to use an in-memory Map-based implementation (bundling in the eviction behavior from the related Advanced issue in this set, if it has landed — otherwise, that's fine to layer in independently) with zero behavior change for existing callers, but a second store implementation can be added later (e.g., Redis-backed) purely by implementing the interface, without touching rateLimitRequest()'s calling code.

Suggested Implementation

  1. Define a RateLimitStore interface in lib/rate-limit.ts (or a new lib/rate-limit/store.ts) with the minimal operations the algorithm needs — something like:
    interface RateLimitStore {
      getBucket(key: string): Promise<Bucket | undefined> // or sync, if keeping in-memory synchronous
      setBucket(key: string, bucket: Bucket): Promise<void>
    }
    Consider carefully whether to make the interface async from the start (a Redis-backed store necessarily is) even though the in-memory implementation doesn't need to be — doing so now avoids a second breaking interface change later when a real remote store is added, but changes today's callers (app/api/integration/*/route.ts) to await rateLimitRequest(...). Justify the chosen approach in the PR description.
  2. Extract today's Map-based logic into an InMemoryRateLimitStore implementing the interface, preserving its exact current refill/token-bucket math.
  3. Refactor getBucket()/take()/rateLimitRequest() to operate against an injected RateLimitStore instance (defaulting to InMemoryRateLimitStore), rather than the module-level Map directly.
  4. Keep rateLimitRequest(req, address)'s public signature and behavior backward-compatible for existing callers — this refactor should be invisible to app/api/integration/*/route.ts beyond the async/await change decided in step 1, if any.
  5. Add a documented (but not necessarily implemented) example of how a second store — e.g., a stub RedisRateLimitStore sketch — would plug into the interface, either as a code comment, a .example.ts file, or an update to docs/deployment.md's "Production rate-limiting" section, so the documented upgrade path is now concretely actionable rather than just described in prose.
  6. Update test/rate-limit.test.ts to test the algorithm against the RateLimitStore interface (e.g., with an injectable fake store for deterministic tests) in addition to (or instead of) only exercising the default in-memory store end to end.

Acceptance Criteria

  • lib/rate-limit.ts exposes a RateLimitStore interface and an InMemoryRateLimitStore default implementation.
  • All existing behavior (token refill math, limited/retryAfter/remaining semantics) is unchanged and covered by the existing test/rate-limit.test.ts suite (updated only as needed for the interface change, not the algorithm).
  • app/api/integration/*/route.ts continue to work correctly against the refactored module.
  • docs/deployment.md (or an equivalent doc) shows concretely how a second store implementation would be wired in, fulfilling the "upgrade path" the code comment already promises.
  • npm run typecheck, npm run lint, npm test pass.

Likely Affected Files/Directories

  • lib/rate-limit.ts
  • lib/rate-limit/store.ts (new, if split out)
  • app/api/integration/membership/route.ts
  • app/api/integration/verify/route.ts
  • test/rate-limit.test.ts
  • docs/deployment.md

Metadata

Metadata

Assignees

Labels

GrantFox OSSGrantFox Open Source Sponsorship program tagMaybe RewardedIssue may qualify for a reward upon successful completion per campaign rulesOfficial Campaign | FWC26Official FWC26 campaign issue — eligible for campaign scoring and rewardsapi-layerAutomatically createdhelp wantedExtra attention is neededpriority: mediumAutomatically createdrefactorCode restructuring without changing external behavior or API

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions