Rate limits
Limit how often each caller can run a function.
Your contact form accepts anonymous visitors, so anything on the internet can call it, including a script that submits ten thousand times a minute. A rate limit caps how many times each caller can run a function in a given window, and refuses the rest.
Graft counts calls from the audit log, which already has a row for every call. You do not run Redis or keep a separate counter.
Add a limit to a function
export const submitContact = defineFunction({
name: "submitContact",
kind: "mutation",
public: true,
rateLimit: { limit: 5, windowSeconds: 60 }, // 5 calls per caller per minute
input: submissions.fields,
handler: async (ctx) => {
/* … */
},
});The window rolls: it always covers the last windowSeconds, rather than
resetting at the top of each minute. So a caller cannot fire 5 calls at 12:00:59
and 5 more at 12:01:00.
To set a default for every function, pass rateLimit to the handler. A
function's own rateLimit overrides it:
createFunctionsHandler({ functions, db, rateLimit: { limit: 60, windowSeconds: 60 } });
graft serve sets that default for you: 60 calls per 60 seconds.
What a limited caller sees
The call after the limit is refused with HTTP 429 and a Retry-After header
saying how many seconds to wait:
{
"error": "RATE_LIMITED",
"message": "\"submitContact\" allows 5 calls per 60s per caller; this caller has made 6.",
"fix": "Wait for the window to pass (Retry-After is set) before retrying. …",
"details": { "function": "submitContact", "limit": 5, "windowSeconds": 60 }
}
How callers are counted
- Signed-in callers are counted by who they are. Two users never share a limit, so one cannot lock out the other.
- Anonymous callers are counted by IP address, when Graft knows it.
graft serverecords each connection's address, and so doesgraftHandlerin a Nuxt or Nitro app. Inside other frameworks, Graft only knows it once you settrustedProxyHops(below). Until then, every anonymous caller shares one limit, which is strict: one busy caller can use it up for everyone.
Every attempt counts, including ones that are refused. A caller who sends twenty invalid requests has used twenty calls. Graft records each call before checking it, which is also what stops a burst of simultaneous requests from all slipping under the limit at once.
If your app is behind a proxy or CDN
Behind a proxy, every request seems to come from the proxy's IP address. The
real client address is in the x-forwarded-for header, but a client can write
that header too, so Graft ignores it unless you say how many proxies you run:
createFunctionsHandler({ functions, db, trustedProxyHops: 1 });
Set it to the number of proxies in front of your app. Behind one nginx or one
CDN, that is 1. Graft then reads that many entries from the right of the
header, the end your proxies write to, and uses the address your outermost
proxy saw.
This holds only if each counted proxy appends the address it received the
request from to x-forwarded-for. With a single proxy, replacing the header
with that address works too. With several, each must append, or the entries
Graft counts back through are lost. nginx with
$proxy_add_x_forwarded_for, and the major CDNs, do this. A proxy that passes
the header through unchanged leaves the client's own text at the end, and a
client can then pick its own limit. Do not count a proxy you do not control,
and do not set the number higher than the proxies you run.
Choose a limit
- Every public function needs one. A
public: truefunction accepts anyone, so its limit is all that stands between it and a script. The docs site's own contact form uses 5 per 60 seconds. - Limit the size of each input too. A rate limit caps how often someone can call, not how much they send each time. Give every field a maximum length:
email: field.string({ maxLength: 320, pattern: /^[^@\s]+@[^@\s]+\.[^@\s]+$/ }),
message: field.text({ maxLength: 4000, optional: true }),
Agents are limited too
Agents run functions through the run_function MCP tool, which uses the same
handler. A function's limit applies the same way whether a browser or an agent
calls it.
The content API
The Content API serves your content to frontends over HTTP without checking who is asking, and it runs searches. Without a limit, one anonymous caller could keep your index busy. Give it its own limit:
createContentApiHandler({
collections,
branch: "main",
index,
rateLimit: { limit: 60, windowSeconds: 60 },
});
graft serve sets 60 per 60 seconds here too. If you mount the handler behind a
proxy that already limits requests, you can leave rateLimit out.
This limit works differently from the function limit, in two ways:
- It counts in memory, per server process. Writing a database row for every read would cost more than the reads it protects. So three server instances allow three times the limit, and a restart resets the count. If you need one exact limit across instances, set it in your proxy or CDN.
- Its window resets on a fixed schedule, rather than rolling.
It identifies callers the same way, including the trustedProxyHops setting,
and refuses with 429 before running the query.
Request bodies have a size limit
Every route that reads a request body stops reading once the body passes a
limit, and answers with HTTP status 413. A request that declares a larger
Content-Length is refused without reading a byte. One that sends no length is
cut off as soon as it passes the limit.
| Route | Default limit |
|---|---|
POST /api/fn/<name> (createFunctionsHandler) | 1 MiB |
| Studio's API | 1 MiB, and 14 MiB for an asset upload so a 10 MB file fits as base64 |
POST /api/mcp (createGraftMcpHandler) | 32 MiB, so put_asset fits a 24 MB file as base64 |
A public docs MCP (createDocsMcpHandler) | 1 MiB |
Arguments to run_function arrive inside an MCP message, so the MCP limit
bounds them, not the functions route's 1 MiB.
The functions route and Studio answer with the PAYLOAD_TOO_LARGE error.
MCP answers in its own protocol: a JSON-RPC error with code -32600 and a
message that says how to raise the limit.
A refused function call still counts against the caller's rate limit. An MCP
endpoint that requires a token refuses a caller without one before reading the
body at all. One that accepts anonymous callers (allowAnonymous: true, or a
public docs MCP) reads it under the limit like any other.
To change a limit, pass maxBodyBytes to the handler, including
createStudioHandler:
createFunctionsHandler({ functions, db, maxBodyBytes: 4 * 1024 * 1024 });
With graft serve, pass --max-body-bytes or set GRAFT_MAX_BODY_BYTES; the
flag wins when both are set. It sets one limit for the functions route, MCP,
Studio and the server itself. The server stops reading a body once it passes
32 MiB by default, and each route stops at its own limit as the bytes arrive,
so at most the chunk that crossed the limit is read past it. A value that
is not a whole number of bytes stops the server with CONFIG_INVALID.
In Nuxt or Nitro, graftHandler from @usegraft/sdk-nitro does the same on
h3 1 under Node: it streams the body to the handler, which stops at its own limit (1 MiB
for createFunctionsHandler), and graftHandler stops at 32 MiB, or its own
maxBodyBytes, as the bytes are read. On h3 2, and on h3 1 under a Web
runtime such as Cloudflare, Deno or Vercel Edge, it hands over the platform's
request untouched, each handler reads it under its own limit, and
maxBodyBytes has no effect.
What is not rate limited
- Reads through the SDKs in your own app. They read the index directly and never pass through a handler.
- MCP tools other than
run_function.
Limits need the audit log
Function limits are counted from audit rows, so they cannot work with the audit
log turned off. If a handler sets both audit: false and a rate limit, Graft
refuses to start it, rather than quietly not limiting anything:
Remove `audit: false` from createFunctionsHandler, or drop the rateLimit
configuration.
Next steps
- Functions & access covers who may call a function.
- Audit & approvals explains the rows the limit counts.
- Error reference documents
RATE_LIMITED.