MainSDK
Search⌘K
Interfaces

Security and privacy

Rate limits and quotas

limits

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

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec limitsFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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

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 }

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)

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

3 drivers. Select one to see its line in main.yaml.

main.yamlLocal (in process) (default)
limits:
  driver: local

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-LIMIT-0006Checkmainsdk check, before deployA policy, quota or concurrency name not declared under limits
MAIN-LIMIT-0007Checkmainsdk check, before deployA 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-0008Checkmainsdk check, before deployThe local limits driver in production without single-instance: true
MAIN-LIMIT-0001RuntimeError responses and logsThe rate limit for this policy and key is reached (429, with Retry-After)
MAIN-LIMIT-0002RuntimeError responses and logsThe quota for this window is used up (429, with Retry-After)
MAIN-LIMIT-0003RuntimeError responses and logsNo concurrency slot came free within wait (429, with Retry-After)
MAIN-LIMIT-0004RuntimeError responses and logsThe lock wasn't acquired within wait (409, retryable)
MAIN-LIMIT-0005RuntimeError responses and logsRefused because the store is down and the policy's on-store-down is closed (503, with Retry-After)
MAIN-LIMIT-0009RuntimeError responses and logsA lock or concurrency lease expired before the work finished; its signal was aborted (logged)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
limits@1 contract and named policiesPhase 10.3limits#contract
Token bucket and sliding window rate limitsPhase 10.3limits#rate
429 with Retry-After and RateLimit headersPhase 10.3limits#enforce
Quotas per windowPhase 10.3limits#quota
Locks and concurrency slots with leasesPhase 10.3limits#lock
Redis driver with atomic decisionsPhase 10.3limits#redis
Open, closed or local when the store is downPhase 10.3limits#fail-policy
Counters in the tenant's regionPhase 10.3limits#regions
Per-key limits for apikeysPhase 10.3apikeys#rate-audit
Plan request ratesPhase 10.3entitlements#limits-flags
IP allowlist per organisationPhase 10.3Not specified yet

Quick start

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

app/api/export/route.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 });
}

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

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 }
KeyWhat it does
driverlocal keeps counters in process. redis shares them across instances.
timeoutHow long a store call may take before on-store-down decides.
single-instanceSet 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-downWhat 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>.keyPlanned to name what a call counts by (ip, user, tenant, apikey, global) when you pass no key.

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

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.

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

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

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:

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

NameWhat 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

CodeWhenFix
MAIN-LIMIT-0001The rate limit for this policy and key is reached (429)Wait for Retry-After, then back off
MAIN-LIMIT-0002The quota for this window is used up (429)Wait until the window resets
MAIN-LIMIT-0003No concurrency slot came free within wait (429)Retry later, or raise max
MAIN-LIMIT-0004The lock was not acquired within wait (409)Retry later
MAIN-LIMIT-0005The 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-0006A policy, quota or concurrency name is not declaredDeclare it under limits
MAIN-LIMIT-0007A policy is invalid; the app does not startFix the key the error names
MAIN-LIMIT-0008mainsdk check --env prod found driver: local without single-instance: trueUse redis, or set single-instance: true
MAIN-LIMIT-0009A lease expired before the function finished; its signal is abortedRaise 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.

Swapping drivers

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.

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

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, 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.

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

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet