CS Fundamentals Trainer · Guides

Design a rate limiter

Where it lives, the algorithms and their trade-offs, working code, Redis at scale, and the numbers.

30 min read

A rate limiter caps how many requests a client may make in a period of time. It protects you from abuse, keeps one noisy customer from starving the others, and stops a retry storm from turning a small problem into an outage. It is one of the most common system design interview questions, and a piece you will build for real sooner or later.

1. Pin down the problem

Before drawing boxes, ask what is being limited and why. The answers change the design.

The limiter must add very little latency, use little memory, and keep working if its own store fails. Decide up front whether it fails open (let traffic through) or fails closed (block it). Open is the right default for a public API: a limiter outage should not become an API outage. Security limits fail closed: if the store behind "5 login attempts per account" is down, letting everyone try passwords without limit defeats the purpose. Often the answer is per rule, not per system.

2. Where does it live?

There are three places to put the check, and the choice is mostly about who controls the code.

Diagram: Three places to enforce a limit

3. Four algorithms

Each algorithm answers the same question, "may this request pass?", with a different memory and accuracy trade-off.

Token bucket

A bucket holds up to capacity tokens and is refilled at rate tokens per second. Each request takes one token. With an empty bucket, the request is rejected.

Diagram: Token bucket decision for one request

It allows short bursts (up to the capacity) while holding the long-run average to the refill rate, and it needs only two numbers per client: the token count and the time of the last refill. There is no timer that adds tokens: the refill is lazy, computed from the elapsed time when the next request arrives. Stripe describes using a token bucket in production [1], and the algorithm itself is a classic of network traffic shaping [13].

Leaky bucket

"Leaky bucket" means two different things, and mixing them up is a common interview slip.

This guide uses the queue version when it says "leaky bucket". If you only need to cap the rate and don't need to smooth the traffic, the meter version adds nothing over a token bucket. The two readings are described side by side in [12].

Fixed window counter

Count requests in each clock window (for example, per minute) and reject above the limit. It is the simplest option, with one integer per client and window. Its flaw is the boundary: a client can send the full limit at 12:00:59 and again at 12:01:00, twice the limit in two seconds.

Diagram: Fixed windows allow 200 requests in 20 seconds

Each window sees only 100 requests, so neither rejects anything, yet any 60-second span that covers both bursts holds 200.

Sliding window

A sliding window removes that spike by looking at the last N seconds instead of the current clock minute. There are two versions, and they are not equally accurate.

Diagram: The sliding window counter weights the previous window by its overlap

At 12:01:30 the sliding window reaches back to 12:00:30, so half of the previous window is still inside: the estimate is 80 × 0.5 + 40 = 80.

AlgorithmMemory per clientBurstsAccuracy
Token bucket2 numbersAllowed up to capacityExact for its own rule
Leaky bucket (queue)Queue of fixed sizeSmoothed outExact, adds latency
Fixed window1 counterUp to 2x at the boundaryRough
Sliding logOne timestamp per requestPreventedExact
Sliding window counter2 countersMostly preventedApproximate (even-spread)

For most APIs, start with a token bucket. Pick a sliding log when the rule must be strict and the limit is small ("100 per hour"), and a sliding window counter when it must be cheap at huge scale.

4. Working code

The diagrams hide the details people forget: the lazy refill, the weighting formula, what exactly gets dropped. Here are all four algorithms for a single process, in four languages (pick a language on the tabs; your choice is remembered for every sample). Each keeps its state in memory and guards it, because two threads could otherwise read the same state and both pass.

Fixed window counter

import threading
import time


class FixedWindowCounter:
    def __init__(self, limit, window):           # window in seconds
        self.limit, self.window = limit, window
        self.start, self.count = 0.0, 0
        self.lock = threading.Lock()

    def allow(self):
        with self.lock:
            now = time.time()
            start = now - now % self.window      # start of the current window
            if start != self.start:              # a new window: the count starts again
                self.start, self.count = start, 0
            if self.count >= self.limit:
                return False
            self.count += 1
            return True
