Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 5 Nov 2026. What's built · Install 0.3.0
jadpo
ReferenceRate limits

Built-in services

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:

example
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:

example
/// 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:

example
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:

example
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:

example
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:

example
application {    apis: {        main: {            prefix: /            rate_limit: {                requests: 300                within: 1m            }        }    }}
Rate-limited requests

A request over a rate limit (Rate limits) gets 429 with the usual envelope and the code rate_limited.

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.ip counts 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:

example
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.