INTRODUCTION: THE 25 RPS REGULATORY MANDATE

In Lesson 7.1, we established the API Gateway as the traffic cop. But a traffic cop is useless without speed limits. The UK CMA Order 2017 (via OBIE MI reporting) mandates that ASPSPs must support at least 25 RPS (requests per second) per TPP client ID during peak hours (09:00–17:00 GMT). The CDR (Australia) has a similar requirement of 25 RPS per Data Holder.

Rate limiting is not a performance optimisation—it is a regulatory compliance requirement. If the ASPSP’s API Gateway throttles a TPP to below 25 RPS during peak hours, the TPP can complain to the CMA, triggering an Article 58 direction. Conversely, if a TPP exceeds 25 RPS and the gateway does not throttle it, a malicious TPP can DDoS the ASPSP’s backend, degrading service for all other TPPs. The EBA’s Guidelines on Outsourcing require that ASPSPs implement “adequate security measures to protect against denial of service attacks,” which includes rate limiting.

This lesson deconstructs the distributed token bucket algorithm—the industry standard for rate limiting. We derive the mathematical formula for token refill (tokens = min(capacity, tokens + (now - last_refill) * rate)), and we implement the Redis atomic Lua script that enforces the limit across multiple gateway nodes. We formalize the burst allowance (50 RPS for 10 seconds) using a dual-bucket strategy and prove that the probability of a well-behaved client exceeding the limit is zero. We quantify the latency overhead of the rate limiter (< 0.2ms), and we calculate the probability of a TPP exceeding the rate limit (which approaches 0 for well-behaved clients). We also derive the optimal Redis key expiry (60 seconds) using the Nyquist sampling theorem, balancing memory usage against accuracy.


LEARNING OBJECTIVES

  1. Formalize the Token Bucket Algorithm—deriving the mathematical formula tokens = min(capacity, tokens + (now - last_refill) * rate), and proving that the algorithm guarantees an average rate of rate tokens per second over a long time horizon. We will also prove that the algorithm is work-conserving (it allows bursts up to the capacity).

  2. Implement the Distributed Rate Limiter in Redis—creating an atomic Lua script that reads the current token count, updates it, and returns a boolean (allow/deny), ensuring that multiple gateway nodes share the same rate limit state. We will prove that the Lua script is atomic and prevents race conditions.

  3. Design the Burst Allowance Strategy—implementing a dual-bucket system (one bucket for the average rate of 25 RPS, and a second bucket for the burst of 50 RPS for 10 seconds), and proving mathematically that the probability of a well-behaved client exceeding the limit is zero.

  4. Calculate the Rate Limiter Latency—measuring the round-trip time to Redis (1.2ms), the Lua script execution time (0.1ms), and the network serialization (0.1ms), and proving that the total overhead is under 1.5ms.

  5. Design the 429 Too Many Requests Response—defining the exact JSON payload and the Retry-After header, and deriving the Retry-After value as ceil((tokens_needed - tokens_available) / rate) seconds. We will also prove that the Retry-After header guides the TPP to retry at the optimal time, reducing the overall API load.

  6. Derive the Optimal Redis Key Expiry—applying the Nyquist sampling theorem to determine the optimal TTL for the rate limit key, balancing memory usage (Redis memory) against accuracy (the rate limiter must accurately track requests over the burst window).

  7. Model the Thundering Herd Prevention—deriving the probability that a large number of TPPs exceed the rate limit simultaneously (the thundering herd), and designing a jittered retry strategy (Retry-After + random(0, 1)) to prevent it.


PART 1: THE TOKEN BUCKET ALGORITHM — The Mathematical Foundation

The token bucket algorithm is a well-known rate-limiting technique. It works as follows:

  • A bucket has a capacity C (maximum number of tokens).

  • Tokens are added to the bucket at a rate r tokens per second.

  • Each request consumes one token.

  • If the bucket has at least one token, the request is allowed; otherwise, it is denied.

The Mathematical Formula:

Let tokens be the current number of tokens in the bucket. Let last_refill be the timestamp of the last refill. Let now be the current timestamp.

elapsed = now - last_refill
tokens = min(capacity, tokens + elapsed * rate)
last_refill = now

If tokens >= 1, allow the request and decrement tokens by 1. Otherwise, deny.

The Rate Guarantee:

Over a long time horizon T, the total number of allowed requests is at most C + r * T. The average rate is (C + r * T) / T ≈ r as T → ∞. The algorithm guarantees an average rate of r tokens per second over a long time horizon, with a maximum burst of C.

The UK Open Banking Requirement:

  • Average Rate (r): 25 tokens per second (25 RPS).

  • Capacity (C): 50 tokens (allowing a burst of 50 requests in a single second).

The CDR Requirement:

  • Average Rate (r): 25 tokens per second.

  • Capacity (C): 50 tokens.

Proof of Work-Conserving:
The token bucket is work-conserving. If the bucket is full (tokens = C), the client can send C requests immediately (a burst). The bucket then refills at rate r. The client is never penalized for “idle time”—the tokens accumulate and can be used later.


PART 2: THE DISTRIBUTED RATE LIMITER — Redis Atomic Lua

In a multi-node API Gateway deployment, the rate limiter state must be shared across all nodes. Redis is the industry standard for distributed rate limiting.

The Redis Key Structure:

  • Keyratelimit:client:{client_id}

  • Fields:

    • tokens: Current number of tokens in the bucket.

    • last_refill: Timestamp of the last refill.

The Redis Lua Script (Atomic) :

lua
-- KEYS[1] = rate limit key (e.g., "ratelimit:client:tpp-789")
-- ARGV[1] = refill rate (tokens per second) = 25
-- ARGV[2] = bucket capacity = 50
-- ARGV[3] = current timestamp (Unix epoch, seconds)

local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 1. Read the current bucket state
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

-- 2. Refill tokens based on elapsed time
local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * rate)

-- 3. Check if request is allowed
local allowed = tokens >= 1
if allowed then
    tokens = tokens - 1
end

-- 4. Store the updated state
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 60)  -- TTL: 60 seconds

-- 5. Return result
return {allowed and 1 or 0, tokens, capacity}

Atomicity Guarantee:
Redis executes the Lua script atomically. If two concurrent requests arrive for the same key, the script executes on the Redis server in a single-threaded manner, ensuring that the read-modify-write operation is atomic. No race conditions.

Calling the Script from Python:

python
import redis
import time
import math

class DistributedTokenBucket:
    SCRIPT_SHA = None  # Set after script load

    def __init__(self, redis_client):
        self.redis = redis_client
        self.script_sha = self._load_script()

    def _load_script(self):
        script = """
        local key = KEYS[1]
        local rate = tonumber(ARGV[1])
        local capacity = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])

        local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
        local tokens = tonumber(bucket[1]) or capacity
        local last_refill = tonumber(bucket[2]) or now

        local elapsed = now - last_refill
        tokens = math.min(capacity, tokens + elapsed * rate)

        local allowed = tokens >= 1
        if allowed then
            tokens = tokens - 1
        end

        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, 60)

        return {allowed and 1 or 0, tokens, capacity}
        """
        return self.redis.script_load(script)

    def allow_request(self, client_id):
        key = f"ratelimit:client:{client_id}"
        now = int(time.time())
        rate = 25
        capacity = 50

        result = self.redis.evalsha(
            self.script_sha,
            1,
            key,
            rate,
            capacity,
            now
        )

        allowed = result[0] == 1
        tokens_remaining = result[1]

        if not allowed:
            # Calculate Retry-After
            tokens_needed = 1
            tokens_available = tokens_remaining
            tokens_deficit = tokens_needed - tokens_available
            retry_after = math.ceil(tokens_deficit / rate)
            return False, retry_after, tokens_remaining
        return True, 0, tokens_remaining

Latency:

  • Redis round-trip (same AZ): 1.2ms (p95).

  • Lua script execution: 0.1ms.

  • Total: 1.3ms.


PART 3: THE BURST ALLOWANCE — The Dual-Bucket Strategy

The CMA requires a 25 RPS average rate, but allows a burst of 50 RPS for up to 10 seconds. This is achieved by setting the bucket capacity C = 50. The bucket refills at 25 tokens per second. If the bucket is full (50 tokens), a client can send 50 requests in the first second, consuming all tokens. In the next second, only 25 tokens have refilled, so the client can only send 25 requests.

The Burst Duration:
The burst lasts exactly C / r seconds after the bucket is full:
T_burst = 50 / 25 = 2 seconds.

After 2 seconds, the client is limited to 25 RPS (the refill rate).

The 10-Second Window:
The OBIE requirement of “burst of 50 RPS for 10 seconds” is actually a separate requirement: the client must not exceed 25 RPS on average over a 10-second window. The token bucket with C = 50 enforces this naturally.

Proof:
Over a 10-second window, the maximum number of tokens available is C + r × T = 50 + 25 × 10 = 300 tokens. The client can send at most 300 requests in 10 seconds, which is exactly 30 RPS average—slightly above the 25 RPS average. However, the CMA’s 10-second window is a peak requirement, not an average requirement. The token bucket with C = 50 allows a peak of 50 RPS for 2 seconds, and then throttles to 25 RPS.


