#$rateLimit
#Import
1import { $rateLimit } from "alepha/rate-limit";
#Overview
Declare a transport-agnostic, fail-closed rate limiter.
Counters are actor state (alepha/actor), so topology is the actor
provider's: Memory is one instance, Redis (once REDIS_URL is set) is
several, and a Durable Object on workerd. An app that runs several instances
without REDIS_URL counts per instance.
consume(input) counts one hit in a fixed window anchored on its first hit.
A counted result carries a ticket; refund(ticket) gives that hit back,
within the same window only, and never throws. A refused hit is not counted:
past the limit the counter saturates and a flood costs reads, not writes.
limit, window and key are numbers or synchronous functions of the
consume input: load anything asynchronous (realm settings) before consuming
and pass it in. A store error or ActorContentionError resolves a result
with reason: "store" | "contention", refused under deny (the default for
both) or allowed uncounted under allow. consume never rejects for them.
Every refusal emits rateLimit:consume:denied. Callers audit lockouts
themselves from count: whether a hit "crossed" depends on whether they
compare before or after counting.
middleware() puts a string-keyed limiter in front of an $action, $job
or $pipeline: 429 on the limit, 503 on a store error.
#Examples
1import { $rateLimit } from "alepha/rate-limit"; 2 3interface Attempt { 4 userId: string; 5 max: number; 6} 7 8class Auth { 9 account = $rateLimit({10 name: "login:account",11 limit: (input: Attempt) => input.max,12 window: [15, "minutes"],13 key: (input: Attempt) => input.userId,14 });15 16 async attempt(userId: string, ok: () => Promise<boolean>) {17 const result = await this.account.consume({ userId, max: 5 });18 if (!result.allowed) return false;19 if (!(await ok())) return false;20 if (result.ticket) await this.account.refund(result.ticket);21 return true;22 }23}