---
title: "Rate limits and quotas"
description: "Rate-limit requests, enforce quotas, run a job on one worker and cap concurrency, answering refusals as 429 problems with Retry-After."
section: "interfaces"
status: "Building for 0.3 · ships as a beta API"
spec: "limits"
canonical: "https://mainsdk.dev/docs/interfaces/limits"
updated: "2026-10-28"
---

# Rate limits and quotas

> Rate-limit requests, enforce quotas, run a job on one worker and cap concurrency, answering refusals as 429 problems with Retry-After.

Status: Building for 0.3 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/limits · Updated: 2026-10-28

`limits` stops abuse and overload with named policies in `main.yaml`: rates, quotas, locks and concurrency caps. A refused request becomes a standard 429 with `Retry-After`. It runs on your app's own store and is free.

## At a glance

- Status: Building for 0.3 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/limits.md
- Category: Security and privacy

Calls:

- `limits.rate(key, policy, { cost? })`
- `limits.enforce(policy, { key?, cost? })`
- `limits.quota(name, { key?, add? })`
- `limits.release(name, { key?, add })`
- `limits.lock(name, fn, { ttl?, wait? })`
- `limits.concurrency(name, fn, { key?, wait? })`
- `limits.headers(decision)`
- `new LimitError(code, message, retryAfter, headers, decision?)`
- `asKeyLimits(limiter)`
- `limitsIssues(raw)`
- `localLimits(o?)`
- `parseLimits(raw)`
- `new StoreHub(o)`
- `dbStore(o)`
- `localStore(o?)`
- `redisStore(o)`
- `limits.hubs()`
- `limits.info()`
- `limits.switchStore(down)`
- `limits.declaredRate(policy)`

In main.yaml:

```yaml
limits:
  driver: redis
  redis: { url: secret:REDIS_URL }
  policies:
    login: { max: 5, per: 15m, algorithm: token-bucket, key: ip, on-store-down: closed }
    export: { max: 5, per: 1m, key: user }
  quotas:
    exports-daily: { max: 50, per: day, key: user }
  concurrency:
    transcode: { max: 2, key: tenant, ttl: 10m }
```

Guarantees, whatever the driver:

- Every refusal is a 429 problem with Retry-After and the RateLimit headers
- Each policy declares what happens when the store is down; nothing fails by surprise
- Counters are per tenant and the store holds no raw keys or personal data
- A decision never waits longer than the store timeout


## Drivers

Pick one in main.yaml with `limits.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local (in process) | `local` | yes |  |
| Redis | `redis` |  |  |
| Arcjet | `arcjet` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-LIMIT-0006` | check | A policy, quota or concurrency name not declared under `limits` |
| `MAIN-LIMIT-0007` | check | A policy is invalid: `max` under 1, a bad `per`, an unknown algorithm or key, `burst` under `max`, or a `ttl` over 1 hour |
| `MAIN-LIMIT-0008` | check | The `local` limits driver in production without `single-instance: true` |
| `MAIN-LIMIT-0001` | runtime | The rate limit for this policy and key is reached (429, with `Retry-After`) |
| `MAIN-LIMIT-0002` | runtime | The quota for this window is used up (429, with `Retry-After`) |
| `MAIN-LIMIT-0003` | runtime | No concurrency slot came free within `wait` (429, with `Retry-After`) |
| `MAIN-LIMIT-0004` | runtime | The lock wasn't acquired within `wait` (409, retryable) |
| `MAIN-LIMIT-0005` | runtime | Refused because the store is down and the policy's `on-store-down` is `closed` (503, with `Retry-After`) |
| `MAIN-LIMIT-0009` | runtime | A lock or concurrency lease expired before the work finished; its signal was aborted (logged) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| limits@1 contract and named policies | 1 | 0.3 | `limits#contract` |
| Token bucket and sliding window rate limits | 1 | 0.3 | `limits#rate` |
| 429 with Retry-After and RateLimit headers | 1 | 0.3 | `limits#enforce` |
| Quotas per window | 1 | 0.3 | `limits#quota` |
| Locks and concurrency slots with leases | 1 | 0.3 | `limits#lock` |
| Redis driver with atomic decisions | 1 | 0.3 | `limits#redis` |
| Open, closed or local when the store is down | 1 | 0.3 | `limits#fail-policy` |
| Counters in the tenant's region | 1 | 0.3 | `limits#regions` |
| Per-key limits for apikeys | 1 | 0.3 | `apikeys#rate-audit` |
| Plan request rates | 1 | 0.3 | `entitlements#limits-flags` |
| IP allowlist per organisation | 1 | 0.3 |  |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
limits:
  driver: local
  single-instance: true
  policies:
    export: { max: 5, per: 1m }