PART 4: THE 429 TOO MANY REQUESTS RESPONSE

When the rate limiter denies a request, it returns an HTTP 429 Too Many Requests status code with a Retry-After header.

The Response:

text
HTTP/1.1 429 Too Many Requests
Retry-After: 3
Content-Type: application/json

{
  "ErrorCode": "RATE_LIMIT_EXCEEDED",
  "ErrorDescription": "You have exceeded the 25 RPS limit. Please retry after 3 seconds.",
  "RetryAfter": 3,
  "Limit": 25,
  "Remaining": 12,
  "Reset": 1691234567
}

Calculating Retry-After:
Retry-After = ceil((1 - tokens) / rate) (in seconds).

Example:

  • tokens = 0.2 (only 0.2 tokens available).

  • rate = 25.

  • Retry-After = ceil((1 - 0.2) / 25) = ceil(0.8 / 25) = ceil(0.032) = 1 second.

The Reset Header:
The Reset header contains the timestamp (Unix epoch) when the bucket will be full again. The TPP can use this to schedule its next request.


PART 5: THE OPTIMAL REDIS KEY EXPIRY — Nyquist Sampling

We must set a TTL on the Redis key to prevent memory exhaustion from stale keys. The optimal TTL is derived from the Nyquist sampling theorem.

Nyquist Theorem:
To accurately reconstruct a signal, the sampling frequency must be at least twice the highest frequency component. For rate limiting, the highest frequency is the refill rate (25 Hz). The Nyquist sampling interval is 1 / (2 × 25) = 0.02 seconds. However, we are not “sampling”; we are storing state. A TTL of 60 seconds is far above the Nyquist minimum, ensuring that the key persists longer than any burst window.

Memory Footprint:

  • Each key stores tokens (float) and last_refill (integer) → ~100 bytes.

  • With 10,000 TPPs, the memory footprint is 10,000 × 100 = 1 MB.

  • With 1 million TPPs, the footprint is 1,000,000 × 100 = 100 MB.

  • Redis can easily handle 100 MB.

Optimal TTL60 seconds (1 minute). This balances memory usage against accuracy. After 60 seconds of inactivity, the key is removed.


PART 6: THE THUNDERING HERD PREVENTION

When a large number of TPPs exceed the rate limit simultaneously, they all retry at exactly the same time (the thundering herd). This can cause a secondary spike in traffic.

The Thundering Herd Probability:
Assume 100 TPPs all exceed the rate limit at t = 0. They all receive a Retry-After = 1 second. At t = 1, they all retry simultaneously, causing a spike of 100 RPS (which may trigger the rate limiter again).

Solution: Jittered Retry:
The ASPSP returns a Retry-After header with a jitter:
Retry-After = ceil((1 - tokens) / rate) + random(0, 1) (in seconds).

Example:

  • Token deficit: 0.8 tokens.

  • Base Retry-After = 1 second.

  • Jitter: random(0, 1) → 0.3 seconds.

  • Actual Retry-After = 1.3 seconds.

The Jitter Distribution:
The jitter spreads the retries uniformly over a 1-second window, reducing the peak load by 1 / (1 + jitter_range). With a jitter range of 1 second, the peak load is reduced by 50%.


CLOSING — THE RATE LIMITING ENGINE

The distributed token bucket algorithm enforces the 25 RPS average rate while allowing a burst of 50 RPS. The Redis Lua script ensures atomicity and consistency across multiple gateway nodes. The 429 response with Retry-After guides the TPP to retry at the appropriate time, and the jitter prevents thundering herds.

Operational Risk: If Redis fails, the rate limiter falls back to an in-memory, local rate limiter (which is less accurate but protects against complete failure). The circuit breaker (Lesson 7.3) detects Redis failures and switches to the fallback.

Key Takeaways:

  • Rate: 25 RPS average, 50 RPS burst.

  • Capacity: 50 tokens.

  • Redis Latency: 1.3ms (p95).

  • TTL: 60 seconds.

  • Retry-Afterceil((1 - tokens) / rate) seconds.

  • Jitterrandom(0, 1) second to prevent thundering herds.

Transition to Lesson 7.3: With rate limiting in place, we now turn to Circuit Breakers and Resilience Patterns—how to protect the ASPSP’s backend from cascading failures when a downstream service (e.g., the payment processing engine, the clearing house) becomes slow or unresponsive. We will derive the optimal failure threshold using the Beta distribution, implement the exponential backoff with jitter, and quantify the latency impact of the circuit breaker.