type FixedWindowCounter struct {
	mu     sync.Mutex
	limit  int
	window time.Duration
	start  time.Time
	count  int
}

func (f *FixedWindowCounter) Allow() bool {
	f.mu.Lock()
	defer f.mu.Unlock()
	start := time.Now().Truncate(f.window) // start of the current window
	if !start.Equal(f.start) {             // a new window: the count starts again
		f.start, f.count = start, 0
	}
	if f.count >= f.limit {
		return false
	}
	f.count++
	return true
}
class FixedWindowCounter {
  constructor(limit, windowMs) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.start = 0;
    this.count = 0;
  }

  allow() {
    const now = Date.now();
    const start = now - (now % this.windowMs);   // start of the current window
    if (start !== this.start) {                  // a new window: the count starts again
      this.start = start;
      this.count = 0;
    }
    if (this.count >= this.limit) return false;
    this.count += 1;
    return true;
  }
}
public sealed class FixedWindowCounter
{
    private readonly object _lock = new();
    private readonly int _limit;
    private readonly long _windowMs;
    private long _start;
    private int _count;

    public FixedWindowCounter(int limit, TimeSpan window)
    {
        _limit = limit;
        _windowMs = (long)window.TotalMilliseconds;
    }

    public bool Allow()
    {
        lock (_lock)
        {
            long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
            long start = now - now % _windowMs;   // start of the current window
            if (start != _start)                  // a new window: the count starts again
            {
                _start = start;
                _count = 0;
            }
            if (_count >= _limit) return false;
            _count++;
            return true;
        }
    }
}

Sliding log

from collections import deque


class SlidingLog:
    def __init__(self, limit, window):           # window in seconds
        self.limit, self.window = limit, window
        self.log = deque()                       # accepted requests, oldest first
        self.lock = threading.Lock()

    def allow(self):
        with self.lock:
            now = time.monotonic()
            while self.log and self.log[0] <= now - self.window:
                self.log.popleft()               # this one left the window
            if len(self.log) >= self.limit:
                return False
            self.log.append(now)
            return True
type SlidingLog struct {
	mu     sync.Mutex
	limit  int
	window time.Duration
	log    []time.Time // accepted requests, oldest first
}

func (s *SlidingLog) Allow() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	now := time.Now()
	i := 0
	for i < len(s.log) && !s.log[i].After(now.Add(-s.window)) {
		i++ // this one left the window
	}
	s.log = s.log[i:]
	if len(s.log) >= s.limit {
		return false
	}
	s.log = append(s.log, now)
	return true
}
class SlidingLog {
  constructor(limit, windowMs) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.log = [];                               // accepted requests, oldest first
  }

  allow() {
    const now = performance.now();
    while (this.log.length && this.log[0] <= now - this.windowMs) {
      this.log.shift();                          // this one left the window
    }
    if (this.log.length >= this.limit) return false;
    this.log.push(now);
    return true;
  }
}
using System.Diagnostics;

public sealed class SlidingLog
{
    private readonly object _lock = new();
    private readonly int _limit;
    private readonly long _windowTicks;
    private readonly Queue<long> _log = new();   // accepted requests, oldest first

    public SlidingLog(int limit, TimeSpan window)
    {
        _limit = limit;
        _windowTicks = (long)(window.TotalSeconds * Stopwatch.Frequency);
    }

    public bool Allow()
    {
        lock (_lock)
        {
            long now = Stopwatch.GetTimestamp();
            while (_log.Count > 0 && _log.Peek() <= now - _windowTicks)
                _log.Dequeue();                   // this one left the window
            if (_log.Count >= _limit) return false;
            _log.Enqueue(now);
            return true;
        }
    }
}

Token bucket

import threading
import time


class TokenBucket:
    def __init__(self, capacity, rate):          # rate = tokens added per second
        self.capacity, self.rate = capacity, rate
        self.tokens, self.last = capacity, time.monotonic()
        self.lock = threading.Lock()

    def allow(self, cost=1):
        with self.lock:
            now = time.monotonic()
            # lazy refill: add what was earned since the last call, up to capacity
            self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
            self.last = now
            if self.tokens < cost:
                return False
            self.tokens -= cost
            return True
