#Rate limiting
alepha/rate-limit counts hits per key in fixed windows and refuses past a limit.
It is not tied to HTTP: a limiter is a class field you call from an action, a job,
a login flow or anything else. Counters are actor state,
so they live wherever the app's actors live. Declaring a $rateLimit registers
the AlephaRateLimit module (and the actor module it counts through): there is
nothing to alepha.with().
1import { $rateLimit } from "alepha/rate-limit"; 2 3class Downloads { 4 perUser = $rateLimit({ 5 name: "downloads:user", 6 limit: 100, 7 window: [1, "hours"], 8 }); 9 10 async download(userId: string) {11 const result = await this.perUser.consume(userId);12 if (!result.allowed) return { retryAfterMs: result.retryAfterMs };13 // ... serve the file14 return {};15 }16}
#Consume and refund
consume(input) counts one hit and resolves a result:
| Result | When |
|---|---|
{ allowed: true, count, limit, remaining, resetAt, ticket } |
counted |
{ allowed: false, reason: "limit", count, limit, remaining: 0, resetAt, retryAfterMs } |
the window is full |
{ allowed: false, reason: "store" | "contention", limit } |
the store failed, under deny (default) |
{ allowed: true, reason: "store" | "contention", limit } |
the store failed, under allow |
Only a counted result has a ticket. refund(ticket) gives that hit back, in the
window it was counted in: once the window has rolled, a refund does nothing. Refund
the hits that should not count, for example a login that succeeded:
1import { $rateLimit } from "alepha/rate-limit"; 2 3interface Attempt { 4 realm: string; 5 userId: string; 6 max: number; 7 windowMs: number; 8} 9 10class Login {11 account = $rateLimit({12 name: "login:account",13 limit: (input: Attempt) => input.max,14 window: (input: Attempt) => input.windowMs,15 key: (input: Attempt) => `${input.realm}:${input.userId}`,16 });17 18 async verify(attempt: Attempt, check: () => Promise<boolean>) {19 const result = await this.account.consume(attempt);20 if (!result.allowed) return false;21 const ok = await check();22 if (ok && result.ticket) await this.account.refund(result.ticket);23 return ok;24 }25}
refund never throws. A refund that fails is logged: it leaves the limiter stricter,
never weaker.
#Options
| Option | Type | Default | Meaning |
|---|---|---|---|
name |
string |
required | Unique per app, the counters' namespace. A second limiter with the same name throws at declaration, since it would share counters silently. |
limit |
number or (input) => number |
required | Hits per window. Must be a nonnegative integer. |
window |
duration or (input) => duration |
required | Window length: 60_000 (ms) or [15, "minutes"]. At least 1 ms. |
key |
(input) => string |
the input | The counter key within the limiter. Without it, the input must be a nonempty string. |
onStoreError |
"deny" or "allow" |
"deny" |
What to do when the counter store fails. See Fail closed. |
onContention |
"deny" or "allow" |
"deny" |
What to do when Redis compare-and-set gives up on a hot key. |
algorithm |
"fixed" |
"fixed" |
The only one built. |
limit, window and key are synchronous. Load anything asynchronous, such as
realm settings, before calling consume and pass it in the input.
A misconfiguration is a bug, not an outage, so it throws instead of resolving a
result: a non-string input without key, an empty key, a negative or fractional
limit, a window under 1 ms.
#As a middleware
middleware() turns a string-keyed limiter into a use: [...] entry for
$action, $job or $pipeline, counting one hit per call:
1import { $rateLimit } from "alepha/rate-limit"; 2import { $action } from "alepha/server"; 3 4class Api { 5 search = $rateLimit({ name: "api:search", limit: 60, window: 60_000 }); 6 7 find = $action({ 8 method: "GET", 9 path: "/find",10 use: [this.search.middleware()],11 handler: async () => "ok",12 });13}
Declare the limiter as a field above the actions that use it. The key is
key(...args) when the middleware options give one, else ip:<request.ip>
inside an HTTP request (ip:unknown when the IP is empty), else ip:global
(one bucket for every call, as for a $job). On an $action, args[0] is the
handler's request object, so a middleware placed after $secure() can key on
the user:
1import { z } from "alepha"; 2import { $rateLimit } from "alepha/rate-limit"; 3import { $secure } from "alepha/security"; 4import { $action } from "alepha/server"; 5 6class Exports { 7 perUser = $rateLimit({ 8 name: "exports:user", 9 limit: 10,10 window: [1, "hours"],11 });12 13 create = $action({14 method: "POST",15 path: "/exports",16 use: [17 $secure(),18 this.perUser.middleware({19 key: (request: { user: { id: string } }) => `user:${request.user.id}`,20 }),21 ],22 schema: { response: z.object({ ok: z.boolean() }) },23 handler: async () => ({ ok: true }),24 });25}
An allowed call sets X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset (epoch seconds). A full window or contention throws a 429
with Retry-After (seconds, at least 1); a store failure throws a 503 with no
limit headers, since an outage is not a limit the client exceeded. On an action
that declares its errors, the middleware's
429 is added to the documented responses, so it appears in the
OpenAPI document.
skipFailedRequests refunds the hit when the handler throws (anything but an
HttpError below 400); skipSuccessfulRequests refunds it when the handler
succeeds, so only failures count, as on a login endpoint.
#Fixed windows
A window opens on the key's first hit and lasts window; the next hit after it
opens a new one. Past the limit the counter saturates: the first refused hit marks
the window full, and every later refusal leaves the state unchanged, so it writes
nothing. A flood against a locked key costs reads, not writes. A limit lowered
mid-window applies on the next hit. A refund after saturation brings the count back
under the limit, so one more hit is allowed.
Each window expires with its counter: the state carries resetAt as its actor
expiry, and every provider reclaims it (see Expiry).
#Fail closed
A counter store can fail: a Redis outage, a Durable Object RPC error, a corrupt
value. By default the limiter refuses (deny), and consume resolves rather than
rejects, with reason: "store". A caller that should answer an outage differently
from a full window reads reason, for example to answer 503 instead of 429.
ActorContentionError (Redis compare-and-set exhausted on a hot key) is
reason: "contention" and is also denied by default. Pass "allow" when an
unthrottled call is better than a refused one, such as an update check from a
device. An allowed result under allow has no ticket: nothing was counted.
#Events and audits
Every refusal emits rateLimit:consume:denied with { name, key, reason }. There
is no "threshold crossed" event: whether a hit crossed depends on whether the caller
compares before or after counting. Write lockout audits yourself from count, for
example when a failed attempt's ticket has count === limit.
#Topology
The limiter has no store of its own: it counts through $actor, so the actor
provider decides who shares a counter.
- Memory (Node and Bun without
REDIS_URL) counts per process. It is right for one instance. - Redis (Node and Bun with
REDIS_URLset) shares counters across instances, with nothing to register. - Durable Objects on workerd, one per counter key, through the
ALEPHA_ACTORbinding the build generates.
No REDIS_URL means one instance. Nothing falls back to SQL for counters: an app
that scales out without it lets each instance count on its own, so a limit of five
becomes five per instance. An evicted counter resets its limit, so run that Redis
with noeviction (one Redis for every role).
See the topology rule.