3. Rate Limiter Intermediate → Advanced+Distributed

A. Realistic Interview Prompt

"Design a rate limiter that restricts how many requests a client can make to an API within a given time window. It should support multiple limiting algorithms and different limits for different clients or endpoints."

B. Difficulty

Intermediate for a single-process, single-algorithm version. Escalates to Advanced + Distributed Systems the moment the interviewer asks "now run this across many servers" — at which point the interesting problem stops being OOD and becomes "how do multiple processes atomically share one counter."

C. What This Problem Tests

D. Requirements

Functional

  1. Accept a request identified by client ID (and optionally endpoint)
  2. Decide allow/deny based on a configured limit and algorithm
  3. Support multiple algorithms (token bucket, sliding window, fixed window) interchangeably
  4. Return enough information for the caller to act: allowed/denied, remaining quota, retry-after

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Is limiting per-client, per-client-per-endpoint, per-IP, or global?Defines the key granularity for tracked stateThe composite key used in the bucket store
2Which algorithm(s) must be supported — token bucket, sliding window, fixed window, leaky bucket?Different algorithms have different burst/precision/memory trade-offsStrategy interface shape
3Can different endpoints/clients have different limits and different algorithms?Drives whether configuration is global or per-keyFactory + config design
4Is this a single process, or must it work correctly across multiple servers behind a load balancer?The single biggest scoping question — changes this from an LLD problem to a distributed-systems oneIn-memory map vs. shared store (Redis)
5On deny, should the request be rejected outright, or queued/delayed?Affects whether the API is purely synchronous allow/denyMethod signature — blocking vs. non-blocking
6Should the response expose remaining quota and retry-after?Common real-world requirement (HTTP 429 headers)Return type richness (RateLimitResult vs. plain boolean)
7Should the clock be injectable rather than read internally?Determines whether algorithms are deterministically testableMethod signature includes a timestamp parameter
8What's the default policy for a brand-new/unknown client?Fail-open (unlimited until first violation) vs. fail-closed (deny by default)Default config lookup behavior
9Must limits be updatable at runtime without a restart?Affects whether config is baked in at construction or re-read per requestHot-reloadable config store
10Is short-burst traffic acceptable, or must throughput be perfectly smooth?Token bucket allows bursts; sliding window is stricterDefault algorithm choice
11How many distinct clients must be tracked simultaneously?Millions of clients means per-client state must be evictableTTL/eviction on idle client buckets

F. Assumptions

G. Actors

ActorResponsibility
ClientSends requests; is the subject being rate-limited
API Gateway / ServiceCalls the rate limiter before processing each request; acts on allow/deny
Admin/OperatorConfigures per-client/per-endpoint limits (outside the hot path)

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
RateLimiter (facade)Public entry point; resolves the right algorithm + config for a key and delegates—check(clientId, endpoint, now)Algorithm math itself
RateLimitAlgorithm (interface)Decides allow/deny for one client key, given a timestamp—allowRequest(key, now)Knowing about other clients/endpoints
TokenBucketLimiter / SlidingWindowLimiter / FixedWindowLimiterConcrete algorithm implementationscapacity, refill rate / window sizeallowRequest()Configuration resolution, HTTP concerns
RateLimiterFactoryProduces the correctly configured algorithm instance per endpoint—getLimiter(endpoint)Making the allow/deny decision itself
RateLimitConfigDeclarative limit definition (capacity, window, algorithm type) per endpoint/tier——Any behavior — pure data
RateLimitResultStructured decision returned to the callerallowed, remaining, retryAfterMs—Any behavior — pure data

I. Class Relationships

J. Class Diagram

RateLimiter

+check(clientId, endpoint, now) : RateLimitResult

«interface»

RateLimitAlgorithm

+allowRequest(key, now) : RateLimitResult

RateLimiterFactory

+getLimiter(endpoint) : RateLimitAlgorithm

RateLimitConfig

capacity

window

algorithmType

TokenBucketLimiter

SlidingWindowLimiter

FixedWindowLimiter

