Limits, metering and entitlements
Rate-limit an endpoint, cap usage with a quota, meter what each tenant uses and gate features and limits by plan.
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.
You will protect an expensive endpoint with a rate limit and a daily quota, record what each tenant uses, and decide what each plan includes. The three interfaces work together: limits refuses abuse, meter counts usage, and entitlements turns a tenant's plan into yes or no. It takes about 20 minutes and needs no account.
You need
- A project with
main.yamland tenancy on (see Build a multi-tenant app). - For plans set by payments: a
paydriver. Optional here.
1. Rate-limit an endpoint
Declare a named policy, then enforce it with the person's id as the key.
limits:
driver: local
single-instance: true # use redis once you run more than one instance
policies:
export: { max: 5, per: 1m }
quotas:
exports-daily: { max: 50, per: day }import { auth, limits } from "mainsdk";
const buildExport = async () => ({ rows: 0 });
export async function POST(req: Request) {
const session = await auth.require(req);
await limits.enforce("export", { key: session.user }); // MAIN-LIMIT-0001 (429) past 5 a minute
return Response.json(await buildExport());
}A refusal is a 429 problem with Retry-After and the RateLimit headers. You should see the sixth request in a minute fail.
2. Cap usage with a quota
A quota counts per UTC window and gives back what you release.
import { limits } from "mainsdk";
const runExport = async () => {};
export async function startExport(userId: string) {
await limits.quota("exports-daily", { key: userId }); // MAIN-LIMIT-0002 when the day's 50 are used
try {
await runExport();
} catch (err) {
await limits.release("exports-daily", { key: userId, add: 1 });
throw err;
}
}3. Meter what each tenant uses
Declare each metric, then record where the usage happens. meter.record() returns at once and never fails the request.
meter:
driver: local # use db or redis in production
metrics:
exports: { unit: export, aggregate: sum }import { meter } from "mainsdk";
meter.record("exports", 1, { feature: "export" });
const month = await meter.usage({ metric: "exports", window: "month" }); // { value, unit, from, to, stale }Pass an idempotencyKey where a step can retry, so it counts once.
4. Say what each plan includes
Declare plans. A limit that reads a meter counter needs a window.
plans:
free: { features: [basic], limits: { exports: 20/month } }
pro:
features: [basic, export]
limits: { exports: 1000/month }
prices: [price_pro_monthly]
entitlements:
default: free
upgrade: /settings/billing
usage:
exports: meter:exportsimport { entitlements } from "mainsdk";
const buildExport = async () => ({ rows: 0 });
entitlements.require("export"); // MAIN-PLAN-0001 (403) on free
await entitlements.within("exports", 1, () => buildExport()); // MAIN-PLAN-0002 (403) at the limit
const { limit, used, remaining } = await entitlements.limit("exports");Every refusal carries an upgrade hint with the plans that include it and your upgrade link.
5. Let a subscription set the plan
When pay verifies a subscription.updated event whose price is listed under a plan's prices and whose metadata.tenant names the tenant, the tenant moves to that plan. To set a plan by hand:
import { entitlements, tenant } from "mainsdk";
await tenant.system("sales deal", () =>
entitlements.set("tnt_acme", "pro", { reason: "annual deal", trial: "14d" }),
);Verify
pnpm mainsdk check
pnpm testcheck fails on an undeclared policy, quota or metric in code, on a feature or limit no plan declares, and on driver: local in production without single-instance: true for limits. A test with createTestApp can prove the behaviour:
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { entitlements, limits } from "mainsdk";
const app = createTestApp({ main: "./main.yaml" });
await app.asTenant({ id: "tnt_free" }, async () => {
assert.ok(await limits.enforce("export", { key: "u1" }));
assert.equal(entitlements.has("export"), false);
});If it fails
| Code | Cause | Fix |
|---|---|---|
MAIN-LIMIT-0006 | A policy or quota name is not declared | Declare it under limits |
MAIN-LIMIT-0007 | A policy has a bad max, per, algorithm or burst | Fix the key the error names |
MAIN-CONFIG-0004 | A policy declares key:, which the loader reads as a secret field | Remove it and pass { key } on the call |
MAIN-LIMIT-0008 | limits uses local in production without single-instance | Use redis, or set single-instance: true |
MAIN-METER-0001 | A metric is not declared under meter.metrics | Declare it |
MAIN-PLAN-0006 | plans is invalid, such as a meter-sourced limit with no window | Write 20/month, and declare the metric |
MAIN-PLAN-0008 | A feature or limit name that no plan declares | Declare it under plans |
For agents
Goal: protect an endpoint with limits, count usage with meter, and gate features by plan with entitlements.
Touch: main.yaml (limits, meter, plans, entitlements sections), the route handlers that enforce and record, tests.
Rules: declare every policy, quota, metric, feature and limit in main.yaml before using it. Pass { key } to limits calls. Key by user or tenant id, never an email. A meter-sourced limit needs a window such as 20/month. Never read the plan from the browser.
Verify: pnpm mainsdk check && pnpm test
Never weaken or skip a check; fix the finding it names.Next
Was this page helpful?