Technical · 3 pages · Document style: Modern

Design: Rate Limiting for the Export API

An engineering design document with code in two languages, a formula, tables and a rollout plan.

  • Code
  • Formula
  • Tables
Design: Rate Limiting for the Export API — 1Design: Rate Limiting for the Export API — 2Design: Rate Limiting for the Export API — 3

Download the source, unzip it and drop the folder onto the converter — then change the text to make it yours.

Open the converter
design-doc.mdMarkdown source
---
title: "Design: Rate Limiting for the Export API"
subtitle: Engineering design document · RFC-0142
author: Platform Team
date: 2026-10-20
---

# Summary

The export API has no per-client limit today. A single client running a backfill can take most of the worker pool and slow everyone else down. This document proposes a **token-bucket rate limiter** at the gateway, with limits set per API key.

> [!IMPORTANT]
> Status: **approved** for implementation in the November release.

# Goals and non-goals

**Goals**

- Keep the export API's p95 latency below 800 ms when one client sends ten times its usual load.
- Give every client a clear, documented limit and a standard `429` response with `Retry-After`.

**Non-goals**

- Billing based on usage — handled separately in RFC-0137.
- Limits inside the export workers themselves.

# Design

Each API key has a bucket of capacity $C$ that refills at $r$ tokens per second. A request costs one token; an export of $n$ pages costs $\lceil n / 50 \rceil$ tokens. The number of tokens available at time $t$ is

$$
B(t) = \min\bigl(C,\; B(t_0) + r\,(t - t_0)\bigr)
$$ {#eq:bucket}

A request is accepted when $B(t)$ in [@eq:bucket] is at least its cost.

| Plan       | Capacity $C$ | Refill $r$ (tokens/s) | Burst allowed |
|------------|-------------:|----------------------:|---------------|
| Free       | 20           | 0.5                   | 40 s of quota |
| Team       | 200          | 5                     | 40 s of quota |
| Enterprise | 2,000        | 50                    | 40 s of quota |

: Default limits per plan {#tbl:limits}

The gateway keeps the buckets in Redis and updates them atomically:

```lua
-- KEYS[1] = bucket key, ARGV = capacity, rate, now (s), cost
local c, r, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local b = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = math.min(c, (tonumber(b[1]) or c) + r * (now - (tonumber(b[2]) or now)))
if tokens < cost then return {0, math.ceil((cost - tokens) / r)} end
redis.call("HSET", KEYS[1], "tokens", tokens - cost, "ts", now)
return {1, 0}
```

Limits are configured per plan ([@tbl:limits]) and can be raised for a single key:

```yaml
rate_limits:
  overrides:
    key_9f3a: { capacity: 5000, rate: 120 }   # nightly migration, until 2026-12-31
```

# Rollout

1. Ship in **shadow mode**: compute the decision, log it, never reject.
2. After one week, compare shadow rejections with support tickets.
3. Enforce for the Free plan, then Team and Enterprise one week apart.

# Alternatives considered

| Option                 | Why not                                                         |
|------------------------|-----------------------------------------------------------------|
| Fixed window counter   | Allows double the limit at window edges.                        |
| Limits in each worker  | Uneven across workers; a client could still flood the queue.    |
| Concurrency limit only | Does not protect against many short requests.                   |

← All examples · All names, companies and figures in these examples are made up.

Writing syntax

Open as a page

Loading…

Send feedback

Tell us what went wrong or what would make this better. We read every message.

Prefer another way? Email · GitHub