View Mermaid source
classDiagram
  class RateLimiter {
    +check(clientId, endpoint, now) RateLimitResult
  }
  class RateLimitAlgorithm {
    <<interface>>
    +allowRequest(key, now) RateLimitResult
  }
  class RateLimiterFactory {
    +getLimiter(endpoint) RateLimitAlgorithm
  }
  class RateLimitConfig {
    capacity
    window
    algorithmType
  }
  RateLimiter --> RateLimitAlgorithm
  RateLimiter --> RateLimiterFactory
  RateLimiterFactory --> RateLimitConfig
  RateLimitAlgorithm <|.. TokenBucketLimiter
  RateLimitAlgorithm <|.. SlidingWindowLimiter
  RateLimitAlgorithm <|.. FixedWindowLimiter

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
RateLimiterEntry point, delegates to the right algorithmAlgorithm math, config storage
RateLimiterFactoryResolves config → correct algorithm instanceThe allow/deny decision itself
TokenBucketLimiter (etc.)Its own algorithm's math and stateKnowing about other algorithms or endpoints

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPFactory resolves config; algorithms only do their own math; facade only delegatesOne class reading config, doing token-bucket math, and sliding-window math via internal if/else
OCPAdd a new algorithm = new class implementing RateLimitAlgorithm; zero edits elsewhereif (algoType == "TOKEN_BUCKET") ... else if (algoType == "SLIDING_WINDOW") ... inside one giant method
LSPAny algorithm can substitute for another behind allowRequest() without special-casing by callerAn algorithm that needs extra caller-side setup the interface doesn't declare
ISPOne-method interface — no algorithm forced to implement irrelevant methodsA fat interface with resetWindow(), getBurstCapacity(), etc. that fixed-window doesn't need
DIPRateLimiter depends on the RateLimitAlgorithm abstraction, not a concrete classRateLimiter directly instantiating new TokenBucketLimiter() inline

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
StrategyThe algorithm is explicitly expected to vary and be swappedRateLimitAlgorithm + implementationsN/A — this is the textbook case; almost never wrong to use it here
FactoryChoosing the right configured algorithm per endpoint without scattering config-reading logicRateLimiterFactorySkip if there's genuinely only one global algorithm/config — a factory with one product is ceremony
FacadeCallers shouldn't need to know about factories/configs/algorithmsRateLimiter.check()Skip only if the system is trivially small
Decorator (optional)Add logging/metrics around every check without touching algorithm codeA MetricsRateLimiter wrapping any RateLimitAlgorithmSkip unless observability was actually requested

Explicitly avoided: a Chain-of-Responsibility for "try token bucket, then sliding window, then fixed window until one matches" — there's no such fallback semantic here; a direct factory lookup by config is simpler and correct. Chain-of-Responsibility is tempting-sounding but not justified by the requirements.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
AlgorithmToken bucketSliding window, leaky bucketRateLimitAlgorithm interface
Limit granularityPer-client-per-endpointPer-IP, per-API-key, globalComposite key construction isolated in one place
State storageIn-memory mapShared store (Redis) for multi-server correctnessState access hidden behind the algorithm class, not exposed to callers
Limit valuesStatic configRuntime-adjustable, per-tier (free/premium)RateLimitConfig resolved fresh per request, not cached at construction

O. Design Evolution

What stays stable: RateLimiter.check()'s signature. V6's biggest change is entirely inside the algorithm implementations' storage layer.

P. API / Interface Design

interface RateLimitAlgorithm {
  RateLimitResult allowRequest(String clientKey, long nowMs);
}

class RateLimiter {
  RateLimitResult check(String clientId, String endpoint, long nowMs);
}

class RateLimitResult {
  boolean allowed;
  long    remaining;
  long    retryAfterMs;   // 0 if allowed
}

Ownership/validation notes: nowMs is always supplied by the calling layer (server-side clock), never trusted from client input, and never read internally via a static call — this is what makes every algorithm implementation independently unit-testable with fabricated timestamps.

Q. Pseudocode — Token Bucket

allowRequest(key, now):
  bucket = store.getOrCreate(key, capacity)
  lock(bucket):
    elapsed = now - bucket.lastRefillTime
    bucket.tokens = min(capacity, bucket.tokens + elapsed * refillRatePerMs)
    bucket.lastRefillTime = now
    if bucket.tokens >= 1:
      bucket.tokens -= 1
      return ALLOW(remaining = floor(bucket.tokens))
    else:
      deficit = 1 - bucket.tokens
      return DENY(retryAfterMs = deficit / refillRatePerMs)

