# 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](https://latest.alepha.dev/docs/guides-persistence-actors.md),
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
import { $rateLimit } from "alepha/rate-limit";

class Downloads {
  perUser = $rateLimit({
    name: "downloads:user",
    limit: 100,
    window: [1, "hours"],
  });

  async download(userId: string) {
    const result = await this.perUser.consume(userId);
    if (!result.allowed) return { retryAfterMs: result.retryAfterMs };
    // ... serve the file
    return {};
  }
}
```

## 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
import { $rateLimit } from "alepha/rate-limit";

interface Attempt {
  realm: string;
  userId: string;
  max: number;
  windowMs: number;
}

class Login {
  account = $rateLimit({
    name: "login:account",
    limit: (input: Attempt) => input.max,
    window: (input: Attempt) => input.windowMs,
    key: (input: Attempt) => `${input.realm}:${input.userId}`,
  });

  async verify(attempt: Attempt, check: () => Promise<boolean>) {
    const result = await this.account.consume(attempt);
    if (!result.allowed) return false;
    const ok = await check();
    if (ok && result.ticket) await this.account.refund(result.ticket);
    return ok;
  }
}
```

`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](https://latest.alepha.dev/docs/guides-server-rate-limiting.md#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
import { $rateLimit } from "alepha/rate-limit";
import { $action } from "alepha/server";

class Api {
  search = $rateLimit({ name: "api:search", limit: 60, window: 60_000 });

  find = $action({
    method: "GET",
    path: "/find",
    use: [this.search.middleware()],
    handler: async () => "ok",
  });
}
```

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
import { z } from "alepha";
import { $rateLimit } from "alepha/rate-limit";
import { $secure } from "alepha/security";
import { $action } from "alepha/server";

class Exports {
  perUser = $rateLimit({
    name: "exports:user",
    limit: 10,
    window: [1, "hours"],
  });

  create = $action({
    method: "POST",
    path: "/exports",
    use: [
      $secure(),
      this.perUser.middleware({
        key: (request: { user: { id: string } }) => `user:${request.user.id}`,
      }),
    ],
    schema: { response: z.object({ ok: z.boolean() }) },
    handler: async () => ({ ok: true }),
  });
}
```

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`](https://latest.alepha.dev/docs/guides-server-typed-errors.md), the middleware's
`429` is added to the documented responses, so it appears in the
[OpenAPI](https://latest.alepha.dev/docs/guides-server-openapi.md) 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](https://latest.alepha.dev/docs/guides-persistence-actors.md#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](https://latest.alepha.dev/docs/guides-deployment-bare.md#one-redis-for-every-role)).
See [the topology rule](https://latest.alepha.dev/docs/guides-persistence-actors.md#topology-no-redis_url-means-one-instance).
