Rate limiting
Share capacity fairly, and tell clients exactly when to come back.
What it is
A rate limit caps how many requests one client can make in a time window, so one buggy script can't starve everyone else. Over the limit, the API answers 429 Too Many Requests with Retry-After: how many seconds to wait. Every response also carries the IETF RateLimit-Policy (the quota and window) and RateLimit (requests remaining, seconds until reset), so a well-behaved client can slow down before hitting the wall. This lab counts in Postgres with a sliding window: the current window's count plus the previous window's, weighted by how much of it still overlaps, which avoids the double burst a plain fixed window allows at its boundary.
When to use it
- Every public endpoint, especially writes and logins
- Expensive operations: search, exports, AI calls
- Giving paying or authenticated clients a bigger budget than anonymous ones
1. Burst: 5 requests per 10 seconds
The first 5 get through with the remaining count going down; the rest get 429 and a Retry-After. Wait 10 seconds and the budget comes back gradually, as the old window slides out.
2. One request, all the headers
Look at RateLimit-Policy and RateLimit.
Press Send to see the response.
The same limiter guards the real writes: creating, changing or deleting tasks is limited to 30 a minute per IP address, or 120 with an API key or token (keys are counted per key, not per IP). Creating keys is limited to 5 an hour, and exchanging them for tokens to 20 a minute. On Vercel the client IP comes from x-real-ip, which Vercel's network sets itself, so a client can't fake its way into a fresh budget.
Show code · src/api/ratelimit.ts
import type { Context, MiddlewareHandler } from "hono";
import { sql } from "drizzle-orm";
import type { AuthEnv } from "./auth";
import { db } from "./db/client";
import { problem } from "./problem";
// A sliding-window rate limiter on Postgres. Each client gets a counter per
// fixed window; the limit is checked against an estimate of the last full
// window's worth of requests: this window's count plus the previous window's,
// weighted by how much of it still overlaps. Smoother than a plain fixed
// window (no double burst at the boundary), and only one row per window.
type Limit = { name: string; limit: number | ((c: Context<AuthEnv>) => number); windowSec: number };
/** Who's asking: their API key or token if they have one, otherwise their IP. */
export function clientId(c: Context<AuthEnv>) {
const auth = c.get("auth");
if (auth) return `key:${auth.keyId}`;
// On Vercel, x-real-ip and x-forwarded-for are set by Vercel's network, not
// the client, so they can't be spoofed. Locally there's no proxy.
return `ip:${c.req.header("x-real-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "local"}`;
}
export async function hit(id: string, windowSec: number) {
const windowMs = windowSec * 1000;
const now = Date.now();
const start = Math.floor(now / windowMs) * windowMs;
const d = await db();
// One statement, so concurrent requests can't both read the old count.
const result = await d.execute(sql`
with cur as (
insert into rate_limits (key, window_start, count) values (${id}, ${start}, 1)
on conflict (key, window_start) do update set count = rate_limits.count + 1
returning count
)
select (select count from cur) as current,
coalesce((select count from rate_limits where key = ${id} and window_start = ${start - windowMs}), 0) as previous`);
const row = (result as unknown as { rows: { current: number; previous: number }[] }).rows[0];
// Now and then, drop counters nobody needs any more.
if (Math.random() < 0.02) await d.execute(sql`delete from rate_limits where window_start < ${now - 3_600_000}`);
const elapsed = (now - start) / windowMs;
const estimate = Number(row.current) + Number(row.previous) * (1 - elapsed);
return { estimate, resetSec: Math.max(1, Math.ceil((start + windowMs - now) / 1000)) };
}
export const rateLimit = ({ name, limit, windowSec }: Limit): MiddlewareHandler<AuthEnv> => async (c, next) => {
const max = typeof limit === "function" ? limit(c) : limit;
const { estimate, resetSec } = await hit(`${name}:${clientId(c)}`, windowSec);
// The IETF RateLimit headers: the policy (quota per window) and where this
// client stands (remaining, seconds until it resets).
c.header("RateLimit-Policy", `"${name}";q=${max};w=${windowSec}`);
c.header("RateLimit", `"${name}";r=${Math.max(0, Math.floor(max - estimate))};t=${resetSec}`);
if (estimate > max) {
c.header("Retry-After", String(resetSec));
return problem(c, 429, "rate-limited", { detail: `At most ${max} requests per ${windowSec} seconds. Try again in ${resetSec}s.` });
}
return next();
};
Show code · src/api/app.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { HTTPException } from "hono/http-exception";
import { type AuthEnv, authenticate } from "./auth";
import { problem } from "./problem";
import { rateLimit } from "./ratelimit";
import { authApi } from "./routes/auth";
import { limitsApi } from "./routes/limits";
import { tasksApi } from "./routes/tasks";
import { validationHook } from "./validation";
// The whole API: one Hono app under /api, mounted into Next.js by
// src/app/api/[[...route]]/route.ts. Hono only sees standard Request and
// Response objects, so the same app could run on Workers, Deno or Bun.
export const app = new OpenAPIHono<AuthEnv>({ defaultHook: validationHook }).basePath("/api");
// Every request: check credentials if any were sent (see auth.ts).
app.use("*", authenticate);
// Writes to the public tasks are rate limited per client: 30 a minute
// anonymously, 120 with an API key or token. (LAB_WRITE_LIMIT raises the
// anonymous limit for the test suites.)
// ("/tasks/*" also matches "/tasks" itself: listing both would count every
// write twice.)
app.on(["POST", "PATCH", "DELETE"], "/tasks/*", rateLimit({
name: "writes",
windowSec: 60,
limit: (c) => (c.get("auth") ? 120 : Number(process.env.LAB_WRITE_LIMIT ?? 30)),
}));
app.get("/health", (c) => c.json({ ok: true }));
app.route("/", tasksApi);
app.route("/", authApi);
app.route("/", limitsApi);
// The OpenAPI document is generated from the same Zod schemas that validate
// requests, so the docs can't drift from the code.
app.doc31("/openapi.json", {
openapi: "3.1.0",
info: {
title: "API Feature Lab",
version: "1.0.0",
description: "The API behind https://api.djmiskey.com: one page per technique, each calling these endpoints live.",
},
servers: [{ url: "https://api.djmiskey.com" }],
});
// Every failure leaves as Problem Details, even ones Hono raises itself.
app.notFound((c) => problem(c, 404, "not-found", { detail: `No route for ${c.req.method} ${new URL(c.req.url).pathname}.` }));
app.onError((err, c) => {
if (err instanceof HTTPException) {
// Hono's JSON validator throws 400 for a body that isn't valid JSON.
return problem(c, err.status === 400 ? 400 : 500, err.status === 400 ? "bad-request" : "internal", { detail: err.message });
}
console.error(err);
return problem(c, 500, "internal");
});