R. End-to-End Use Case

Client request
  │
  ▼
API Gateway → RateLimiter.check(clientId, endpoint, now)
  │
  ▼
RateLimiterFactory.getLimiter(endpoint)  → returns configured TokenBucketLimiter
  │
  ▼
TokenBucketLimiter.allowRequest(clientKey, now)
  │  refill → check → decrement (atomic under per-key lock)
  ▼
RateLimitResult{allowed, remaining, retryAfterMs}
  │
  ▼
Gateway forwards request (200) or rejects (429 + Retry-After header)

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
ConcurrentHashMap<ClientKey, Bucket>Per-client state lookupO(1) access, safe concurrent inserts of new client keysA single global lock around a plain map — simpler, but serializes all clients
Deque/queue of timestamps (sliding window log)Exact sliding-window countingPrecise — no boundary burst issueFixed window counters — O(1) memory but allows a 2x burst at window boundaries
Two rolling counters (sliding window counter, approximate)Memory-efficient approximation of sliding windowO(1) memory per client instead of O(requests in window)Less precise than the full log approach — accepted trade-off at scale

T. Complexity Analysis

AlgorithmTime per RequestSpace per ClientPrecision
Token BucketO(1)O(1)Allows controlled bursts
Fixed Window CounterO(1)O(1)Boundary issue — up to 2x burst across a window edge
Sliding Window LogO(w) to prune (w = requests in window)O(w)Exact
Sliding Window Counter (approx.)O(1)O(1)Close approximation, small error at boundaries

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Run this across 10 servers behind a load balancer."LLD → distributed boundaryMove per-client state to a shared store (Redis); use an atomic script for check-and-decrementStorage layer inside each algorithm implFail-open vs. fail-closed when the store is unreachable
"Different limits for free vs. premium users."Config-driven variability, not code changesRateLimitConfig keyed by tier; Factory selects per-requestRateLimiterFactory, RateLimitConfigZero change to algorithm classes — validates the OCP investment
"Two requests for the last token arrive simultaneously."Atomicity of check-then-decrementGuard the refill+check+decrement sequence with a per-key lock (or CAS loop)Concrete algorithm classPer-key lock beats a single global lock for throughput
"Limits must be changeable without a restart."Hot-reload awarenessConfig lookup happens per-request from a live config store, not cached at startupRateLimiterFactoryAdds a small per-request config-lookup cost — usually worth it
"Millions of distinct clients — memory is exploding."Bounded-resource thinkingTTL-evict idle client buckets (same idea as LRU eviction in Problem 2)Per-algorithm state storeTrades a little memory precision for a bounded footprint
"What if a client sends its own timestamp to try to reset its limit?"Security/trust boundary awarenessNever use client-supplied time for algorithm math — timestamp must be server-generatedAPI contract (already designed this way — validates the earlier decision)Trust boundary discipline
"The shared Redis cluster goes down."Failure-mode reasoningExplicit, documented fail-open (risk overload) or fail-closed (risk false rejections) policy — not left undefinedFacade-level fallback logicAvailability vs. protection trade-off — there's no universally "correct" choice, only an explicit one
"Add a global limit on top of per-endpoint limits."Composability of rulesCheck a global limiter first, then the per-endpoint one, short-circuiting on the first denialRateLimiter.check() orchestrationOrdering matters — decide and document which check runs first
"Return exactly how long until the client can retry."API completenessRateLimitResult.retryAfterMs, computed differently per algorithmAlready part of the base design — validates early API choice—

W. Concurrency

Shared mutable state: each client's bucket/window counters. The canonical race: two threads both read "tokens = 1," both decide to allow, both decrement — exceeding the limit by one.

X. Persistence

