Skip to content

Rolling windows and concurrency

By default, a minute, hour or day limit resets at the UTC boundary. Say each of your API’s customers may make 1,000 requests an hour with their key. A key could send 1,000 requests at 12:59 and 1,000 more at 13:01, and both bursts would be allowed. Set rolling: true and the limit counts the last 60 seconds, 60 minutes or 24 hours at the moment of each check instead.

import { createClient } from "@limitry/sdk";
const limitry = createClient();
await limitry.limits.create({
name: "API per hour",
subject: { kind: "apikey" },
action: "api-request",
amount: 1000,
window: "hour",
rolling: true,
});

Limitry counts use in 60 slices of the window: a second for a minute, a minute for an hour, 24 minutes for a day. The oldest slice counts in full, so a rolling limit never allows more than its amount, and may refuse up to one slice early. In a check’s answer, resetsAt (and retryAfterSeconds when refused) is when enough use has left the window: the Retry-After to send with your 429. The Rate-limit your API’s customers guide adds a second rolling limit, 60 in any minute, against bursts.

week, month, year, total and concurrent limits can’t be rolling. In the app, choose In any 60 seconds, In any 60 minutes or In any 24 hours as the window.

A limit with the window concurrent caps work in progress: amount is how many at once. Say exports are heavy, and each workspace of your product may run at most 3 at the same time. Each reservation takes one slot, whatever its cost, and gives it back when it’s committed, released or expires. The cost still counts against the subject’s other limits.

At most 3 exports at once per workspace:

import { createClient } from "@limitry/sdk";
const limitry = createClient();
await limitry.limits.create({
name: "Exports at once",
subject: { kind: "workspace" },
action: "export",
amount: 3,
window: "concurrent",
});

Then reserve a slot while each export runs. Limitry refuses a 4th export until one of the 3 ends. reserve gives it back when the work finishes, or fails:

const file = await limitry.reserve(
{ subject: { kind: "workspace", id: "ws_42" }, action: "export" },
() => runExport(),
);
  • A consume or preview check against a concurrent limit is allowed while a slot is free, but takes none. Instant work takes no slot.
  • When every slot is taken, retryAfterSeconds is when the earliest reservation expires. A release can free one sooner.
  • If you reserve, commit and release yourself (see Reservations), release in a finally block. A reservation you never commit or release keeps its slot until it expires.

A reservation lasts ttlSeconds: 300 by default, 3,600 at most. For work that runs longer, extend it as a heartbeat:

await limitry.reserve(
{ subject: { kind: "workspace", id: "ws_42" }, action: "export" },
async (reservation) => {
for (const part of parts) {
await exportPart(part);
await reservation.extend(600);
}
},
);

If your worker dies, it stops extending, and the slot frees itself.

A limit can’t change to or from concurrent, because one counts use and the other counts open reservations. Its window is fixed once it exists: create a new limit instead (see What can change).

Rolling limits report like any other. On the Usage page, Right now shows each subject’s use in the last window. For a concurrent limit, the chart shows how much work the limit let start each day, and Right now shows the slots each subject has reserved, among subjects checked in the last hour. For both, Limitry sends limit.reached at most once an hour per subject.