type TokenBucket struct {
	mu       sync.Mutex
	capacity float64
	rate     float64 // tokens added per second
	tokens   float64
	last     time.Time
}

func NewTokenBucket(capacity, rate float64) *TokenBucket {
	return &TokenBucket{capacity: capacity, rate: rate, tokens: capacity, last: time.Now()}
}

func (b *TokenBucket) Allow(cost float64) bool {
	b.mu.Lock()
	defer b.mu.Unlock()
	now := time.Now() // carries a monotonic reading, so Sub ignores clock changes
	// lazy refill: add what was earned since the last call, up to capacity
	b.tokens = math.Min(b.capacity, b.tokens+now.Sub(b.last).Seconds()*b.rate)
	b.last = now
	if b.tokens < cost {
		return false
	}
	b.tokens -= cost
	return true
}
class TokenBucket {
  constructor(capacity, rate) {              // rate = tokens added per second
    this.capacity = capacity;
    this.rate = rate;
    this.tokens = capacity;
    this.last = performance.now();           // monotonic, in milliseconds
  }

  allow(cost = 1) {
    const now = performance.now();
    // lazy refill: add what was earned since the last call, up to capacity
    this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.rate);
    this.last = now;
    if (this.tokens < cost) return false;
    this.tokens -= cost;
    return true;
  }
}
using System.Diagnostics;

public sealed class TokenBucket
{
    private readonly object _lock = new();
    private readonly double _capacity, _rate;   // rate = tokens added per second
    private double _tokens;
    private long _last = Stopwatch.GetTimestamp();

    public TokenBucket(double capacity, double rate)
    {
        _capacity = capacity;
        _rate = rate;
        _tokens = capacity;
    }

    public bool Allow(double cost = 1)
    {
        lock (_lock)
        {
            long now = Stopwatch.GetTimestamp();   // monotonic
            double elapsed = Stopwatch.GetElapsedTime(_last, now).TotalSeconds;
            // lazy refill: add what was earned since the last call, up to capacity
            _tokens = Math.Min(_capacity, _tokens + elapsed * _rate);
            _last = now;
            if (_tokens < cost) return false;
            _tokens -= cost;
            return true;
        }
    }
}

Sliding window counter

class SlidingWindowCounter:
    def __init__(self, limit, window):           # window in seconds
        self.limit, self.window = limit, window
        self.start, self.current, self.previous = 0.0, 0, 0
        self.lock = threading.Lock()

    def allow(self):
        with self.lock:
            now = time.time()
            start = now - now % self.window      # start of the current fixed window
            if start != self.start:
                # exactly one window later: current becomes previous; longer: nothing to carry
                self.previous = self.current if start - self.start == self.window else 0
                self.current, self.start = 0, start
            overlap = 1 - (now - start) / self.window
            if self.previous * overlap + self.current + 1 > self.limit:
                return False
            self.current += 1
            return True
// Create it with &SlidingWindowCounter{limit: 100, window: time.Minute}.
type SlidingWindowCounter struct {
	mu       sync.Mutex
	limit    float64
	window   time.Duration
	start    time.Time // start of the current fixed window
	current  float64
	previous float64
}

func (s *SlidingWindowCounter) Allow() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	now := time.Now()
	start := now.Truncate(s.window) // start of the current fixed window
	if !start.Equal(s.start) {
		// exactly one window later: current becomes previous; longer: nothing to carry
		if start.Sub(s.start) == s.window {
			s.previous = s.current
		} else {
			s.previous = 0
		}
		s.current, s.start = 0, start
	}
	overlap := 1 - float64(now.Sub(start))/float64(s.window)
	if s.previous*overlap+s.current+1 > s.limit {
		return false
	}
	s.current++
	return true
}
class SlidingWindowCounter {
  constructor(limit, windowMs) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.start = 0;
    this.current = 0;
    this.previous = 0;
  }

  allow() {
    const now = Date.now();
    const start = now - (now % this.windowMs);   // start of the current fixed window
    if (start !== this.start) {
      // exactly one window later: current becomes previous; longer: nothing to carry
      this.previous = start - this.start === this.windowMs ? this.current : 0;
      this.current = 0;
      this.start = start;
    }
    const overlap = 1 - (now - start) / this.windowMs;
    if (this.previous * overlap + this.current + 1 > this.limit) return false;
    this.current += 1;
    return true;
  }
}
public sealed class SlidingWindowCounter
{
    private readonly object _lock = new();
    private readonly double _limit;
    private readonly long _windowMs;
    private long _start;
    private double _current, _previous;