Rate-limiter state is inherently ephemeral and derived — losing it on restart (resetting all clients to a full bucket) is an acceptable, safe default, not a data-loss incident. In the distributed design, Redis (with a TTL) effectively serves as the "shared state" layer, but it's not meant to be a durable system of record — it can be treated as a fast, shared cache.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDAlgorithm classes, Factory, in-process state and locking for one server
HLDWhere the rate limiter sits (gateway/middleware), a shared Redis/Memcached tier, per-service vs. centralized limiting
Distributed SystemsAtomic cross-node counter updates (Lua scripts), replication of the shared store, fail-open/fail-closed policy under partition, clock considerations across nodes

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BTrade-off
AlgorithmToken BucketSliding Window LogToken bucket allows bursts and is O(1) memory; sliding window log is exact but O(requests-in-window) memory
LockingGlobal lockPer-client lockGlobal is simpler to reason about; per-client scales far better under load
State locationIn-memory (single server)Shared store (Redis)In-memory is fast and simple but wrong once there's more than one server; shared store is correct at scale but adds latency and a new failure mode
Failure policyFail-openFail-closedFail-open protects availability at the cost of temporarily unlimited traffic; fail-closed protects the backend at the cost of rejecting legitimate traffic during an outage

AC. Interviewer Evaluation

AD. Interview Simulation

Interviewer: Design a rate limiter for an API.
Candidate: Should limiting be per-client, per-endpoint, or both — and do you need multiple algorithms supported, like token bucket versus sliding window?
Interviewer: Per-client-per-endpoint, and yes, assume the algorithm might change later.
Candidate: Then I'll define a RateLimitAlgorithm interface with one method, allowRequest(key, timestamp) — the timestamp is passed in rather than read internally, so every algorithm is testable without mocking a clock.
Interviewer: Good. Implement token bucket.
Candidate: Each client key maps to a bucket with a token count and last-refill time. On each request, I refill based on elapsed time, then check if a token is available before decrementing.
Interviewer: Two requests for the same client arrive at the same instant, both see one token left.
Candidate: That refill-check-decrement sequence needs to be atomic per client key — I'd use a lock scoped to that client's bucket, not a global lock, so unrelated clients aren't affected.
Interviewer: Now this needs to run correctly across ten application servers.
Candidate: At that point the per-client state has to move out of any single server's memory into a shared store like Redis, and the check-and-decrement needs to be one atomic operation there too — typically a Lua script executed server-side, so two app servers can't race each other over the network.
Interviewer: What if Redis goes down?
Candidate: That's a policy decision, not a bug to hide — fail-open keeps the service available but risks a traffic spike; fail-closed protects the backend but rejects legitimate users. I'd make that explicit and configurable rather than picking silently.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
Hello InterviewRateLimiter orchestrator + per-client bucket delegation; explicit "Tell, Don't Ask" framing; timestamp passed as a parameterBest testability framing of any source found; clean interface designDistributed extension is only briefly gestured atThe timestamp-as-parameter discipline — adopted directly in this document
AlgoMonster (cross-referenced)Standard multi-algorithm walkthroughGood comparative coverage of token bucket vs. window algorithmsLess depth on concurrency/distributed concernsUseful as an algorithm-tradeoff reference
Tutorials Made Easy (blog)Interview-narrative style walkthroughGood sense of how the conversation typically escalatesLess rigorous on class-level API designPacing/escalation pattern used in this document's simulation (AD)

AF. Interview Cheat Sheet

ProblemRate Limiter
DifficultyIntermediate → Advanced+Distributed
Core requirementsAllow/deny per client based on a configurable, swappable algorithm
Key clarificationsGranularity? Single-process or distributed? Algorithm(s)? Fail-open or fail-closed?
Core entitiesRateLimiter (facade), RateLimitAlgorithm (strategy), RateLimiterFactory, RateLimitConfig, RateLimitResult
Key relationshipsFacade depends on Strategy interface; Factory builds instances from Config
Key patternsStrategy (textbook fit), Factory
Most important decisionPassing timestamp as a parameter, never trusting client-supplied or internally-read time
Likely-changing behaviorAlgorithm, granularity, storage backend, limit values
Concurrency concernAtomic check-and-decrement per client key; Lua scripts for distributed atomicity
Top follow-upsMulti-server distribution, per-tier limits, memory bounding, Redis outage policy
Common mistakesHardcoded algorithm with no interface; global lock; trusting client clocks; premature distributed design
What NOT to over-engineerDon't build the Redis/distributed version before the interviewer asks for scale

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

// Rate Limiter - full LLD implementation (single-file demo).
// Difficulty badge is "Intermediate -> Advanced+Distributed" - it does NOT explicitly say
// "Concurrency", so per convention this stays single-threaded/simple (no locks), even
// though a production system would eventually need per-key locking or a distributed store.
import java.util.*;

