Rate limits
partly builtMissing: the Redis store; turning a limit off. See What is built.
On this page
Limit how often a route is called does the whole job, start to finish.
A rate limit caps how many requests a caller may make in a period. Every
route has limits by default, so you write nothing to get them: 600 a
minute for each signed-in caller, and 120 a minute for each address on
routes open to anyone. A refused request gets the built-in
rate_limited failure: a 429 with a Retry-After header
(Rate-limited requests).
You change a limit with rate_limit:, on one route or on an API in
app.jadpo, where it is the default for the API's routes. Here a public
form allows five requests a minute from each address. rate_limit: and
RateLimitKey are built into Jadpo; the route, its types and its action are
your app's, and are all shown below it.
domains/sales/routes/demo_requests.jadpo:
use sales/types { DemoForm, DemoReceived }use sales/actions { request_demo }/// Requests for a demo.routes DemoRequests { prefix: /demo-requests /// Asks for a demo, five times a minute from each address. POST / { authentication: RouteAuthentication.none rate_limit: { requests: 5 within: 1m by: RateLimitKey.ip } input: DemoForm output: DemoReceived status: SuccessStatus.created run: request_demo(input) }}
domains/sales/entities/demo_request.jadpo, the record it keeps:
/// Someone asking to see the product.entity DemoRequest { id: Uuid company: Text { min_length: 1 max_length: 100 } policy: { create: [Access.public] }}
domains/sales/types/demo_form.jadpo, what the route takes:
use sales/entities { DemoRequest }/// What someone sends to ask for a demo.type DemoForm = Input { company: DemoRequest.company}
domains/sales/types/demo_received.jadpo, what it answers:
use sales/entities { DemoRequest }/// What someone gets back for asking.type DemoReceived = Output { pick: [DemoRequest.id]}
domains/sales/actions/request_demo.jadpo, what it runs:
use sales/entities { DemoRequest }use sales/types { DemoForm, DemoReceived }/// Keeps a request for a demo.action request_demo(input: DemoForm) -> DemoReceived { var demo_request: DemoRequest = DemoRequest.create { set: { company: input.company } } return DemoReceived { id: demo_request.id }}
On a first run, jadpo migrate new initial writes the migration and jadpo records update the records, as jadpo check's next: lines say. Commit what
they write.
This limit's window is a minute, which every target can count. A longer one,
such as within: 1h, runs on the native target but not on Workers, whose store
counts only 10 or 60 seconds (Counting on Workers).
requests: is a whole number of at least 1. within: is whole seconds
from 1s to 24h; a longer allowance is a plan's limit, which is your
app's data (What isn't built in). Anything else
is route.rate_limit_invalid.
An API's limit is written the same way, in app.jadpo:
application { apis: { main: { prefix: / rate_limit: { requests: 300 within: 1m } } }}
| Rate-limited requests A request over a rate limit (Rate limits) gets |
What a client sees
A refused request gets a 429 with Retry-After and the standard
RateLimit headers, its wording from translations like any failure's
(Rate-limited requests). Each route you
write shows what it is limited to, its own limit or the default one, in its
OpenAPI entry (x-jadpo-rate-limit) and in jadpo routes. A route test's
host counts nothing, so a route test never gets a 429; a limit is seen on
jadpo dev and in a deployable.
What a limit you write changes
A limit you write stands in for the default limits, so it can loosen them as well as tighten them:
- A route's own limit has a count of its own. A request to that route counts against it and uses nothing of the caller's or address's default allowance.
- An API's limit is one count, which all the API's routes that write no limit of their own share, as the defaults are shared across every route.
- A request whose credential is refused still counts against its address's default allowance as well, whatever the route says, so a generous limit doesn't make guessing a credential any cheaper.
- Routes Jadpo answers itself, such as sign-in, webhooks, the OpenAPI document and health checks, keep their own limits. An API's limit doesn't reach them.
Who is counted: by:
by: says what a limit counts requests by:
RateLimitKey.caller, the default, counts each signed-in caller separately, or each address when the caller isn't signed in.RateLimitKey.ipcounts each address.
The address is the client's address as your app finds it (Client addresses). By default that is the address the connection comes from. If your app runs behind a proxy or load balancer of your own, you name the header that proxy sets, so the limits count each client, not the balancer. An IPv4 client is counted by its address. An IPv6 client is counted by its /64, the smallest block a network hands one subscriber, so moving to another address in its own block doesn't get it a fresh count.
The magic-link routes Jadpo generates (Magic links) are limited by address without you writing anything.
Where the counts are kept: rate_limits: { store: }
With no store named, each server counts in its own memory. A store shared
between servers is named with rate_limits: { store: cache } in
app.jadpo's application { } block. Redis is the store for that, and
its driver isn't built yet, so for now a Redis store counts in each
server's memory too (What is built). If a store can't be
reached, each server counts in its own memory, so limits stay roughly
right and no request fails. Caches follow the same rule
(Caches).
The store must be a connection with driver: Driver.redis or
driver: Driver.cloudflare_rate_limits; anything else is a compile
error (rate_limits.store_invalid).
The same store also holds provider rate limits, a package's limits on calls to its own API (Calling). Your servers then share one count of calls to a provider, rather than each spending the whole allowance.
On Cloudflare Workers, a Worker's instances share no memory, so a
deployable built for Workers that serves an API must name a store, or
the compiler refuses it (rate_limits.store_required). The next section
shows the store Workers uses.
Counting on Workers
An edge deployable built for Workers counts its rate limits in Cloudflare's binding:
app.jadpo:
application { connections: { limits: { driver: Driver.cloudflare_rate_limits namespace: 1001 } } rate_limits: { store: limits } deployables: { edge: { serves: [main] target: Target.wasm } }}
A connection with driver: Driver.cloudflare_rate_limits runs only on
Workers. Its namespace: is the first of the namespace ids in your
Cloudflare account that its bindings take. A binding counts one limit
over one window, so each limit your app counts there is a binding of its
own: LIMITS_120_PER_60S counts 120 a minute, and a route's requests: 5
within: 1m is LIMITS_5_PER_60S. Two routes with the same limit share a
binding and still count apart. The bindings are numbered from namespace:,
in order of window and then of requests, and jadpo build writes each into
the Wrangler file's ratelimits.
This store can count only windows of 10 or 60 seconds. A limit with any
other window, such as within: 1h, is a compile error
(rate_limits.window_unsupported), which names Redis as a store that can
count it. It is never counted over another window instead. Until Redis's
driver comes a Redis store counts in each instance's memory
(A Redis store on Workers), so for now a limit
on Workers is in practice per 10 seconds or per minute. The default limits
are all per minute, so only a limit you write yourself can raise this.
The binding says only whether a request is within the limit, so a refused client is told to try again after the window's whole length. If the Worker hasn't got a binding, or the binding throws or doesn't answer within 500 ms, the instance counts in its own memory instead. That is logged once for each instance, and no request fails for it.
Rate limits on Workers has the details. What is built says how a Redis store counts.
What a limit doesn't promise
A limit is per caller or per address. It doesn't cap what everyone does
between them: by: has no value that counts all callers together, so a
client with many addresses gets the allowance many times. A limit makes an
expensive anonymous route more costly to abuse, but it isn't a budget. A
route that starts expensive work, or calls a model and spends money each
time, needs a cap of its own as well: a limit on the work waiting, kept in
your data, or for a model the AI budget's over_budget
(Budgets).
What else falls short depends on where the counts are kept:
- In memory, the native host's default. Each server has its own counts, so several servers each give the whole allowance. A restart starts every count again, so a long window is only as good as the server's uptime. When the 16 MB allowance runs out, the counters whose windows end soonest are dropped first, so enough different addresses can win some of them a fresh count.
- In Cloudflare's binding, on Workers. Counts are per Cloudflare location and rough: a limit of 5 let 24 through when measured (What the binding promises).
Details
The exact rules, for when you hit an edge case.
| Rate limits on Workers A Worker's instances share no memory, so on Cloudflare Workers your rate limits need a store that every instance can reach. |
Markdown for agents: /docs/rate-limits.md.Built from Jadpo main at aee2e8f.