    public SlidingWindowCounter(double limit, TimeSpan window)
    {
        _limit = limit;
        _windowMs = (long)window.TotalMilliseconds;
    }

    public bool Allow()
    {
        lock (_lock)
        {
            long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
            long start = now - now % _windowMs;   // start of the current fixed window
            if (start != _start)
            {
                // exactly one window later: current becomes previous; longer: nothing to carry
                _previous = start - _start == _windowMs ? _current : 0;
                _current = 0;
                _start = start;
            }
            double overlap = 1 - (double)(now - start) / _windowMs;
            if (_previous * overlap + _current + 1 > _limit) return false;
            _current++;
            return true;
        }
    }
}

Leaky bucket (the queue version)

Requests wait in a bounded queue and a worker takes one out at a fixed rate. A request that finds the queue full is dropped. (The meter version is the token bucket above.)

import queue


class LeakyBucket:
    def __init__(self, capacity, rate, handle):  # rate = requests processed per second
        self.q = queue.Queue(maxsize=capacity)
        self.handle = handle                     # the function that does the real work
        threading.Thread(target=self._drain, args=(1 / rate,), daemon=True).start()

    def submit(self, request):
        try:
            self.q.put_nowait(request)           # wait in line...
            return True
        except queue.Full:
            return False                         # ...or be dropped: the bucket is full

    def _drain(self, interval):
        while True:
            self.handle(self.q.get())            # one request leaks out
            time.sleep(interval)
type LeakyBucket struct{ queue chan func() }

func NewLeakyBucket(capacity int, rate float64) *LeakyBucket {
	b := &LeakyBucket{queue: make(chan func(), capacity)}
	go func() {
		tick := time.NewTicker(time.Duration(float64(time.Second) / rate))
		defer tick.Stop()
		for job := range b.queue {
			<-tick.C // one request leaks out per tick
			job()
		}
	}()
	return b
}

func (b *LeakyBucket) Submit(job func()) bool {
	select {
	case b.queue <- job: // wait in line...
		return true
	default: // ...or be dropped: the bucket is full
		return false
	}
}
class LeakyBucket {
  constructor(capacity, rate) {                  // rate = requests processed per second
    this.capacity = capacity;
    this.queue = [];
    // one request leaks out per tick
    setInterval(() => this.queue.shift()?.(), 1000 / rate).unref();
  }

  submit(job) {
    if (this.queue.length >= this.capacity) return false;   // the bucket is full: drop
    this.queue.push(job);                                   // wait in line
    return true;
  }
}
using System.Threading.Channels;

public sealed class LeakyBucket
{
    private readonly Channel<Func<Task>> _queue;

    public LeakyBucket(int capacity, double rate)   // rate = requests processed per second
    {
        _queue = Channel.CreateBounded<Func<Task>>(capacity);
        _ = Task.Run(async () =>
        {
            var tick = new PeriodicTimer(TimeSpan.FromSeconds(1 / rate));
            await foreach (var job in _queue.Reader.ReadAllAsync())
            {
                await tick.WaitForNextTickAsync();   // one request leaks out per tick
                await job();
            }
        });
    }

    // false when the bucket is full: the request is dropped
    public bool Submit(Func<Task> job) => _queue.Writer.TryWrite(job);
}

A few notes:

5. Making it work on many servers

With one server, the counters are a hash map in memory. With ten servers behind a load balancer, each would count only its share, and a client could get ten times the limit.

The standard fix is a shared, fast store, usually Redis, so every server checks the same counter.