public class p3 {

    // ---------- RateLimitResult (pure data) ----------
    static class RateLimitResult {
        final boolean allowed;
        final long remaining;
        final long retryAfterMs; // 0 if allowed

        RateLimitResult(boolean allowed, long remaining, long retryAfterMs) {
            this.allowed = allowed;
            this.remaining = remaining;
            this.retryAfterMs = retryAfterMs;
        }

        static RateLimitResult allow(long remaining) { return new RateLimitResult(true, remaining, 0); }
        static RateLimitResult deny(long retryAfterMs) { return new RateLimitResult(false, 0, retryAfterMs); }

        public String toString() {
            return "RateLimitResult{allowed=" + allowed + ", remaining=" + remaining + ", retryAfterMs=" + retryAfterMs + "}";
        }
    }

    // ---------- RateLimitConfig (pure data) ----------
    enum AlgorithmType { TOKEN_BUCKET, SLIDING_WINDOW, FIXED_WINDOW }

    static class RateLimitConfig {
        final long capacity;      // max tokens / max requests per window
        final long windowMs;      // window size in ms (or refill window for token bucket)
        final AlgorithmType algorithmType;

        RateLimitConfig(long capacity, long windowMs, AlgorithmType algorithmType) {
            this.capacity = capacity;
            this.windowMs = windowMs;
            this.algorithmType = algorithmType;
        }
    }

    // ---------- RateLimitAlgorithm (interface) ----------
    interface RateLimitAlgorithm {
        RateLimitResult allowRequest(String clientKey, long nowMs);
    }

    // Each algorithm owns its own per-client state map, since the state shape genuinely
    // differs (a counter vs. a timestamp deque vs. two rolling counters) - forcing a shared
    // generic "Bucket" class would either bloat it or leak abstraction.
    static class TokenBucketLimiter implements RateLimitAlgorithm {
        private static class Bucket {
            double tokens;
            long lastRefillTime;
        }

        private final long capacity;
        private final double refillRatePerMs; // tokens added per millisecond
        private final Map<String, Bucket> buckets = new HashMap<>();

        TokenBucketLimiter(RateLimitConfig config) {
            this.capacity = config.capacity;
            this.refillRatePerMs = (double) config.capacity / config.windowMs;
        }

        public RateLimitResult allowRequest(String clientKey, long nowMs) {
            Bucket bucket = buckets.computeIfAbsent(clientKey, k -> {
                Bucket b = new Bucket();
                b.tokens = capacity;
                b.lastRefillTime = nowMs;
                return b;
            });
            long elapsed = nowMs - bucket.lastRefillTime;
            bucket.tokens = Math.min(capacity, bucket.tokens + elapsed * refillRatePerMs);
            bucket.lastRefillTime = nowMs;
            if (bucket.tokens >= 1) {
                bucket.tokens -= 1;
                return RateLimitResult.allow((long) Math.floor(bucket.tokens));
            }
            double deficit = 1 - bucket.tokens;
            long retryAfterMs = (long) Math.ceil(deficit / refillRatePerMs);
            return RateLimitResult.deny(retryAfterMs);
        }
    }

    // Sliding window: keeps an exact deque of recent request timestamps per client and
    // counts how many fall within the last windowMs - more accurate than fixed window at
    // the cost of memory proportional to request rate.
    static class SlidingWindowLimiter implements RateLimitAlgorithm {
        private final long capacity;
        private final long windowMs;
        private final Map<String, Deque<Long>> history = new HashMap<>();

        SlidingWindowLimiter(RateLimitConfig config) {
            this.capacity = config.capacity;
            this.windowMs = config.windowMs;
        }

        public RateLimitResult allowRequest(String clientKey, long nowMs) {
            Deque<Long> timestamps = history.computeIfAbsent(clientKey, k -> new ArrayDeque<>());
            long windowStart = nowMs - windowMs;
            while (!timestamps.isEmpty() && timestamps.peekFirst() <= windowStart) {
                timestamps.pollFirst();
            }
            if (timestamps.size() < capacity) {
                timestamps.addLast(nowMs);
                return RateLimitResult.allow(capacity - timestamps.size());
            }
            long retryAfterMs = timestamps.peekFirst() + windowMs - nowMs;
            return RateLimitResult.deny(Math.max(0, retryAfterMs));
        }
    }

