chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
<!-- 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>
|
||||
Reference in New Issue
Block a user