Diagram: Two servers share one counter in Redis

Make the check atomic

"Read the counter, then write it back" is not safe when two servers do it at once: both may see room for one more request. The check and the update must be a single step, which in Redis means a single command or a Lua script (a script runs without any other command interleaving) [6].

For a fixed window, INCR is atomic by itself, but INCR followed by a separate EXPIRE is not. If the server crashes between the two calls, the key exists without a TTL and never expires, and that client is blocked forever. Put both in one script:

-- KEYS[1] = counter key, ARGV[1] = window in seconds
local n = redis.call('INCR', KEYS[1])
if n == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return n

For a token bucket, the whole read-refill-decide-write cycle goes into one script:

-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill rate (tokens per second), ARGV[3] = cost
local t = redis.call('TIME')                      -- the clock of Redis, not of the caller
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local capacity, rate, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])

local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or capacity    -- a new client starts with a full bucket
local ts = tonumber(bucket[2]) or now

tokens = math.min(capacity, tokens + (now - ts) * rate)   -- lazy refill
local allowed = tokens >= cost
local retry_after = 0
if allowed then
  tokens = tokens - cost
else
  retry_after = (cost - tokens) / rate
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 1)  -- idle keys clean themselves up
return {allowed and 1 or 0, tostring(tokens), tostring(retry_after)}

Why each line is there:

The other three algorithms in Redis

The same rules apply to every algorithm: one script, the time from TIME, and a TTL. The fixed window script is the one above.

Sliding log uses a sorted set whose scores are the request times:

-- KEYS[1] = sorted set of requests, scored by time
-- ARGV[1] = limit, ARGV[2] = window in seconds, ARGV[3] = a unique id for this request
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local limit, window = tonumber(ARGV[1]), tonumber(ARGV[2])

redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)   -- drop what left the window
if redis.call('ZCARD', KEYS[1]) >= limit then
  local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
  return {0, tostring(tonumber(oldest[2]) + window - now)}      -- seconds until a slot frees up
end
redis.call('ZADD', KEYS[1], now, ARGV[3])   -- the id keeps two requests of the same instant apart
redis.call('EXPIRE', KEYS[1], math.ceil(window))
return {1, '0'}

Sliding window counter keeps one counter per fixed window and weights the previous one:

-- KEYS[1] = key prefix, ARGV[1] = limit, ARGV[2] = window in seconds
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local limit, window = tonumber(ARGV[1]), tonumber(ARGV[2])

local index = math.floor(now / window)                       -- which fixed window we are in
local current_key = KEYS[1] .. ':' .. index
local previous_key = KEYS[1] .. ':' .. (index - 1)
local current = tonumber(redis.call('GET', current_key) or '0')
local previous = tonumber(redis.call('GET', previous_key) or '0')

local overlap = 1 - (now - index * window) / window
if previous * overlap + current + 1 > limit then
  return 0
end
redis.call('INCR', current_key)
redis.call('EXPIRE', current_key, window * 2)                -- still needed as "previous" next window
return 1

Redis Cluster expects a script to be given every key it touches. This one builds the key names inside the script, which works on a single node; on a cluster, compute the window index in the client and pass both keys (with the same {hash tag}) in KEYS.

Leaky bucket (queue) is not a counter, so Redis stores the queue itself, a list, and a worker drains it at the fixed rate:

-- KEYS[1] = queue (a list), ARGV[1] = maximum queue length, ARGV[2] = the request
if redis.call('LLEN', KEYS[1]) >= tonumber(ARGV[1]) then
  return 0                                  -- the bucket is full: drop
end
redis.call('RPUSH', KEYS[1], ARGV[2])       -- wait in line
return 1

A worker then runs LPOP once per interval and handles what it gets. Because the work happens in the worker, this version fits job queues better than a synchronous API call. For simply capping a rate, use the token bucket.

Other things that go wrong

6. Numbers

A rough estimate is worth doing out loud in an interview. These are assumptions, to be replaced by your own.

How that changes with the algorithm, for the rule "100 per hour" and the same 20 million clients (all estimates; measure your own with MEMORY USAGE <key> on a real key [9]):