    // Fixed window: counts requests in discrete, non-overlapping windows - cheap (one
    // counter per client) but allows bursts at window boundaries.
    static class FixedWindowLimiter implements RateLimitAlgorithm {
        private static class WindowCounter {
            long windowStart;
            long count;
        }

        private final long capacity;
        private final long windowMs;
        private final Map<String, WindowCounter> counters = new HashMap<>();

        FixedWindowLimiter(RateLimitConfig config) {
            this.capacity = config.capacity;
            this.windowMs = config.windowMs;
        }

        public RateLimitResult allowRequest(String clientKey, long nowMs) {
            WindowCounter counter = counters.computeIfAbsent(clientKey, k -> {
                WindowCounter c = new WindowCounter();
                c.windowStart = nowMs;
                c.count = 0;
                return c;
            });
            if (nowMs - counter.windowStart >= windowMs) {
                counter.windowStart = nowMs;
                counter.count = 0;
            }
            if (counter.count < capacity) {
                counter.count++;
                return RateLimitResult.allow(capacity - counter.count);
            }
            long retryAfterMs = counter.windowStart + windowMs - nowMs;
            return RateLimitResult.deny(Math.max(0, retryAfterMs));
        }
    }

    // ---------- RateLimiterFactory ----------
    // Resolves "which algorithm for which endpoint" - kept out of both the facade and the
    // algorithms themselves so that mapping logic has exactly one home.
    static class RateLimiterFactory {
        private final Map<String, RateLimitConfig> configsByEndpoint;
        private final Map<String, RateLimitAlgorithm> instancesByEndpoint = new HashMap<>();

        RateLimiterFactory(Map<String, RateLimitConfig> configsByEndpoint) {
            this.configsByEndpoint = configsByEndpoint;
        }

        RateLimitAlgorithm getLimiter(String endpoint) {
            return instancesByEndpoint.computeIfAbsent(endpoint, ep -> {
                RateLimitConfig config = configsByEndpoint.get(ep);
                if (config == null) throw new IllegalArgumentException("No config for endpoint: " + ep);
                switch (config.algorithmType) {
                    case TOKEN_BUCKET: return new TokenBucketLimiter(config);
                    case SLIDING_WINDOW: return new SlidingWindowLimiter(config);
                    case FIXED_WINDOW: return new FixedWindowLimiter(config);
                    default: throw new IllegalStateException("Unknown algorithm type");
                }
            });
        }
    }

    // ---------- RateLimiter (facade) ----------
    static class RateLimiter {
        private final RateLimiterFactory factory; // injected interface-like dependency

        RateLimiter(RateLimiterFactory factory) {
            this.factory = factory;
        }

        RateLimitResult check(String clientId, String endpoint, long nowMs) {
            RateLimitAlgorithm algorithm = factory.getLimiter(endpoint);
            String clientKey = endpoint + ":" + clientId; // scope quota per endpoint+client
            return algorithm.allowRequest(clientKey, nowMs);
        }
    }

    // ---------- Demo ----------
    public static void main(String[] args) {
        Map<String, RateLimitConfig> configs = new HashMap<>();
        configs.put("/api/login", new RateLimitConfig(3, 1000, AlgorithmType.TOKEN_BUCKET));
        configs.put("/api/search", new RateLimitConfig(2, 1000, AlgorithmType.FIXED_WINDOW));

        RateLimiterFactory factory = new RateLimiterFactory(configs);
        RateLimiter limiter = new RateLimiter(factory);

        long t = 0;
        for (int i = 0; i < 5; i++) {
            RateLimitResult result = limiter.check("clientA", "/api/login", t);
            System.out.println("t=" + t + " login attempt " + (i + 1) + " -> " + result);
            t += 100; // 100ms between requests, faster than refill can keep up
        }

        System.out.println();
        long t2 = 0;
        for (int i = 0; i < 3; i++) {
            RateLimitResult result = limiter.check("clientB", "/api/search", t2);
            System.out.println("t=" + t2 + " search attempt " + (i + 1) + " -> " + result);
        }
    }
}