Skip to main content

Rate Limiting

A rate limit caps how many times a task may run. The cap can be over a stretch of time — say twenty runs a minute per user — or over what is running right now — say one at a time per user. Anything past the cap is rejected instead of executed.

The framework provides both kinds. A task can declare either or both:

  • Request rate — at most N per window.
  • Concurrency — at most N running at the same time.

Limits are declared in a task's options() and checked before it runs. By default a request past the cap is rejected with a 429 and is not charged; enforce lets you take the verdict and decide for yourself instead.

Nothing about a limit is registered or stored — limit and windowSeconds are arguments you pass on every call.

API​

// one entry per limit — the key is the discriminator, e.g. `user=123`
interface RateSpec { key: string; limit: number; windowSeconds: number; label?: string; countSuccessesOnly?: boolean }
interface ConcurrencySpec { key: string; limit: number; label?: string }

// what a call gives back
interface RateLimitDecision {
type: 'rate' | 'concurrency'; // rate from consume(), concurrency from acquire()
overLimit: boolean; // true if ANY spec in the call is over
retryAfterSeconds: number; // max reset among the over-limit keys; 0 when allowed
degraded: boolean; // the limiter could not answer, so nothing was checked
results: RateLimitResult[]; // per-key detail; empty when degraded
}

flow.rateLimit().consume(specs: RateSpec[], opts?: { enforce?: boolean }): Promise<RateLimitDecision>
flow.rateLimit().acquire(specs: ConcurrencySpec[], opts?: { enforce?: boolean }): Promise<RateLimitDecision>

Prerequisites​

Rate limits are declared in options(), so the task needs enablePreflight:

tasks:
- name: example-task
publish: true
enablePreflight: true
info

Because options() runs for every execution of the task, the limit applies to it as a root task and as a sub-task — flow.executeTask(), flow.streamTask() and deferred sub-tasks all count against the same limits.

Request rate​

await flow.rateLimit().consume([
{ key: `user=${flow.meta().userId()}`, limit: 20, windowSeconds: 60 },
]);

Keys can be built from flow.meta(), which exposes the request's headers inside options() — userId(), countryCode(), touchpoint() and so on.

Counts one hit against the key. When over, it throws RateLimitExceededError — a 429 with reason: 'rate_limit_exceeded'.

Several limits in one call are evaluated all-or-nothing: if any would exceed, nothing is counted.

await flow.rateLimit().consume([
{ key: `user=${uid}`, limit: 20, windowSeconds: 60 },
{ key: 'global', limit: 5000, windowSeconds: 60 },
]);
warning

consume() and acquire() must be awaited.

Counting only successful tasks​

By default a request limit counts attempts — a task that fails still uses up its allowance. Set countSuccessesOnly: true on a spec to count only completed tasks.

await flow.rateLimit().consume([
{ key: `free:user=${uid}`, limit: 3, windowSeconds: THIRTY_DAYS, countSuccessesOnly: true },
{ key: `abuse:ip=${ip}`, limit: 100, windowSeconds: 60 },
]);

The hit is still counted up front, in options(), so parallel requests can't slip past the limit. If the task then fails — for any reason, including invalid params or a moderation rejection — the framework gives the hit back.

  • Specs of both kinds can share one call, as above — keeping them in one call is what keeps them all-or-nothing.
  • Turning it on for an existing limit keeps the allowance already spent.
  • When every hit in a window is given back, the window ends and the next hit starts a new one. If a quota must reset on a fixed date, put the period in the key (e.g. quota:user=7:2026-09).

Concurrency​

await flow.rateLimit().acquire([
{ key: `user=${flow.meta().userId()}`, limit: 1 },
]);

Reserves one in-flight slot per key. The framework releases it when the task reaches a terminal status — you never release manually and never state a duration. The slot survives flow.defer(). When no slot is free, it throws RateLimitExceededError — a 429 with reason: 'concurrency_limit_exceeded'.

warning

A slot is held for at most one hour. The service reclaims it after that even if the task is still running, so a task that can run longer than an hour cannot be reliably limited this way.

enforce​

enforce decides whether an over-limit result throws. It defaults to true.

It does not change what is counted: an allowed hit is always counted, a rejected one never is, and a rejected acquire() never takes a slot.

over limitunder limit
enforce: true (default)throws RateLimitExceededErrorreturns the decision
enforce: falsereturns the decisionreturns the decision
const decision = await flow.rateLimit().consume(specs, { enforce: false });
if (decision.overLimit) {
return { monetization: { toolId: 'example_paid' } };
}

To reject after inspecting the decision yourself, throw RateLimitExceededError rather than your own error, so the client gets the same 429 as a framework rejection. Pass 'acquire' for a concurrency decision:

