system design

API Throttling System Low-Level Design: Rate Limits, Quota Management, and Adaptive Throttling

Throttling Tiers

Three tiers compose the throttling hierarchy:

  • The per-endpoint tier is a global rate limit on a specific endpoint regardless of caller. It protects expensive operations (e.g., report generation) from being called at arbitrary rates by any user.
  • The per-user tier gives each user a rate limit (requests per minute) derived from their plan, enforced independently per user.
  • The per-plan tier applies a monthly quota to each plan. Once exhausted, all requests are rejected with 429 until the quota resets on the billing anniversary date.

All three limits are checked on each request. The first limit breached determines the response.

Sliding Window Counter with Redis

Fixed-window counters have a boundary problem: a user can send double their rate limit by bursting at the end of one window and the start of the next. Sliding window counters eliminate this using a Redis sorted set:

-- Conceptual Redis operations for sliding window (1-minute window)
ZADD user:{user_id}:endpoint:{endpoint} {now_ms} {unique_request_id}
ZREMRANGEBYSCORE user:{user_id}:endpoint:{endpoint} 0 {now_ms - 60000}
count = ZCARD user:{user_id}:endpoint:{endpoint}
EXPIRE user:{user_id}:endpoint:{endpoint} 60

Each request adds a timestamped member to the sorted set, removes members older than the window, and counts remaining members. The count is the number of requests in the sliding window. This runs as a Lua script for atomicity.

Monthly Quota Management

Monthly quotas are too high-volume to track purely in Redis without periodic persistence. The pattern:

  • A Redis counter tracks usage within the current hour: INCR quota:{user_id}:{YYYY-MM-DD-HH}
  • A background job runs hourly, reads all hourly buckets, and flushes the sum to the UserQuota table in PostgreSQL.
  • On each request, read the DB-persisted count plus the current Redis hourly bucket for the total used.
  • If used_count >= monthly_quota, return 429 with a Retry-After pointing to the reset_at timestamp.

Adaptive Throttling Under Load

When server CPU exceeds a threshold (e.g., 80%), globally reduce rate limits by a scaling factor. This prevents a legitimate traffic surge from degrading the service for everyone while still serving as much as the infrastructure can handle:

effective_limit = plan_limit * adaptive_scale_factor()

The scale factor is read from a shared Redis key updated by a background health monitor. When CPU returns below threshold, the factor recovers linearly over 60 seconds to avoid oscillation.

429 Response Format

A 429 response must include enough information for clients to implement correct retry logic:

HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1713340800
Content-Type: application/json

{
  "error": "rate_limit_exceeded",
  "limit_type": "per_minute",
  "retry_after_seconds": 42
}

Retry-After is the integer number of seconds until the window resets. X-RateLimit-Reset is the Unix timestamp of the reset. Clients that respect these headers implement exponential backoff starting at Retry-After seconds.

Quota Reset on Billing Anniversary

Monthly quotas reset on the billing anniversary date, not on the calendar month. A user who subscribed on the 17th gets their quota reset on the 17th of each month. The reset_at column in UserQuota stores the exact UTC timestamp of the next reset. A scheduled job runs daily and updates reset_at for users whose anniversary falls on that day.

SQL Schema

CREATE TABLE plan (
    id                  SERIAL PRIMARY KEY,
    name                VARCHAR(50) NOT NULL UNIQUE,
    requests_per_minute INT NOT NULL,
    monthly_quota       INT NOT NULL,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE user_quota (
    user_id     INT NOT NULL,
    plan_id     INT NOT NULL REFERENCES plan(id),
    month       DATE NOT NULL,  -- first day of quota period (anniversary-based)
    used_count  BIGINT NOT NULL DEFAULT 0,
    reset_at    TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (user_id, month)
);
CREATE INDEX ON user_quota (reset_at) WHERE used_count > 0;

CREATE TABLE throttle_violation (
    id           BIGSERIAL PRIMARY KEY,
    user_id      INT NOT NULL,
    endpoint     VARCHAR(200) NOT NULL,
    violated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    limit_type   VARCHAR(30) NOT NULL,
    -- per_minute | per_endpoint | monthly_quota
    limit_value  INT,
    actual_count INT
);
CREATE INDEX ON throttle_violation (user_id, violated_at DESC);

Python Implementation

import time, redis, psycopg2

r = redis.Redis()

LUA_SLIDING_WINDOW = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local req_id = ARGV[3]
redis.call("ZADD", key, now, req_id)
redis.call("ZREMRANGEBYSCORE", key, 0, now - window * 1000)
local count = redis.call("ZCARD", key)
redis.call("EXPIRE", key, window + 1)
return count
"""
sliding_window_script = r.register_script(LUA_SLIDING_WINDOW)

def sliding_window_count(user_id: int, endpoint: str,
                          window_seconds: int = 60) -> int:
    key = f"user:{user_id}:ep:{endpoint}"
    now_ms = int(time.time() * 1000)
    req_id = f"{now_ms}-{id(object())}"
    return sliding_window_script(keys=[key],
                                  args=[now_ms, window_seconds, req_id])

def adaptive_scale_factor() -> float:
    """Return a factor in (0,1] based on current server CPU load."""
    cpu_pct = float(r.get("server:cpu_pct") or 0)
    if cpu_pct < 80:
        return 1.0
    # 80% CPU -> factor 1.0; 100% CPU -> factor 0.5
    return max(0.5, 1.0 - (cpu_pct - 80) / 40)

def check_throttle(user_id: int, endpoint: str, plan: dict) -> dict:
    """
    Returns {"allowed": True} or {"allowed": False, "retry_after": N, "limit_type": ...}
    """
    # 1. Monthly quota check
    used = get_monthly_used(user_id)  # DB + Redis hourly bucket
    if used >= plan["monthly_quota"]:
        reset_at = get_quota_reset_at(user_id)
        retry_after = max(0, int(reset_at - time.time()))
        return {"allowed": False, "retry_after": retry_after,
                "limit_type": "monthly_quota"}

    # 2. Per-minute sliding window check
    effective_limit = int(plan["requests_per_minute"] * adaptive_scale_factor())
    count = sliding_window_count(user_id, endpoint, window_seconds=60)
    if count > effective_limit:
        # time until oldest entry in window expires
        retry_after = 60 - int((int(time.time() * 1000) -
                                  get_oldest_entry_ms(user_id, endpoint)) / 1000)
        record_violation(user_id, endpoint, "per_minute",
                         effective_limit, count)
        return {"allowed": False, "retry_after": max(1, retry_after),
                "limit_type": "per_minute"}

    # 3. Increment monthly counter (Redis hourly bucket)
    r.incr(f"quota:{user_id}:{time.strftime('%Y-%m-%d-%H')}")
    return {"allowed": True, "remaining": effective_limit - count}

Sliding Window vs Fixed Window

Fixed windows reset at fixed clock boundaries (e.g., every minute on the minute). A user can exploit this by sending their full quota in the last second of one window and the first second of the next, doubling their effective burst rate. Sliding windows track the true request count in the past N seconds regardless of clock boundaries, preventing this exploit at the cost of higher Redis memory usage (one sorted set entry per request vs one counter per window).

See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

newsletter

What's actually being asked right now

Interview patterns & comp trends, straight to your inbox.

No spam. Unsubscribe anytime.

1972 Soviet postage stamp commemorating the Mars 2 probe

worth a read

Mars For The Rest of Us — a weekly-or-more deep dive on the technical side of Mars exploration: rocket propulsion, microbiology, mission architecture, and everything in between. Written by Maciej Ceglowski.

Read it on Substack →
Scroll to Top