383 lines
12 KiB
HTML
383 lines
12 KiB
HTML
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>How rate limiting works in acme/api</title>
|
||
<style>
|
||
:root {
|
||
--ivory: #FAF9F5;
|
||
--slate: #141413;
|
||
--clay: #D97757;
|
||
--oat: #E3DACC;
|
||
--olive: #788C5D;
|
||
--gray-150:#F0EEE6;
|
||
--gray-300:#D1CFC5;
|
||
--gray-500:#87867F;
|
||
--gray-700:#3D3D3A;
|
||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
background: var(--ivory);
|
||
color: var(--gray-700);
|
||
font-family: var(--sans);
|
||
font-size: 15px;
|
||
line-height: 1.65;
|
||
-webkit-font-smoothing: antialiased;
|
||
padding: 56px 24px 120px;
|
||
}
|
||
.page {
|
||
max-width: 1100px;
|
||
margin: 0 auto;
|
||
display: grid;
|
||
grid-template-columns: 200px minmax(0, 1fr);
|
||
gap: 48px;
|
||
}
|
||
@media (max-width: 920px) { .page { grid-template-columns: 1fr; } nav { display: none; } }
|
||
|
||
/* ── nav ──────────────────────────────── */
|
||
nav {
|
||
position: sticky;
|
||
top: 32px;
|
||
align-self: start;
|
||
font-size: 13px;
|
||
}
|
||
nav .label {
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--gray-500);
|
||
margin-bottom: 12px;
|
||
}
|
||
nav a {
|
||
display: block;
|
||
padding: 5px 0 5px 12px;
|
||
border-left: 2px solid var(--gray-300);
|
||
color: var(--gray-700);
|
||
text-decoration: none;
|
||
}
|
||
nav a:hover { color: var(--slate); border-color: var(--slate); }
|
||
nav a.l2 { padding-left: 24px; font-size: 12.5px; color: var(--gray-500); }
|
||
nav .files {
|
||
margin-top: 28px;
|
||
border-top: 1px solid var(--gray-300);
|
||
padding-top: 16px;
|
||
}
|
||
nav .files code {
|
||
display: block;
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
color: var(--gray-500);
|
||
padding: 3px 0;
|
||
}
|
||
|
||
/* ── header ───────────────────────────── */
|
||
header { margin-bottom: 12px; }
|
||
.eyebrow {
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--gray-500);
|
||
margin-bottom: 10px;
|
||
}
|
||
h1 {
|
||
font-family: var(--serif);
|
||
font-weight: 500;
|
||
font-size: 32px;
|
||
color: var(--slate);
|
||
letter-spacing: -0.01em;
|
||
margin-bottom: 14px;
|
||
}
|
||
.tldr {
|
||
border: 1.5px solid var(--gray-300);
|
||
border-left: 3px solid var(--clay);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
padding: 16px 18px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.tldr b { color: var(--slate); }
|
||
|
||
h2 {
|
||
font-family: var(--serif);
|
||
font-weight: 500;
|
||
font-size: 22px;
|
||
color: var(--slate);
|
||
margin: 40px 0 14px;
|
||
scroll-margin-top: 24px;
|
||
}
|
||
p { margin-bottom: 12px; max-width: 680px; }
|
||
code { font-family: var(--mono); font-size: 13px; }
|
||
|
||
/* ── collapsible ─────────────────────── */
|
||
details {
|
||
border: 1.5px solid var(--gray-300);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
margin: 14px 0;
|
||
overflow: hidden;
|
||
}
|
||
summary {
|
||
list-style: none;
|
||
cursor: pointer;
|
||
padding: 14px 16px;
|
||
font-family: var(--serif);
|
||
font-size: 16px;
|
||
color: var(--slate);
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
}
|
||
summary::-webkit-details-marker { display: none; }
|
||
summary::before {
|
||
content: "▸";
|
||
color: var(--clay);
|
||
font-family: var(--sans);
|
||
font-size: 12px;
|
||
transition: transform 120ms;
|
||
}
|
||
details[open] summary::before { transform: rotate(90deg); }
|
||
summary .where {
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
color: var(--gray-500);
|
||
margin-left: auto;
|
||
}
|
||
details .body { padding: 0 16px 16px; }
|
||
details .body p { font-size: 14px; }
|
||
|
||
/* ── tabs ─────────────────────────────── */
|
||
.tabs {
|
||
border: 1.5px solid var(--gray-300);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
margin: 16px 0 8px;
|
||
overflow: hidden;
|
||
}
|
||
.tabbar {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--gray-300);
|
||
background: var(--gray-150);
|
||
}
|
||
.tabbar button {
|
||
appearance: none;
|
||
border: none;
|
||
background: none;
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--gray-500);
|
||
padding: 10px 16px;
|
||
cursor: pointer;
|
||
border-right: 1px solid var(--gray-300);
|
||
}
|
||
.tabbar button.on {
|
||
background: #fff;
|
||
color: var(--slate);
|
||
border-bottom: 2px solid var(--clay);
|
||
margin-bottom: -1px;
|
||
}
|
||
.tabs pre {
|
||
display: none;
|
||
margin: 0;
|
||
padding: 16px 18px;
|
||
font-family: var(--mono);
|
||
font-size: 12.5px;
|
||
line-height: 1.6;
|
||
color: var(--slate);
|
||
overflow-x: auto;
|
||
}
|
||
.tabs pre.on { display: block; }
|
||
.hl { color: var(--clay); }
|
||
.cm { color: var(--gray-500); }
|
||
|
||
/* ── callout ──────────────────────────── */
|
||
.callout {
|
||
display: flex;
|
||
gap: 12px;
|
||
border: 1.5px solid var(--oat);
|
||
background: rgba(227,218,204,0.35);
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
margin: 18px 0;
|
||
font-size: 14px;
|
||
}
|
||
.callout .ico { color: var(--clay); font-weight: 600; }
|
||
|
||
/* ── faq ──────────────────────────────── */
|
||
dl.faq { margin-top: 8px; }
|
||
dl.faq dt {
|
||
font-family: var(--serif);
|
||
font-size: 16px;
|
||
color: var(--slate);
|
||
margin-top: 18px;
|
||
}
|
||
dl.faq dd { font-size: 14px; margin: 4px 0 0; max-width: 640px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
|
||
<!-- ── side nav ── -->
|
||
<nav>
|
||
<div class="label">On this page</div>
|
||
<a href="#tldr">TL;DR</a>
|
||
<a href="#path">Request path</a>
|
||
<a href="#path" class="l2">1. Identify</a>
|
||
<a href="#path" class="l2">2. Bucket lookup</a>
|
||
<a href="#path" class="l2">3. Consume</a>
|
||
<a href="#path" class="l2">4. Reject</a>
|
||
<a href="#config">Configuring a route</a>
|
||
<a href="#gotchas">Gotchas</a>
|
||
<a href="#faq">FAQ</a>
|
||
<div class="files">
|
||
<div class="label">Files read</div>
|
||
<code>middleware/ratelimit.ts</code>
|
||
<code>lib/tokenBucket.ts</code>
|
||
<code>config/limits.yaml</code>
|
||
<code>routes/*.ts</code>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- ── main ── -->
|
||
<main>
|
||
<header>
|
||
<div class="eyebrow">Research & Learning · feature summary</div>
|
||
<h1>How rate limiting works in <code>acme/api</code></h1>
|
||
<div class="tldr" id="tldr">
|
||
<b>TL;DR</b> — Every request passes through <code>rateLimit()</code> middleware, which resolves the
|
||
caller to a <em>bucket key</em>, fetches a token-bucket from Redis, and either consumes one token or
|
||
returns <code>429</code>. Limits are declared per-route in <code>config/limits.yaml</code>; routes
|
||
without an entry inherit the <code>default</code> tier (100 req/min per API key).
|
||
</div>
|
||
</header>
|
||
|
||
<h2 id="path">The request path, step by step</h2>
|
||
<p>Expand each step to see what runs and where it lives. The whole path is ~40 lines and adds about
|
||
0.4 ms p50 to every request.</p>
|
||
|
||
<details open>
|
||
<summary>1 · Identify the caller <span class="where">middleware/ratelimit.ts:21</span></summary>
|
||
<div class="body">
|
||
<p>The middleware first reduces the request to a <code>bucketKey</code>: API key if an
|
||
<code>Authorization</code> header is present, otherwise the client IP (via the
|
||
<code>x-forwarded-for</code> chain, trusting only our own LB). Anonymous IP traffic gets a much
|
||
lower default tier.</p>
|
||
</div>
|
||
</details>
|
||
|
||
<details>
|
||
<summary>2 · Look up the bucket <span class="where">lib/tokenBucket.ts:9</span></summary>
|
||
<div class="body">
|
||
<p>The route name plus bucket key map to a Redis hash (<code>rl:{route}:{key}</code>) holding
|
||
<code>tokens</code> and <code>updatedAt</code>. If the key is missing it's created lazily at full
|
||
capacity — there's no warm-up.</p>
|
||
</div>
|
||
</details>
|
||
|
||
<details>
|
||
<summary>3 · Refill and consume <span class="where">lib/tokenBucket.ts:31</span></summary>
|
||
<div class="body">
|
||
<p>Refill is computed from elapsed time (<code>rate × Δt</code>, capped at <code>burst</code>), then
|
||
one token is subtracted. The whole read-modify-write runs as a single Lua script so concurrent
|
||
requests can't double-spend.</p>
|
||
</div>
|
||
</details>
|
||
|
||
<details>
|
||
<summary>4 · Reject when empty <span class="where">middleware/ratelimit.ts:48</span></summary>
|
||
<div class="body">
|
||
<p>If the script returns <code>tokens < 0</code> the middleware short-circuits with
|
||
<code>429 Too Many Requests</code> and sets <code>Retry-After</code> to the seconds until one token
|
||
refills. Successful responses always carry <code>X-RateLimit-Remaining</code>.</p>
|
||
</div>
|
||
</details>
|
||
|
||
<h2 id="config">Configuring a limit on your route</h2>
|
||
<p>You don't touch the middleware. Add an entry to <code>config/limits.yaml</code> keyed by route name,
|
||
and (optionally) tag the route so the middleware can find it.</p>
|
||
|
||
<div class="tabs" data-tabs>
|
||
<div class="tabbar">
|
||
<button class="on" data-t="0">limits.yaml</button>
|
||
<button data-t="1">route.ts</button>
|
||
<button data-t="2">client response</button>
|
||
</div>
|
||
<pre class="on"><span class="cm"># config/limits.yaml</span>
|
||
default:
|
||
rate: 100/min
|
||
burst: 120
|
||
|
||
<span class="hl">search.query</span>:
|
||
rate: 20/min
|
||
burst: 40
|
||
key: api_key <span class="cm"># or: ip</span></pre>
|
||
<pre><span class="cm">// routes/search.ts</span>
|
||
router.post(
|
||
"/search",
|
||
<span class="hl">rateLimit("search.query")</span>,
|
||
handler,
|
||
);</pre>
|
||
<pre>HTTP/1.1 429 Too Many Requests
|
||
Retry-After: 17
|
||
X-RateLimit-Limit: 20
|
||
X-RateLimit-Remaining: 0
|
||
|
||
{ "error": "rate_limited", "retry_after": 17 }</pre>
|
||
</div>
|
||
|
||
<div class="callout">
|
||
<span class="ico">★</span>
|
||
<div>If you only need the default tier, you don't need a YAML entry at all — just wrap the handler in
|
||
<code>rateLimit()</code> with no argument. The route name is inferred from the path.</div>
|
||
</div>
|
||
|
||
<h2 id="gotchas">Gotchas worth knowing</h2>
|
||
<ul style="padding-left:20px; max-width:680px;">
|
||
<li style="margin-bottom:8px;"><b>Limits are per-process in dev.</b> The Redis client falls back to an
|
||
in-memory map when <code>REDIS_URL</code> is unset, so local testing won't reflect real cluster
|
||
behaviour.</li>
|
||
<li style="margin-bottom:8px;"><b>Burst ≠ rate.</b> <code>burst</code> is the bucket capacity; a caller
|
||
idle for a minute can fire <code>burst</code> requests instantly even if <code>rate</code> is low.</li>
|
||
<li><b>Streaming responses count once.</b> The token is consumed at request start; a 30-second SSE
|
||
stream still costs one token.</li>
|
||
</ul>
|
||
|
||
<h2 id="faq">FAQ</h2>
|
||
<dl class="faq">
|
||
<dt>How do I exempt internal traffic?</dt>
|
||
<dd>Set <code>x-acme-internal: 1</code> from the caller; the middleware checks it against the
|
||
mTLS peer name and skips the bucket entirely.</dd>
|
||
|
||
<dt>Where do I see who's getting limited?</dt>
|
||
<dd>Every <code>429</code> emits a <code>ratelimit.rejected</code> metric tagged with route and key
|
||
type. There's a Grafana panel under <em>API → Health</em>.</dd>
|
||
|
||
<dt>Can a single user have a higher limit?</dt>
|
||
<dd>Yes — add their API key under <code>overrides:</code> in the YAML. Overrides are reloaded without
|
||
a deploy.</dd>
|
||
</dl>
|
||
</main>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
document.querySelectorAll("[data-tabs]").forEach(box => {
|
||
const btns = box.querySelectorAll("button");
|
||
const panes = box.querySelectorAll("pre");
|
||
btns.forEach(b => b.addEventListener("click", () => {
|
||
btns.forEach(x => x.classList.remove("on"));
|
||
panes.forEach(x => x.classList.remove("on"));
|
||
b.classList.add("on");
|
||
panes[+b.dataset.t].classList.add("on");
|
||
}));
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|