const decision = await flow.rateLimit().acquire(specs, { enforce: false });
if (decision.overLimit && !isPriorityUser) {
throw new RateLimitExceededError(decision, 'acquire');
}

On the /options preview​

The public /options endpoint is a quote: it must not spend anything, so nothing is counted and no slot is taken there.

  • consume() evaluates the same limits read-only and returns the real verdict — the same overLimit the execution would get.
  • acquire() checks read-only whether a slot is free right now, without taking one. It is a snapshot: slots can fill or free before the run starts, so the run's own acquire() can still be refused.
  • Neither throws, whatever enforce says. But a call made with enforce: false does not count toward the quote's rateLimitVerdict: it would not reject the run, so it does not make it look blocked. A task that reads with enforce: false and then throws RateLimitExceededError itself cannot be predicted this way.

Warning about a sub-task's limits​

A sub-task enforces its own limits when it runs, so a parent needs to do nothing for the run itself. To warn in the parent's quote that a sub-task it is about to run would be refused, include the sub-task's verdict:

const upscale = await flow.options('upscale', params, { includeRateLimit: true });

Its rateLimitVerdict then counts toward the parent's like one of the parent's own limits. Set it only for a sub-task the parent will actually run — one it merely inspects, as an alternative it may not pick, should not make the parent look blocked. Outside a quote, includeRateLimit does nothing: refusing the parent on a snapshot of the sub-task's state would refuse runs that would have gone through.

What reaches the client​

When the task lets the framework reject the request (the default enforce: true), the client receives a 429 carrying the retry hint and the limit that was hit:

{
"status": "error",
"reason": "rate_limit_exceeded",
"message": "…",
"details": { "retryAfterSeconds": 42, "limit": 20 }
}

Branch on reason: rate_limit_exceeded for a consume() rejection, concurrency_limit_exceeded for an acquire() one. The message is for display and may change. A concurrency rejection has retryAfterSeconds: null, since a slot frees when a running task finishes, not after a set time.

Adding a label to a spec is what makes that limit identifiable to the caller: a rejection's details carry the label of the limit that rejected, and the /options verdict carries the label of the limit that would.

await flow.rateLimit().consume([
{ key: `free:user=${uid}`, limit: 3, windowSeconds: THIRTY_DAYS, label: 'free_tries' },
{ key: `abuse:ip=${ip}`, limit: 100, windowSeconds: 60 },
]);
{
"status": "error",
"reason": "rate_limit_exceeded",
"message": "…",
"details": { "retryAfterSeconds": 42, "limit": 3, "label": "free_tries" }
}
{ "status": "success", "response": {
"credits": 12,
"rateLimitVerdict": { "type": "rate", "overLimit": true, "retryAfterSeconds": 42, "label": "free_tries" }
} }

In /options, rateLimitVerdict is one verdict across every enforced consume() and acquire() the task made and every sub-task it included, and a parent task gets the same one from flow.options():

  • overLimit is true if any of them is over — that is, if the run would be refused.
  • type says which kind blocks the run — rate or concurrency — and is absent when nothing is over. When both are over, it is concurrency.
  • retryAfterSeconds is the longest wait among the rate limits that are over, and 0 when nothing is. It is null when a concurrency limit blocks: a slot frees when a running task finishes, not after a set time.
  • label is the label of the limit that blocks the run — the one retryAfterSeconds comes from — when it has one. It is the same limit a 429 would name in details.label.

The field is absent when the task read no limit. A concurrency verdict is a snapshot: slots can fill or free before the run starts.

In a rejection, a limit without a label contributes nothing — the caller learns only that something was hit. The key is never sent in any case.

When the limiter cannot answer​

If the limiter is unreachable the call does not throw. It returns degraded: true, which means the limit was not checked — not that it was checked and passed:

const decision = await flow.rateLimit().consume(specs, { enforce: false });
if (decision.degraded) {
throw new PicsArtError('service_unavailable', 'Cannot verify quota', 503);
}

The default is to continue: an outage degrades limiting, not execution.

Things to know​

  • Attempts are counted, not successes. A task that fails still spends its allowance.
  • Fixed window. The window starts on the first hit against a key and the whole allowance refreshes at once — not a rolling "N in any 24h", so a burst can span the reset.
  • limit changes apply immediately, windowSeconds does not. The window is set when the counter is created and ignored until it drains. Change the key if a new window must apply now.
  • A Kafka redelivery counts twice.
  • Your task command is prefixed automatically — write only the discriminator.
  • The key decides who shares a counter — a user id, not a prompt. One that changes every request gets a fresh allowance every request, so nothing is ever limited.
  • Keys allow only A-Z a-z 0-9 . _ = @ | / : -.