```

TypeScript:

```ts
import { auth, limits } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  await limits.enforce("export", { key: session.user });   // throws MAIN-LIMIT-0001 (429) past 5 a minute
  return Response.json({ started: true });
}
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test_with("schema: 1\nlimits:\n  policies:\n    api: { max: 5, per: minute }\n")?;
Context::new().tenant("tnt_a").user("u1").scope(async {
let d = app.limits().rate("api", "client-1").await?;
assert!(d.allowed);
Ok::<_, MainError>(())
}).await?;
```


`limits.rate()` returns a `Decision` instead of throwing, for when you want to branch:

```ts
import { limits } from "mainsdk";

const d = await limits.rate("u_1", { per: "1m", max: 5 });   // an inline policy needs no main.yaml entry
if (!d.ok) console.log(`try again in ${d.retryAfter}s`);       // { ok, limit, remaining, resetAt, retryAfter?, policy, window }
```

## Configuration

```yaml title="main.yaml"
limits:
  driver: redis                    # local | redis; default: the runtime store's driver
  redis: { url: secret:REDIS_URL }
  timeout: 50ms                    # per store call; then on-store-down applies
  single-instance: false           # true says local is enough in production
  policies:
    login:  { max: 5, per: 15m, algorithm: token-bucket, on-store-down: closed }
    api:    { max: 600, per: 1m, algorithm: sliding-window, burst: 600 }
    export: { max: 5, per: 1m }
  quotas:
    exports-daily: { max: 50, per: day, on-store-down: open }
  concurrency:
    transcode: { max: 2, ttl: 10m }
```

| Key | What it does |
| --- | --- |
| `driver` | `local` keeps counters in process. `redis` shares them across instances. |
| `timeout` | How long a store call may take before `on-store-down` decides. |
| `single-instance` | Set `true` when one instance serves production, so `local` passes the check. |
| `policies.<name>` | A rate limit: `max` per `per` (a duration such as `15m`), `algorithm` (`token-bucket` default, or `sliding-window`), `burst` (token bucket only, at least `max`). |
| `quotas.<name>` | A ceiling per window: `max` per `per`, where `per` is `hour`, `day`, `month` (UTC calendar windows) or a rolling duration. |
| `concurrency.<name>` | At most `max` holders at once, each on a lease of `ttl` (default 30s, at most 1h). |
| `on-store-down` | What a policy does when the store does not answer: `open` allows, `closed` refuses with 503, `local` counts on this instance. Default `local` for rates, locks and concurrency, `open` for quotas. |
| `policies.<name>.key` | Planned to name what a call counts by (`ip`, `user`, `tenant`, `apikey`, `global`) when you pass no key. |

> **Warning:** Today a policy cannot declare `key`: the config loader treats any key named `key` as a secret field and refuses it with `MAIN-CONFIG-0004`. Pass the key on each call (`{ key: session.user }`) until that is fixed.

A name you have not declared throws `MAIN-LIMIT-0006`. An invalid policy stops the app from starting with `MAIN-LIMIT-0007`, naming every bad key.

## What you get

Named policies, quotas, locks and concurrency caps share one store. Keys are scoped to the tenant and hashed, every refusal is a 429 problem with `Retry-After`, and each policy declares what happens when the store is down. Reach for it on exports, invites, expensive endpoints and anything a bot would hammer.

## Examples

### Return a 429 with the RateLimit headers

`limits.enforce()` throws a `MAIN-LIMIT-0001` problem with `retryAfter` and the headers. To build the response yourself, use `limits.rate()` and `limits.headers()`.

```ts title="app/api/search/route.ts"
import { limits } from "mainsdk";

