alepha@docs:~/framework/docs/guides/server$
cat 9-rate-limiting.md | pretty
6 min read
Last commit:

#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().

typescript
 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:

typescript
 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:

typescript
 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:

typescript
 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_URL set) shares counters across instances, with nothing to register.
  • Durable Objects on workerd, one per counter key, through the ALEPHA_ACTOR binding 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.