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
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 },
]);
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'.
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 limit | under limit | |
|---|---|---|
enforce: true (default) | throws RateLimitExceededError | returns the decision |
enforce: false | returns the decision | returns 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 sameoverLimitthe 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 ownacquire()can still be refused.- Neither throws, whatever
enforcesays. But a call made withenforce: falsedoes not count toward the quote'srateLimitVerdict: it would not reject the run, so it does not make it look blocked. A task that reads withenforce: falseand then throwsRateLimitExceededErroritself 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():
overLimitis true if any of them is over — that is, if the run would be refused.typesays which kind blocks the run —rateorconcurrency— and is absent when nothing is over. When both are over, it isconcurrency.retryAfterSecondsis the longest wait among the rate limits that are over, and0when nothing is. It isnullwhen a concurrency limit blocks: a slot frees when a running task finishes, not after a set time.labelis the label of the limit that blocks the run — the oneretryAfterSecondscomes from — when it has one. It is the same limit a429would name indetails.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.
limitchanges apply immediately,windowSecondsdoes 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 . _ = @ | / : -.