back
Backend · Distributed Systems

API SurgeProtector

API SurgeProtector is a distributed rate-limiting gateway designed to protect backend services from request surges.

sourceReleasing Soon
02

Project Overview

API SurgeProtector is a distributed rate-limiting gateway designed to protect backend services from request surges. It implements three classic algorithms (fixed-window, sliding-window-log, and token-bucket) in both in-memory form (for testing and reference) and Redis-backed form using atomic Lua scripts for production. A pluggable TypeScript interface and an Express middleware factory make enforcement a one-liner in any route, complete with standard rate-limit headers and a fail-open stance so a limiter failure never takes down the API it protects.

03

Solution

The core abstraction is the RateLimiterAlgorithm interface: isAllowed(identifier) and reset(identifier). Every strategy (fixed-window, sliding-window-log, token-bucket) implements this contract, and consumers depend on the interface, never on a concrete class. In-memory implementations use an injected Clock so they are fully deterministic under test. Distributed implementations move state into Redis and wrap every mutating operation in an atomic Lua script executed with EVAL, so trimming, counting, and adding happen in a single server-side step with no interleaving. The Express middleware factory wires any algorithm into a route, sets X-RateLimit-Limit/Remaining/Reset headers, emits Retry-After on 429, and fails open if the limiter itself errors. The included server demonstrates all three algorithms on real endpoints.

04

Architecture

API SurgeProtector architecture diagram
05

Core Features

01

Fixed-Window Rate Limiter

A counter keyed by identifier with an EXPIRE bound to the window. Simple, memory-cheap, and perfect for strict per-minute policies, at the cost of boundary spikes at window edges. Used for login (5 req/min per IP).

02

Sliding-Window-Log Rate Limiter

A Redis Sorted Set stores a timestamp per request. Each call atomically removes expired entries, counts the window, and adds the new request. Precise and spike-free, used for search (30 req/min per API key), at the cost of more memory per identifier.

03

Token-Bucket Rate Limiter

A bucket of capacity tokens refilled at a steady rate (refillRatePerSecond), allowing short bursts up to the bucket size while capping the average. The classic choice for APIs that tolerate bursts, used for /api/users (100 req/min, burst 10).

04

Atomic Lua Scripts

Every Redis-backed operation runs as a single server-side Lua script via EVAL, so window trimming, counting, and insertion cannot interleave with other requests. Correct under true concurrency, not just in theory.

05

Pluggable Algorithm Interface

One isAllowed/reset contract. Algorithms are swapped per route, per tenant, or config-driven without touching middleware or consumers. The Strategy pattern applied to traffic control.

06

Express Middleware Factory

A drop-in rateLimiter({ algorithm, limit, identifier, onLimit }) factory that sets standard headers, emits Retry-After on rejection, and fails open on limiter errors. A single line protects any route.

06

Engineering Challenges

01

Redis atomicity

Separate ZREMRANGEBYSCORE / ZCARD / ZADD calls race: two requests can both read a count under the limit and both slip through. The solution is a single Lua script that trims, counts, and adds inside one atomic EVAL, with no interleaving possible.

02

Window-boundary spikes

Fixed windows reset on a boundary, so a client can burst 2N requests across a reset. Sliding-window-log eliminates the spike by trimming continuously; the tradeoff is memory per identifier. Choosing per endpoint is the right answer, not choosing once.

03

Deterministic testing

Rate limiting is pure math over 'now', but Date.now() makes tests flaky. A FakeClock injected into every in-memory algorithm makes behavior 100% deterministic, with no fake timers or sleeps, and mirrors how Redis will source time from TIME.

04

Fail-open vs fail-closed

If the limiter itself throws (Redis down, script error), crashing the API protects nothing. The middleware catches algorithm errors, logs them, and allows the request through, prioritizing availability over strictness when the guard itself fails.

05

Memory growth in window logs

A sorted-set entry per request can grow unboundedly. The sliding-window script prunes expired entries on every call and sets an EXPIRE at window + 1s headroom, bounding memory to the active window.

07

Technical Decisions

01

Redis over in-memory state

In-memory counters are per-instance. Two API servers each allow their own limit. Redis centralizes state so limits hold across the fleet. It also survives restarts and provides a natural TTL story for counters.

02

Lua scripts over optimistic locking

Optimistic concurrency (WATCH/MULTI) requires retries and still has edge cases. A Lua script runs atomically on the Redis server, eliminating check-then-act races with no client retry logic.

03

Strategy pattern over hard-coded limiters

Consumers depend on RateLimiterAlgorithm, not concrete classes. That is what makes per-route, per-tenant, or config-driven algorithm selection possible later without touching middleware.

04

Sorted sets for sliding windows

A ZSET with timestamp scores gives exact per-request history and O(log n) trim/count operations, the right structure for precise windows. Fixed-window uses a simple counter with EXPIRE where precision is not needed.

05

In-memory reference implementations

Shipping in-memory versions alongside the Redis ones serves double duty: deterministic unit tests and a readable reference for what each algorithm does before the Redis/Lua layer adds I/O.

06

Docker for reproducibility

A compose file brings up the app and Redis together, so the distributed behavior is reproducible for anyone, with no local Redis install required.

08

Performance Considerations

  • All Redis-backed operations are single EVAL calls: one round trip, O(log n) or O(1) work on the server.
  • Fixed-window uses one INCR-style counter with EXPIRE; token-bucket stores two small fields; both keep memory to a handful of bytes per identifier.
  • The sliding-window script prunes expired members and sets EXPIRE at window + 1s, bounding memory to the active window.
  • A benchmark harness (npm run bench) measures throughput so algorithm choice is evidence-based, not vibes.