Limits & Timeouts (v2)
The numbers a client author needs to build against: request size, timeouts, pagination, batch and ingest bounds, and backpressure. These are enforced server-side โ design your chunking, retry, and pagination around them rather than discovering them at runtime. Values are the defaults for a standard Aito server; a managed tier may set stricter quotas (see Quotas).
When a limit produces an error, it uses the structured error envelope โ { "kind": "error", "data": { "code", "message" } } โ except the request timeout, which is enforced by the HTTP layer before the query handler runs.
Request size
- Max request body: 10 MB (10,000,000 bytes) for every
POSTunder/api/v1and/api/v2, except the v1 query routes and the v1 stream insert below. The body is read in full before the route runs; this includes/api/v2/data/{table}/batch,.../importand/api/v2/data/{table}/stream. A larger body is refused with 413 Payload Too Large (payload_too_largein the v2 error envelope), and the message names the limit. - The v1 query routes (
/api/v1/_query,_search,_predict,_recommendand the other query endpoints) take a body of up to 8 MiB (8,388,608 bytes). A larger one is answered 413, and the message names that limit. POST /api/v1/data/{table}/streamhas no total size limit. This v1 stream insert is shared by the v2 API on purpose: it is the route for loads bigger than one request. It reads newline-delimited JSON one object at a time and commits in chunks; each object can be up to 16 MB. It is also exempt from the request timeout, so a long upload is not cut off at 15 minutes. It streams whether the client sends aContent-LengthorTransfer-Encoding: chunked.
Available since: v2.11.0. On v2.10.3 and earlier every route, including /api/v1/data/{table}/stream, is capped at 8 MiB (8,388,608 bytes), and an oversize body is answered with a 500 or a closed connection rather than a 413.
- Response bodies are also capped at 10 MB. A query whose result would exceed that is truncated at the transport layer, so keep result sets bounded (see pagination) rather than fetching everything in one call.
Query size
- A query is at most 262,144 JSON tokens, on v1 and v2 alike. A token is one structural mark or value:
{"c0": 1}is four ({,"c0",1,}), so the limit is roughly 65,000 one-field terms in an$or. A larger query is refused with 400,query too large: N tokens, the limit is 262,144(request.invalidin the v2 error envelope). Such a query was refused before as well, with a message that did not say why.
Available since: v2.11.8 for the query too large message; earlier builds refuse the same queries with N is before buffer begin M.
To stay under the request limit: split large uploads into several requests (see Batch & ingest) or stream them to the v1 stream insert, and bound reads with limit.
Bulk loads without the request cap: use the file upload API (v1). It needs S3 storage configured on the deployment, and it does not accept tables with a primary key or identity column.
Timeouts
- A single HTTP request has a 15-minute deadline (idle, request, and linger timeouts are all 15 minutes). A query still executing at 15 minutes has its connection closed by the server.
- There is no separate per-query execution cap โ a query's server-side budget is the 15-minute request deadline together with the admission gate. Most queries return in milliseconds; the 15-minute ceiling exists for heavy analytics and ingest.
_evaluate and maxTime
_evaluate behaves differently depending on the engine of the from table:
- On a legacy
type: "table"(rep1) table,_evaluatehonorsmaxTime(seconds): default 300 s, hard maximum 3600 s. On reachingmaxTimeit truncates gracefully โ returning the metrics computed so far plus awarningsnote โ it does not error. - On a
type: "collection"(rep2) table โ the v2 default โ_evaluatecurrently runs the full test set with nomaxTimebudget. It is bounded only by the 15-minute HTTP request timeout: if the test set is large enough to exceed 15 minutes on one request, the connection closes with no result.
Two ways to keep a rep2 evaluation inside the window:
- Narrow
testSource(a smaller held-out fold) so the run finishes well under 15 minutes. - Run it as a job (below) โ the way to evaluate a large test set without the 15-minute ceiling.
Long-running operations: jobs
Any v2 operation can be submitted as an asynchronous job instead of being held open on one request. This is the escape hatch for work that can exceed the 15-minute request timeout โ a large _evaluate, a bulk import/batch, an optimize.
POST /api/v2/jobs/_evaluate โ 201 { "id": "โฆ", "path": "Evaluate", "startedAt": โฆ }
GET /api/v2/jobs โ list running + recently-finished jobs
GET /api/v2/jobs/{id} โ status; once done, adds "status": "ok" | "failed"
GET /api/v2/jobs/{id}/result โ the result (byte-identical to the sync response);
202 while still running
DELETE /api/v2/jobs/{id} โ cooperative cancel
- The
jobs/prefix works in front of every v2 op โ query ops (jobs/_query,jobs/_predict,jobs/_evaluate, โฆ) and write ops (jobs/data/{table}/batch,jobs/data/{table}/optimize,jobs/data/_delete,jobs/data/_modify(a large-tablemigrate), โฆ). - A job's
/resultis byte-identical to what the synchronous endpoint would return, including the error envelope on failure โ a failed job reports"status": "failed"with the reason, so it never fails silently. - Poll
GET /api/v2/jobs/{id}until astatusfield appears, then fetch/result. Jobs are retained for a while after finishing, then expire from the result cache (a later fetch is a404). - Jobs are shared across API versions โ a job submitted at
/api/v2/jobsis also visible and cancellable at/api/v1/jobs/{id}. - Cancellation is cooperative:
_evaluatechecks for cancellation per test row, so aDELETEstops it promptly; some other ops finish their current unit of work before stopping.
Job threads
Available since: v2.11.8. On v2.11.7 and earlier, jobs ran on the same threads as v1 synchronous queries.
Jobs run on their own pool of JOB_THREAD_POOL_SIZE threads, shared by every database on the server. The default is the size of the light pool (LIGHT_THREAD_POOL_SIZE, else twice the number of cores), so as many jobs run at once as before, but a few long jobs (a large _evaluate, an optimize, an import) no longer leave v1 synchronous queries waiting for a thread.
Result size & pagination
- Default page size is 10 for every read (
_search,_query,_predict,_recommend,_relate,$knn,$patterns). Setlimitto change it. - There is no maximum-
limitclamp โ a largelimitis honored, bounded only by the 10 MB response cap.offsetdefaults to0. - Paginate with
offset+limit. The response carriesoffset,limit, and a realtotal. totaldoes not mean the same thing on every endpoint. On row endpoints it is the count of matching rows; on_predict/_recommendit is the number of distinct candidate values; on_matchit is the matched-row count alongside a smallerhitsarray. Don't build a "showing 10 of N rows" pagination loop ontotalfrom a predict/recommend/match response.- SQL note: a
SELECTwith noLIMITis unbounded (it fetches every matching row, subject to the 10 MB cap). Always addLIMITin_sql.
Batch & ingest
_batch (an array of queries) is fail-fast. The sub-queries all read one snapshot of the database, and the results come back in submission order. If any one errors, the whole request returns that single error envelope: there is no partial result array and no per-element error object. If query 3 of 10 fails, you get one error, not seven results and an error; when several fail, the error is the first failing query's in submission order. Retry the whole batch once the cause is fixed.
The sub-queries may run concurrently, up to one less than the server's CPU cores, so a large batch answers sooner on a bigger machine. A self-hosted server can set the limit with AITO_BATCH_PARALLELISM (1 runs them one at a time). The response is the same either way.
Available since: v2.11.8 for concurrent sub-queries and AITO_BATCH_PARALLELISM. Earlier builds run them one at a time, with the same results.
Data writes are committed in chunks:
| Bound | Value |
|---|---|
| Chunk size (rows committed per flush) | 50,000 (raise with ?batchSize=, clamped to 250,000) |
| Flush window | 10 s |
| Max request body | 10 MB per request; no total limit (and no request timeout) on the v1 stream insert (see Request size) |
| Total rows in a table | unbounded across requests (subject to quotas) |
Idempotency โ retrying a timed-out write. A write has no implicit deduplication: replaying an insert that timed out duplicates rows, unless the table declares a primaryKey and you pass ?on_conflict=update or ?on_conflict=ignore (the default is error). Define a key and an on_conflict policy before you build retry logic โ see Identity, keys & deduplication. Because a large ingest can approach the 15-minute request timeout, chunking it into smaller batches also makes each batch independently retryable.
Rate limiting & backpressure
-
There is no per-request rate limit by default. A token-bucket rate limiter exists but is opt-in per database/tenant (managed tiers may enable it, returning
429withX-RateLimit-*headers). -
Always-on backpressure is admission control. When the server's in-flight query weight exceeds its budget, or a request's projected wait exceeds ~20 s, it returns
429with aRetry-Afterheader. This is a back-off-and-retry signal, not a permanent failure. Heavier operations consume more of the budget:Operation Admission weight _search,_predict,$similarity1 _recommend,_match2 _relate5 _evaluate10 So a burst of
_evaluate/_relatecalls hits the gate far sooner than the same number of_searchcalls. -
A self-hosted server can weigh a request by its size as well, so that a burst of very large queries is told to retry instead of exhausting memory. Both are off by default.
V1_ADMISSION_BY_SIZE=truemakes a v1 request weigh its operation's weight ร (1 + JSON tokens / 16,384), so a maximum-size query (262,144 tokens) weighs 17ร a small one.V2_ADMISSION_ENABLED=truegates the v2 query endpoints (_query,_search,_predict,_recommend,_relate,_evaluate,_estimate,_similarity,_match,_aggregate,_batch,_sql) and their jobs (/api/v2/jobs/_<op>): a request weighs its operation (_evaluate10,_relate5,_recommendand_match2, the rest 1; a_queryby the operation in its body; a_batchthe sum of its queries') times its size, so a small_searchweighs 1 unit and a maximum-size one about 129. Jobs are admitted against their own budget (V2_ADMISSION_JOB_MAX_WEIGHT, default heap MB / 8), so a long job never blocks synchronous queries; a job holds its weight from submit until it finishes, so a burst of job submissions can get429at submit. Writes are not admission-controlled: the data endpoints, write jobs, and_sqlstatements that are not queries (INSERT,DELETE,UPDATE, DDL,COPY), however large.Available since: v2.11.8 for the size-based weights.
-
On the public demo host, a shared key can be throttled โ treat a
429as "retry with exponential backoff," and honorRetry-After.
Any client that issues concurrent requests should implement 429 + Retry-After handling. It is the one status you should expect under load even when every request is well-formed.
Field & schema limits
- Links resolve multi-hop dotted paths on rep2 collections โ
processor.departmentandprocessor.company.nameboth work, andwhere,select, andorderByall traverse the full chain. (A separate, capped and off-by-default feature โ using deep linked attributes as prediction evidence โ is a different mechanism, not path resolution.) - Vector dimensions are fixed at schema-declaration time โ every vector written to a column must match the declared dimension, or the write is rejected (
data.bad_request, 400).
Quotas
Row quotas are off by default on a licensed self-hosted server: a table and the database as a whole are unbounded. The free Docker image is the exception, below. A managed tier may enforce a row quota (e.g. 10,000 rows per table), and a write past it fails loud with a 4xx rather than silently dropping rows. Check your plan's quota before a bulk import.
There is no disk quota. A database's disk is bounded by the machine it runs on, and plans are described by machine size (memory, CPU and disk), not by a disk allowance. A database configuration that still sets limits.diskQuota loads as before, and the key is ignored: it was never enforced and has been removed. The server logs a warning naming it each time the database loads, and the database's admin status (GET /system/api/v1/admin/db/{name}/status) lists it under ignoredConfig. Remove the key from the configuration to clear the warning.
Available since: v2.11.9. On v2.11.8 and earlier, limits.diskQuota was accepted but never enforced.
The free Docker image is capped. The freely distributed
Docker image (ghcr.io/aitohq/aito) enforces
10,000 rows per table and 50,000 rows in total. An insert past the cap
returns HTTP 403 with the code row_limit_exceeded, and a license object naming the
state behind the cap (earlier builds answered 429):
{
"kind": "error",
"data": {
"code": "row_limit_exceeded",
"message": "Free-tier cap reached. Get a Production License at https://aito.ai/docker",
"limit": 10000,
"table": "big",
"hint": "Free-tier cap reached. Get a Production License at https://aito.ai/docker",
"license": { "state": "free", "reason": "no AITO_LICENSE_KEY set" }
}
}
license.state is the licence state behind the cap (free, expired, invalid, or, for a
licence that carries limits of its own, licensed); the hint follows it.
A batch that would cross the cap is refused whole, and data.limit says which
cap it hit (10000 per table, 50000 in total). A _modify with several
operations is applied one operation at a time, so when operation N hits the
cap, operations 1 to N-1 are already committed and the response is the 403:
check the row count before retrying the rest. Reads keep working at any
size; the cap only stops new rows. Unlike the
admission-control 429 above, this one is not transient, so a client should not
retry it: branch on data.code. A production licence lifts the cap (below).
Nothing is ever deleted because of a cap.
Licences
A production licence is one of two kinds.
A signed offline licence (recommended for paid licences). A token
aito1.<payload>.<signature> issued by Aito, which the server verifies itself against a
public key built into the image. It needs no network, so it works air-gapped and
with outbound traffic locked (Azure BYOL). Give it to the container either way:
- the environment variable
AITO_LICENSE(the token string), or - a file at
AITO_LICENSE_FILE, by default/run/secrets/aito-license(the Docker and Kubernetes secret mount). A missing or empty file at the default path means "no offline licence".
An offline licence, when present, takes precedence: AITO_LICENSE_KEY is then not
validated. To rotate a licence file, replace it in one step (a rename, or the secret
mount's own update). A read that comes back empty or broken while the server runs licensed
takes effect only after three consecutive reads agree, about three minutes.
The licence names the customer, tier, instance count and expiry (at most 13 months after issue). The instance count is a contractual term: offline servers cannot count each other, so it is reported, not enforced.
An online licence key. AITO_LICENSE_KEY, validated against console.aito.ai at
startup and re-checked in the background. This needs outbound HTTPS. It is the path for
trial keys and keys issued before offline licences existed.
What a licence's state means. The state is re-read about once a minute, so a renewed licence file, an expiry, or a validator that comes back takes effect without a restart.
state | when | row caps |
|---|---|---|
licensed | a valid licence | none (or the licence's own) |
expiring | an offline licence's last 30 days | none; the log warns daily |
grace | an offline licence past its expiry, for its grace (default 30 days); or an online key whose validator has been unreachable for 7โ30 days since its last good validation | none; the log errors daily |
expired | past the grace | the free caps; data stays readable |
invalid | an offline licence that does not verify (edited, unknown or retired key, malformed, a grace over 90 days); or a validator state of licensed with no AITO_LICENSE_KEY set | the free caps, with the reason in the log |
free | no licence, a rejected key, or a validator unreachable with no good validation in the last 30 days | the free caps |
A tampered or otherwise invalid licence is never treated as valid, not even for a grace. A grant of more than 90 days of grace is refused as invalid.
The licence clock. Setting the host clock back cannot un-expire a licence: for each
licence the server keeps the latest time it has evaluated it at, in
.aito-license-clock in the state directory, and evaluates at the later of that and the
system clock. If the system clock was once wrong forward, the reason says the evaluation
time is ahead of the system clock and names that file. Delete it to reset: the next
poll, within a minute, re-evaluates. A renewed licence has its own entry and
is not affected.
An online key whose validator is unreachable keeps serving on its last good validation (fail open) for 30 days, and says so: the reason names the unreachable validator and the date of that validation. A response from the validator that is not a licence answer (a captive portal or a proxy page) counts as unreachable, never as a rejection.
GET /api/v2/_version (with a read key) reports the state and why:
"license": {
"state": "licensed",
"reason": "licence lic_01Jโฆ (production) valid until 2027-10-20T00:00:00Z",
"since": "2026-10-20T08:00:00Z",
"source": "file /run/secrets/aito-license",
"limits": { "rowsPerTable": 0, "rowsTotal": 0 },
"licenseId": "lic_01Jโฆ",
"tier": "production",
"instances": 3,
"expiresAt": "2027-10-20T00:00:00Z"
}
source says where the licence came from, and limits gives the caps in effect, where
0 means none. During grace and expired a graceEndsAt is added. The public,
unauthenticated /version carries none of this. A server not started from the Docker
image, and with no offline licence, reports no license block.
Available since: v2.11.8 for offline licences, the live state, the fail-open grace for online keys, and the 403 cap error.
Related
- Common Errors (v2) โ the error envelope and the full code catalogue every limit above reports through.
- Evaluation (v2) โ
maxTime,testSource, and the truncation note. - Schema Design (v2) โ
primaryKey,on_conflict, and vector dimensions. - Environments & Auth (v2) โ API keys and the
429throttling model on shared hosts.