Rate limits and quotas
limitsRate-limit requests, enforce quotas, run a job on one worker and cap concurrency, answering refusals as 429 problems with Retry-After.
limitsFree in your app, Apache-2.0Status 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.
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 }import { limits } from "mainsdk";
// 429 with Retry-After and RateLimit headers past 5 a minute per person
await limits.enforce("export");
await limits.concurrency("transcode", (signal) => transcode(file, signal));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);
});
});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.
limits:
driver: localChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-LIMIT-0006 | Checkmainsdk check, before deploy | A policy, quota or concurrency name not declared under limits |
MAIN-LIMIT-0007 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | The local limits driver in production without single-instance: true |
MAIN-LIMIT-0001 | RuntimeError responses and logs | The rate limit for this policy and key is reached (429, with Retry-After) |
MAIN-LIMIT-0002 | RuntimeError responses and logs | The quota for this window is used up (429, with Retry-After) |
MAIN-LIMIT-0003 | RuntimeError responses and logs | No concurrency slot came free within wait (429, with Retry-After) |
MAIN-LIMIT-0004 | RuntimeError responses and logs | The lock wasn't acquired within wait (409, retryable) |
MAIN-LIMIT-0005 | RuntimeError responses and logs | Refused because the store is down and the policy's on-store-down is closed (503, with Retry-After) |
MAIN-LIMIT-0009 | RuntimeError responses and logs | A 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| limits@1 contract and named policies | Phase 1 | 0.3 | limits#contract |
| Token bucket and sliding window rate limits | Phase 1 | 0.3 | limits#rate |
| 429 with Retry-After and RateLimit headers | Phase 1 | 0.3 | limits#enforce |
| Quotas per window | Phase 1 | 0.3 | limits#quota |
| Locks and concurrency slots with leases | Phase 1 | 0.3 | limits#lock |
| Redis driver with atomic decisions | Phase 1 | 0.3 | limits#redis |
| Open, closed or local when the store is down | Phase 1 | 0.3 | limits#fail-policy |
| Counters in the tenant's region | Phase 1 | 0.3 | limits#regions |
| Per-key limits for apikeys | Phase 1 | 0.3 | apikeys#rate-audit |
| Plan request rates | Phase 1 | 0.3 | entitlements#limits-flags |
| IP allowlist per organisation | Phase 1 | 0.3 | Not specified yet |
Quick start
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 });
}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:
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
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. |
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().
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.
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
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
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.
Swapping drivers
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).
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
authdeclares them, add aloginpolicy 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
- Limits, metering and entitlements: one guide for rate limits, usage and plans.
- Usage metering
- Sign-in and sessions
Was this page helpful?