export async function GET(req: Request) {
  const d = await limits.rate(req.headers.get("x-forwarded-for") ?? "anon", "api");
  const headers = limits.headers(d);   // RateLimit-Policy and RateLimit, plus Retry-After when refused
  if (!d.ok) return new Response("Too many requests", { status: 429, headers });
  return new Response("ok", { headers });
}
```

A refused call does not use up capacity. Pass `{ cost: 3 }` to take three tokens for one call.

### Cap a person's exports per day

`limits.quota()` counts `add` (default 1) and throws `MAIN-LIMIT-0002` when the total would pass `max`, with `Retry-After` until the window ends. Give the amount back when the work fails.

```ts title="app/exports/actions.ts"
import { limits } from "mainsdk";

async function runExport(userId: string): Promise<string> {
  return `export-${userId}`;
}

export async function startExport(userId: string) {
  await limits.quota("exports-daily", { key: userId });
  try {
    return await runExport(userId);
  } catch (err) {
    await limits.release("exports-daily", { key: userId, add: 1 });   // never below 0
    throw err;
  }
}
```

### Run a job on one worker

```ts title="jobs/nightly.ts"
import { limits } from "mainsdk";

export async function nightlyReport() {
  await limits.lock("reports.nightly", async (signal) => {
    // one worker at a time; `signal` aborts if the lease is lost
  }, { ttl: "10m", wait: "5s" });   // wait for the holder; without `wait`, the second caller throws MAIN-LIMIT-0004
}
```

### Limit work in flight per tenant

```ts title="jobs/transcode.ts"
import { limits } from "mainsdk";

export async function transcode(tenantId: string) {
  await limits.concurrency("transcode", async () => {
    // at most 2 at a time for this key; a third caller throws MAIN-LIMIT-0003
  }, { key: tenantId });
}
```

## Reference

Calls you make from app code:

| Call | What it does |
| --- | --- |
| `limits.hubs()` | Returns the stores in use: the default, one per region when `limits.regions` is set, and the in-process store behind the `local` mode. |
| `limits.info()` | Lists every declared policy as `{ name, kind, mode }`, where `kind` is `rate`, `quota` or `concurrency` and `mode` is what it does when the store is down. |
| `limits.declaredRate(policy)` | Returns a declared rate policy's `max` and window in milliseconds (`perMs`) as `main.yaml` has it, or `undefined` when none is declared. |
| `limits.switchStore(down)` | Test switch: makes every shared store fail as an outage (`true`) or answer again (`false`). The in-process store stays up. |

Building blocks for code that wires `limits` by hand, outside `main.yaml`:

| Name | What it is |
| --- | --- |
| `parseLimits(raw)` | Reads and checks the `limits` section. Throws `MAIN-LIMIT-0007` naming every invalid key. |
| `limitsIssues(raw)` | Returns the invalid keys as `{ path, message }` without throwing. |
| `new LimitError(code, message, retryAfter, headers, decision?)` | The 429 problem `limits` throws for `MAIN-LIMIT-0001`, `0002` and `0003`. It carries `Retry-After`, the RateLimit headers and the `decision`. |
| `asKeyLimits(limiter)` | Fits a limiter to the two calls `apikeys` and `entitlements` make: `rate` as a sliding window and `lock` that queues. |
| `localLimits(o?)` | In-process rate counters and per-key locks, with the clock passed as `{ now }` so a test can move time. |
| `new StoreHub(o)` | The runtime store of one app: a driver with tenant-scoped keys, HMAC-hashed key text, one timeout per remote call (50 ms by default) and a log record once per outage. |
| `localStore(o?)` | Store driver in the process, optionally saved to `{ file }`. It serves one instance. |
| `redisStore(o)` | Store driver on Redis over the SDK's own client. Every connection goes through `net.connect`, so the Redis host must be a declared driver host. |
| `dbStore(o)` | Store driver on a SQL driver (SQLite or Postgres), in one table, `_mainsdk_store`, created on first use. |

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-LIMIT-0001` | The rate limit for this policy and key is reached (429) | Wait for `Retry-After`, then back off |
| `MAIN-LIMIT-0002` | The quota for this window is used up (429) | Wait until the window resets |
| `MAIN-LIMIT-0003` | No concurrency slot came free within `wait` (429) | Retry later, or raise `max` |
| `MAIN-LIMIT-0004` | The lock was not acquired within `wait` (409) | Retry later |
| `MAIN-LIMIT-0005` | The store is down and the policy is `closed` (503) | Retry in a few seconds, or set `on-store-down` to `open` or `local` |
| `MAIN-LIMIT-0006` | A policy, quota or concurrency name is not declared | Declare it under `limits` |
| `MAIN-LIMIT-0007` | A policy is invalid; the app does not start | Fix the key the error names |
| `MAIN-LIMIT-0008` | `mainsdk check --env prod` found `driver: local` without `single-instance: true` | Use `redis`, or set `single-instance: true` |
| `MAIN-LIMIT-0009` | A lease expired before the function finished; its signal is aborted | Raise `ttl`, or finish sooner |

A store that does not answer within `timeout` is `MAIN-SDK-0004`, logged once per outage. A decision made without the store carries `degraded: "open" | "closed" | "local"`. See [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

```yaml title="main.yaml"
limits:
  driver: redis   # was local
  redis: { url: secret:REDIS_URL }
```

Your calls stay the same. Counters start fresh on the new driver. With `redis`, each decision is one script call, so counts are exact across instances, and keys are hashed so the store holds no personal data. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver).

## Testing

`createTestApp` runs `limits` on its in-memory driver with a fake clock, so `clock.advance()` resets a window without waiting. The kit adds `app.limits.exhaust(policy, key)` and `app.limits.storeDown(true)`.

```ts title="lib/export-limit.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { limits } from "mainsdk";

describe("export rate limit", () => {
  it("refuses the sixth export in a minute and allows it after", async () => {
    const app = createTestApp({ main: "./main.yaml" });   // export: { max: 5, per: 1m }
    for (let i = 0; i < 5; i++) assert.equal((await limits.rate("u1", "export")).ok, true);
    const refused = await limits.rate("u1", "export");
    assert.equal(refused.ok, false);
    assert.ok((refused.retryAfter ?? 0) > 0);
    app.clock.advance("1m");
    assert.equal((await limits.rate("u1", "export")).ok, true);
  });
});
```

## Security and privacy

- Limit by user or tenant ID, not by an email address. Keys are scoped to the current tenant and stored as an HMAC, so the store holds no raw key and one tenant's counters never mix with another's.
- Every refusal is logged with the policy and the hashed key, never the raw key.
- Default policies for sign-in, sign-up, password reset and AI calls are planned; the mechanism is here. Until `auth` declares them, add a `login` policy and enforce it yourself.

## FAQ

**Is this the same as plan limits?** No. `limits` protects your app from abuse. What a plan includes (seats, projects, a request rate per plan) is [`entitlements`](https://mainsdk.dev/docs/interfaces/entitlements), which uses `limits` underneath for request rates.

**Is this the same as the MainSDK Cloud usage limit?** No. Cloud allowances are described in [Free in the app, paid in the Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud).

**Which hosted limiters are supported?** None as drivers yet. Upstash Redis works through `driver: redis`.

## Next

- [Limits, metering and entitlements](https://mainsdk.dev/docs/guides/limits-and-metering): one guide for rate limits, usage and plans.
- [Usage metering](https://mainsdk.dev/docs/interfaces/meter)
- [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)