AlgorithmStored per clientApprox. per keyApprox. total
Fixed window1 counter~70 bytes~1.4 GB
Sliding window counter2 counters~100 bytes~2 GB
Token buckettokens and timestamp~120 bytes~2.4 GB
Sliding logup to 100 timestamps~3 to 5 KB~60 to 100 GB

The sliding log is 25 to 50 times larger, which is why it is chosen only when exactness is worth that cost. The takeaway for an interview: memory is rarely the problem for counters; requests per second to the store is. That is what decides the number of shards.

7. Telling the client

A rejected client should not have to guess.

Diagram: A client that respects Retry-After

The jitter matters. If a thousand clients all retry exactly three seconds later, they arrive together and trip the limit again.

8. Questions interviewers like to add

Requests that cost more than one

Not every call is equal: a search may cost 10 times a lookup. In a token bucket this is natural: a request takes cost tokens instead of one (the cost argument in both code samples above). It is rejected if fewer than cost tokens are left. Decide whether a rejected expensive request should still use up the tokens it found (usually not), and give very expensive calls a cost no larger than the capacity, or they can never succeed.

A burst limit and a sustained limit

Real APIs often have two rules at once, such as 10 per second and 1,000 per hour. The first protects against spikes, the second against steady heavy use. Keep one bucket for each, and let the request through only if both allow it.

The trap is consuming from the first bucket and then being refused by the second: the client has paid for a request that never ran. Check both before spending either, or do both inside one Lua script, which is atomic, so there is nothing to roll back. Return the Retry-After of the rule that is rejecting.

Retries and idempotency

Limiters and retries interact in both directions.

Testing a limiter

A limiter that passes one-request-at-a-time tests can still be wrong under load. Test it at three levels.

  1. Logic with a fake clock. Inject the clock so a test can move time forward without sleeping: after 5 requests the sixth fails, after 1 / rate seconds one more passes, an idle bucket never holds more than capacity. Also test the boundary of a window.
  2. Concurrency. Fire, say, 1,000 simultaneous requests from many threads or processes against a limit of 100, and assert that exactly 100 pass (token bucket: capacity plus what was refilled during the run). A read-then-write race shows up here as 101 or more.
  3. Distributed. Run several application instances against one real Redis, repeat the test, then kill the primary or add latency to check the fail-open or fail-closed behaviour and what the client sees.

9. Things to mention in an interview

Quick recap

  1. Clarify who and what is limited, and how strict it must be. Decide fail open or closed per rule.
  2. Enforce at the gateway.
  3. Token bucket by default, sliding log for small strict limits, sliding window counter for huge scale.
  4. Share the counter in Redis, make the update atomic (a Lua script), take the time from Redis, and give every key a TTL.
  5. Do the arithmetic: the requests per second to the store decide the shard count, not the memory.
  6. Answer with 429 and Retry-After, and have clients retry with jitter and idempotency keys.
  7. Test with a fake clock, under concurrency, and against a real Redis.

Sources

  1. Stripe, Scaling your API with rate limiters. Token bucket, and why they use several limiters together.
  2. Cloudflare, How we built rate limiting capable of scaling to millions of domains. The sliding window counter and its measured accuracy.
  3. IETF, RFC 6585, section 4: 429 Too Many Requests.
  4. IETF, RFC 9110, section 10.2.3: Retry-After.
  5. IETF HTTPAPI working group, RateLimit header fields for HTTP (draft).
  6. Redis documentation, Scripting with Lua. Why a script is atomic.
  7. Redis documentation, TIME. The server clock.
  8. Redis documentation, Benchmarks. Measuring requests per second with redis-benchmark.
  9. Redis documentation, MEMORY USAGE. Measuring bytes per key.
  10. Stripe, Idempotent requests.
  11. IETF HTTPAPI working group, The Idempotency-Key HTTP header field (draft).
  12. Wikipedia, Leaky bucket. The queue and the meter versions.
  13. Wikipedia, Token bucket.

Practise this topic: Resilience & Scalability Overload

More guides