An API that accepts a query language needs ceilings, or one request can ask for the whole database, nest forty levels deep, or send a gigabyte. Aventara sets conservative defaults, lets you tune them per layer, and checks every request against the contract before it reaches your ORM.
The limits
| Limit | Default | What it bounds | Refusal |
|---|---|---|---|
maxRequestBytes | 1048576 (1 MiB) | The decoded size of a request body. | 413 A2010 |
maxNestingDepth | 12 | Levels of nested relations in a select/include. | 422 A2009 / V1013 |
maxListLimit | 250 | The limit of a list or of a nested relation. | 422 A2009 / V1014 |
maxBooleanNodes | 50 | Nodes in a where tree (AND/OR/NOT and conditions). | 422 A2009 / V1015 |
maxTransactionOperations | 20 | Steps in one transaction plan. | 422 A2009 / V1014 |
maxJsonDepth | 64 | Levels of nesting inside a Json field's value. | 422 A2009 / V1021 |
maxAll | 10 | Operations in one Operation Set (POST /_all). | 422 A2009 / V1014, for the whole request |
maxAllConcurrency | 4 | Operations of one Operation Set running at once. | none: the rest wait their turn |
All of them are in the contract you can read at GET /api/_contract under limits:
"limits": {"maxAll":10,"maxAllConcurrency":4,"maxBooleanNodes":50,"maxJsonDepth":64,"maxListLimit":250,"maxNestingDepth":12,"maxRequestBytes":1048576,"maxTransactionOperations":20}Changing them
limits is one of the six layer keys, so it goes at the root and, to differ for one origin, in the application or client overlay. Each limit an overlay sets wins for that origin; every other limit comes from the root (Configuration). Define only the limits you change:
return {
api: "/api",
adapter: /* ... */,
limits: { maxListLimit: 100, maxNestingDepth: 3, maxJsonDepth: 32 },
client: {
limits: { maxListLimit: 25, maxRequestBytes: 4096, maxBooleanNodes: 5 },
},
} as const;Remote callers get the client layer's limits (25 rows, 4 KiB, 5 where nodes, depth 3, Json depth 32); your own server code gets the root's (100 rows, depth 3, Json depth 32). Client limits are part of the client contract, so changing them changes its hash: regenerate the frontend client.
Each limit, as a caller sees it with that configuration:
// Post/find/many {"limit":26} (client layer, maxListLimit 25)
{"data":null,"code":"A2009","cause":{"message":"Operation arguments failed framework validation.","issues":[{"code":"V1014","path":["arguments","limit"],"message":"limit exceeds maxListLimit (25)."}]}}
// the same call from your own code: framework.application.Post.find.many({ limit: 101 }) -> maxListLimit 100
{ data: null, code: 'A2009', cause: { issues: [ { code: 'V1014', ... } ] } }
// four levels of relations (posts -> author -> posts -> category) with maxNestingDepth 3
{"data":null,"code":"A2009","cause":{"message":"Operation arguments failed framework validation.","issues":[{"path":["arguments","select"],"code":"V1013","message":"Request nesting exceeds maxNestingDepth (3)."}]}}
// a where with six OR branches, maxBooleanNodes 5
{"data":null,"code":"A2009","cause":{"message":"Operation arguments failed framework validation.","issues":[{"path":["arguments","where"],"code":"V1015","message":"Boolean/filter node count exceeds maxBooleanNodes (5)."}]}}
// a 5 000-character name, maxRequestBytes 4096
{"data":null,"code":"A2010","cause":{"message":"The request body exceeds the maximum request size."}} // HTTP 413Nesting depth counts relation levels: three levels (posts -> author -> posts) pass with maxNestingDepth: 3, a fourth is refused. A plan of 21 steps is refused with A2009 before anything runs.
The value of a Json field has its own limit, maxJsonDepth. Each object or array inside the value is one level, counted from the value itself, so the same value has the same room at the top of data, inside a nested write, or in a where filter. With the default of 64, a value 64 levels deep is accepted; one more level is refused with A2009 at the field, before anything is written:
// Post/create/one with a "meta" value 65 levels deep: {"a":{"a":{"a": ... 1}}}
{"data":null,"code":"A2009","cause":{"message":"Operation arguments failed framework validation.","issues":[{"path":["arguments","data","meta"],"code":"V1021","message":"Json value depth exceeds maxJsonDepth (64)."}]}}Rich-text documents fit well under 64. In a TipTap or ProseMirror document each node adds two levels (its object and its content array), so a paragraph inside a list nested five levels deep is around 30 levels. If you store deeper documents, raise maxJsonDepth. It does not affect maxNestingDepth.
maxRequestBytes counts decoded bytes: a compressed body (gzip, deflate, br) is measured after inflation, so compression does not get around it. A cached read's compressed q is measured the same way, once inflated. See HTTP protocol and NestJS host.
Operation Sets
Two limits bound POST /_all, the request that carries several operations at once:
maxAll(default10) is how many operations one request may carry. More is refused whole, before anything runs:422 A2009,V1014at["operations"]. The generated client never sends more: it splits a larger group into several requests. Each operation is then held to every other limit on its own, as on its own route.maxAllConcurrency(default4) is how many of one request's operations run at once. The others wait and start in key order. It keeps one request from taking the whole database pool, so you don't need a larger pool for/_all. It must be a whole number of at least1;limits.maxAllConcurrency must be a whole number of at least 1is refused at startup.
return {
api: "/api",
adapter: /* ... */,
limits: { maxAll: 20, maxAllConcurrency: 6 },
client: { limits: { maxAllConcurrency: 2 } },
} as const;Server code is different: framework.serverClient.all and Next's avClient.all run in process, take any number of calls (maxAll guards the HTTP route only), and start at most maxAllConcurrency of their avLazy calls at once.
What is refused by default
The framework validates every operation against the contract of the layer it runs in. Before the ORM runs, these fail with A2004 (HTTP 422) and a precise issue list in cause.issues:
| Request | Issue |
|---|---|
| A field that does not exist or is hidden | V1005 |
An operator the field does not offer ($gt, contains on an Int) | V1006 |
A value of the wrong type ("id": "1") | V1001 ("Value must be a 32-bit integer number.") |
| A value outside an enum, or a malformed date, decimal, big integer or base64 | V1002, V1003 |
select together with include | V1008 ("select and include are mutually exclusive.") |
A field with select: false, filter: false or order: false used where it is not allowed | V1008, V1006 |
A negative or fractional limit | V1001 |
A cursor with no explicit, deterministic orderBy | V1009 |
| A relation directive the contract does not offer | V1006 / V1012 |
and, before validation even starts:
| Request | Answer |
|---|---|
An operation the layer does not advertise (delete switched off, an unknown family) | 404 A2002 |
| A Resource that is hidden or does not exist | 404 A2001 |
| A body that is not a JSON object, or malformed JSON | 400 A2000 |
A content type other than application/json, or an unsupported Content-Encoding | 415 A2011 |
A missing Aventara-Protocol-Version or Aventara-Contract-Hash header | 400 A2000, naming the missing header |
| A contract hash that is not the current one (a stale client) | 409 A2005 |
Example, a stale client:
{"data":null,"code":"A2005","cause":{"message":"Generated client contract does not match the server. Regenerate the client."}}Nothing the caller sends is ever put into a query as raw text: arguments are validated into the framework's own query language and translated by the adapter. No caller can send SQL. The only SQL is your own server code's, through framework.appTx.$sql and ctx.$sql, written as a tagged template whose values are always bound parameters (Transactions).
Planned failures and unplanned ones
| Planned | Unplanned | |
|---|---|---|
| Examples | validation, not found, conflict, limits, auth | a database failure the framework cannot classify, an error thrown in your own code |
| Caller gets | a specific code and a message you can show | A3000 / A3001 / A3004: "Internal framework error." / "Adapter execution failed." / "A server pipeline produced invalid arguments." |
| Raw error | none | your diagnostics reporter only; one log line with the code, operation and request id goes to the server log |
Raw database errors, SQL, connection strings and stack traces are never put in cause.
Rate limiting
The limits above bound one request. How many requests a caller may make is a different question, and Aventara does not answer it for you: add a rate limiter at the platform level, or in front of the server (a proxy, an API gateway).
@nestjs/throttler does not protect Aventara's routes. It is a Nest guard, and no Nest guard, interceptor or pipe runs on Aventara's protocol routes (they are registered on the HTTP platform directly; see NestJS host). Verified: with ThrottlerGuard installed as a global guard and a limit of 2, a Nest controller route answered 200 200 429 429 429 and Aventara's GET /api/_contract answered 200 five times in a row.
A limiter that is HTTP-platform middleware does run. On Express (what aventara new scaffolds), use express-rate-limit:
npm i express-rate-limit// src/main.ts
import { rateLimit } from 'express-rate-limit';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// ...
app.use(rateLimit({ windowMs: 60_000, limit: 100 })); // 100 requests per minute per IP
await app.listen(process.env.PORT ?? 3000);
}The scaffold's main.ts has these lines commented out, with a link to this section; pick your own limits and uncomment. With limit: 3, verified against a running server:
GET /api/_contract -> 200 200 200 429 429
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 3
X-RateLimit-Remaining: 0
Retry-After: 60
Content-Type: text/html; charset=utf-8
Too many requests, please try again later.The 429 is the limiter's own answer, not an Aventara envelope, so the generated client raises an AvTransportError with status 429 (The response to "User" find.count (HTTP 429) is not an Aventara response envelope.). Read Retry-After from your own fetch, or handle 429 there (Client errors).
On Fastify, the equivalent is @fastify/rate-limit, registered on the Fastify instance; this page's example was run on Express only. Limits keyed by IP behind a proxy need the proxy's address handled (trust proxy on Express): see the limiter's own documentation. Rate limits are per process by default; with several instances, use a shared store or limit at the proxy.
CORS is not access control
CORS_ORIGINS (or any enableCors setting) tells browsers which origins may read the API's answers. A server, a script, curl or a bot ignores it entirely, so it neither authenticates callers nor limits them. Guards decide who may call; rate limiting decides how often.
Defense in depth
The limits and the validation layer are only a minimum. Beyond them:
- Authenticate and narrow exposure. By default everything the adapter offers is exposed and no guard runs, so with no guard anyone who can reach the server can call every exposed operation, bulk updates and deletes (
update.many,update.count,delete.count) included. Before you serve untrusted callers, add a guard (Authentication and guards) and hide what they should not reach (Exposure). - Remove what callers should not have. Hide fields, make fields write-only, switch off operations: Exposure, Exposing and hiding fields.
- Tighten the client layer. Defaults suit a trusted frontend. For a public API, lower
maxListLimitandmaxNestingDepthunderclient. - Rate-limit, and put a proxy in front. Add a rate limiter. TLS and request timeouts belong to your platform; Aventara's
maxRequestBytescomplements, but does not replace, a proxy's body limit.