Examples

Rate-limited API

Atomic counting, honest Retry-After values, and limits that differ per key rather than per IP.

Declare the tiers

Limits belong to the key, not the IP. A shared office NAT punishes every user behind it, and an attacker rotates IPs freely.

yatta/func/limits.tsts
export interface RateLimitRule {  /** Window length. */  windowMs: number;  /** Requests permitted per window. */  max: number;} export const LIMITS = {  anon:    { windowMs: 60_000, max: 60 },  free:    { windowMs: 60_000, max: 300 },  pro:     { windowMs: 60_000, max: 3_000 },  admin:   { windowMs: 60_000, max: 30_000 },} satisfies Record<string, RateLimitRule>; export type Tier = keyof typeof LIMITS;

Count atomically

A read-then-write loses increments under concurrency: two requests both read 59, both write 60, and the limit holds at 60 while 61 requests were served. Increment inside the statement.

yatta/func/limits.tsts
import { db } from "./db"; export interface RateLimitResult {  allowed: boolean;  limit: number;  remaining: number;  /** Seconds until the window frees a slot. */  retryAfter: number;  resetAt: number;} export async function consume(  key: string,  rule: RateLimitRule,): Promise<RateLimitResult> {  const now = Date.now();  const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs;  const bucketId = `${key}:${windowStart}`;   // One statement: read, increment and write. No lost updates.  // One statement: upsert, increment and read back. There is no read-then-write  // window for two concurrent requests to both slip through.  const count = await increment(bucketId, rule.windowMs, now);   const allowed = count <= rule.max;   return {    allowed,    limit: rule.max,    remaining: Math.max(0, rule.max - count),    retryAfter: allowed ? 0 : Math.ceil((windowStart + rule.windowMs - now) / 1000),    resetAt: windowStart + rule.windowMs,  };} /** * INSERT ... ON CONFLICT DO UPDATE SET count = count + 1 * RETURNING count */async function increment(bucketId: string, windowMs: number, now: number): Promise<number> {  const db = getDb();  const row = db.run(    `INSERT INTO rate_buckets (id, count, expires_at)     VALUES (?, 1, ?)     ON CONFLICT(id) DO UPDATE SET count = count + 1     RETURNING count`,    [bucketId, now + windowMs * 2],    "get",  );  return Number(row?.count ?? 0);}
Warning
The counter has to increment in the same statement that reads it. A read-then-write loses increments under concurrency, and the limit silently admits more traffic than you configured.

Expire old buckets

A rate-limit table grows forever unless something removes it. Expire anything past its window on a cron.

yatta/func/cron.tsts
export const cron = createCron(jobs); cron.schedule("*/5 * * * *", async () => {  const purged = await db.rateBuckets    .where((f) => f.expiresAt.isLessThan(new Date()))    .delete();   if (purged > 0) {    observer.log.info("Purged rate-limit buckets", { purged });  }});

Enforce it

Always set RateLimit-* headers, including on success. A client that cannot see its budget cannot pace itself, and a client that only discovers the limit by being rejected will retry harder.

yatta/func/rate-limit.tsts
import type { Middleware } from "yatta/api";import { HttpError } from "yatta/api";import { auth } from "./auth";import { LIMITS, consume, type Tier } from "./limits"; /** Pick the strictest of every limit the request matches. */export function rateLimit(tierFor: (ctx: never) => Promise<Tier>): Middleware {  return async (ctx, next) => {    const tier = await tierFor(ctx as never);     // API key if present, else the session user, else the client address.    const identity =      ctx.header("authorization")?.replace(/^Bearer\s+/i, "") ??      (await auth.getUser(ctx.req))?.id ??      ctx.header("x-forwarded-for")?.split(",")[0]?.trim() ??      "anon";     const result = await consume(`${tier}:${identity}`, LIMITS[tier]);     if (!result.allowed) {      throw new HttpError(429, "Rate limit exceeded", {        headers: {          "Retry-After": String(result.retryAfter),          "X-RateLimit-Limit": String(result.limit),          "X-RateLimit-Remaining": "0",          "X-RateLimit-Reset": String(Math.floor(result.resetAt / 1000)),        },      });    }     const res = await next();     res.headers.set("X-RateLimit-Limit", String(result.limit));    res.headers.set("X-RateLimit-Remaining", String(result.remaining));    res.headers.set("X-RateLimit-Reset", String(Math.floor(result.resetAt / 1000)));     return res;  };} export const apiRateLimit = rateLimit(async (ctx: never) => {  const user = await auth.getUser((ctx as { req: Request }).req);  return (user?.roles?.includes("admin") ? "admin" : user ? "free" : "anon") as Tier;});

What the client sees

tsts
HTTP/1.1 200 OKX-RateLimit-Limit: 300X-RateLimit-Remaining: 271X-RateLimit-Reset: 1780000260 HTTP/1.1 429 Too Many RequestsRetry-After: 17X-RateLimit-Limit: 300X-RateLimit-Remaining: 0X-RateLimit-Reset: 1780000260
Tip
Put Retry-After on the rejection. Clients that retry immediately make a throttling event into an outage, and the header is the only signal telling them to wait.