Errors¶
Errors are RFC 9457 problem documents, served as
application/problem+json:
{
"type": "https://platform.trilocore.com/docs/en/api/errors/#not_found",
"title": "Not found",
"status": 404,
"detail": "no such session",
"code": "not_found",
"error": "not_found",
"message": "no such session",
"instance": "/api/v1/bevm/sessions",
"request_id": "req_e345361146654a96898ff450881779d0"
}
Every error carries a request_id that matches the x-request-id response header. Log it. It is
the one field that makes a support conversation tractable.
The fields¶
| Field | Type | Notes |
|---|---|---|
code |
string | the machine contract. Stable lower_snake_case; branch on this |
status |
number | repeats the HTTP status |
title |
string | short, stable, human-readable summary for a given code |
detail |
string | varies per occurrence; safe to show a user, unsafe to parse |
type |
string (URI) | this page, anchored at the code: https://platform.trilocore.com/docs/en/api/errors/#<code>. May move — do not branch on it |
instance |
string | the request path that produced the error |
request_id |
string | equals the x-request-id header |
error |
string | legacy alias, always equal to code |
message |
string | legacy alias, always equal to detail |
code is the contract, not type and not title. type is a documentation URL and may be
re-pointed; title and detail are prose and may be reworded. Some errors also merge extra
top-level members — quota_exhausted carries used, limit, plan and reset_at;
method_not_allowed carries allow; request-contract rejections may carry errors and
truncated (see Field-level validation).
The catalogue¶
Every row is the target of the type URI for its code: …/errors/#not_found lands on the
not_found row.
| Status | code |
Meaning |
|---|---|---|
400 |
validation_failed |
the request is malformed — most often a path parameter of the wrong shape, or a malformed session tag (see Authentication) |
400 |
idempotency_key_required |
the endpoint requires Idempotency-Key and it was absent |
400 |
duplicate_query_param |
a query parameter was sent more than once |
400 |
malformed_body |
the body is not well-formed JSON, is nested more than 64 levels deep, or contains a number outside IEEE-754 double range |
400 |
malformed_request |
the request violates HTTP itself — header or path bounds, or an RFC 9110/9112 violation |
400 |
invalid_body |
the request body could not be read — for example the connection closed part-way through it |
400 |
invalid_page_token |
the page_token is not one this service issued for this list, was altered, or has expired (tokens last 3 days). Carries param: "page_token". Start again without page_token — see Pagination |
400 |
page_token_filter_mismatch |
the page_token was issued for different filters, a different order or a different caller. Send it back only with the exact filters that produced it — see Pagination |
401 |
unauthorized |
missing, malformed, expired or revoked credential. Carries a WWW-Authenticate: Bearer challenge. An expired API key answers exactly like a revoked or unknown one |
401 |
unauthenticated |
a live-history WebSocket upgrade arrived without a verifiable identity |
403 |
forbidden |
authenticated, but not permitted to perform this operation |
403 |
insufficient_scope |
the API key's scopes do not cover this request. Carries WWW-Authenticate: Bearer error="insufficient_scope", scope="<product>:<access>" naming the scope it needed — see Scopes. Use a key that has that scope |
403 |
csrf_required |
cookie-authenticated writing method with no x-csrf-token header. Uniform across every prefix since 2026-08-21, when the last gateway-native surface, notes (now /api/v1/workspaces/notes/*), became a proxied one; it used to answer forbidden with csrf_required in detail. Match the 403, not one code string |
403 |
persona_forbidden |
the credential's persona may not use this endpoint |
403 |
org_role_forbidden |
your organisation role may not perform this write — for example an auditor or viewer writing a Contract IDE file |
404 |
not_found |
the resource does not exist, or is not yours |
405 |
method_not_allowed |
wrong method for this path; the response carries an accurate Allow header and an allow array |
409 |
conflict |
the request conflicts with the resource's current state |
410 |
resume_impossible |
only ever inside a terminal event: error on a stream you tried to resume with Last-Event-ID: the stream is unknown, expired or not yours, or the events you missed are no longer kept. The body of a resume is ignored and nothing re-ran — send a new request to start again. See Streaming responses |
412 |
precondition_failed |
an If-Match / If-None-Match precondition did not hold |
413 |
payload_too_large |
the body exceeds the front door's limit: 1 MiB, or 64 KiB on the unauthenticated /api/v1/public/* prefix |
413 |
body_too_large |
the body exceeds the request-contract limit for this endpoint |
414 |
query_too_large |
the query string exceeds the limit for this endpoint |
415 |
unsupported_media_type |
endpoints accept application/json only — send that Content-Type. A request body with a Content-Encoding other than identity is refused too: send bodies uncompressed |
422 |
schema_violation |
the body or query did not satisfy this endpoint's contract; carries errors naming each field — see Field-level validation |
422 |
unexpected_query |
this endpoint accepts no query parameters, and some were sent |
428 |
precondition_required |
this operation requires a conditional request; send If-Match |
429 |
rate_limited |
too many requests — see Rate limits |
429 |
quota_exhausted |
the plan's daily allowance is spent — see Rate limits |
429 |
concurrency_limit |
you already have the maximum number of this kind of work running — for example unfinished analysis jobs on a session, or compiles in your organisation; carries retry-after |
4xx |
request_rejected |
the service behind the front door refused the request with a status this catalogue has no name for; the HTTP status is the signal |
500 |
internal |
an unexpected failure inside the service that owns the resource |
500 |
internal_error |
an unexpected failure inside the front door itself |
501 |
not_implemented |
a deliberate "not supported here" — for example an EVM-only operation on a Solana session. Not an outage |
502 |
upstream_unavailable |
the service behind the front door could not be reached — the connection was refused or reset. Nothing ran |
504 |
upstream_timeout |
the request reached the service, which did not answer before the front door's deadline. It may still be running, so there is no retry-after: retry a POST, PUT, PATCH or DELETE only with the same Idempotency-Key |
504 |
deadline_exceeded |
the service behind the front door received the request after its time budget was already spent, and stopped before doing anything. Nothing ran: retry with the same Idempotency-Key |
502 |
upstream_redirect_refused |
the service answered with a redirect the front door does not follow or forward |
503 |
stream_capacity |
every streaming slot is in use; carries retry-after |
503 |
placement_pressure |
the service is saturated and is deferring new work — new sessions on this plan, compile jobs, solver runs; carries retry-after |
503 |
placement_unavailable |
no session host could be assigned right now; carries retry-after |
503 |
draining |
this instance is shutting down; the same request succeeds on another |
Five further codes come from the idempotency layer and are covered in
Idempotency: invalid_idempotency_key,
idempotency_key_reuse, idempotency_conflict,
idempotency_unavailable and bad_gateway.
A service behind the front door can also answer with a code of its own — for example
mfa_required from sign-in, which carries the mfa_token the next step needs. Those codes keep
their own meaning and extra members; their type still points at this page, which does not list
them. Branch on them where the endpoint documents them, and fall back to the HTTP status.
Contract search¶
The contract-search endpoints under /api/v1/radar/code/* add their own codes, mostly about the query
language. Their type uses the same base, anchored at the code, and not_found and internal
mean what they mean above.
| Status | code |
Meaning |
|---|---|---|
400 |
tql_syntax |
the query could not be parsed; detail gives the offset |
400 |
tql_unknown_field |
the query names a field that does not exist; carries field |
400 |
tql_unknown_opcode |
the value is not an EVM opcode |
400 |
tql_value_not_indexed |
a real opcode this index does not carry; carries field, value and indexed_values |
400 |
tql_negation_only |
every clause is negated; add at least one positive clause |
400 |
tql_forbidden_construct |
a construct this version of the query language does not support |
400 |
tql_too_complex |
the query exceeds a length, nesting or token bound |
400 |
cursor_query_mismatch |
a page_token was sent with a different query from the one that produced it |
400 |
facets_invalid |
the search:facets body is malformed: an unknown member, facets missing, empty or longer than 32, or an entry that is blank or longer than 1024 bytes; carries facet_index when one entry is at fault |
409 |
cursor_generation_unavailable |
the page_token was minted by a dataset generation the index no longer serves; restart from page one (omit page_token). Carries generation |
403 |
tql_field_gated |
the field requires a verified account; carries field |
422 |
tql_field_unavailable |
the field exists but is not available yet; carries field and available_in_phase |
401 |
origin_unauthenticated |
the front door's own credential to the search service was refused. Your request did not cause it: retry later, and quote the request_id if it persists |
503 |
index_busy |
the search index is at capacity and did not start your request; carries retry-after. Retry after that many seconds |
A search:facets request that fails on one of its clauses is refused whole, with the clause's own
tql_* code above plus two extension members: facet_index (0-based) and clause. An
internal error from these endpoints carries a request_id; it never carries the underlying
failure text.
The catalogue is closed and append-only. New codes may be added; existing ones do not change
meaning. Write your client so an unrecognised code falls back to branching on the HTTP status.
Field-level validation: the errors array¶
When a request fails its endpoint's contract, the rejection names the fields. A
schema_violation problem document carries an errors array, one item per violation:
{
"type": "https://platform.trilocore.com/docs/en/api/errors/#schema_violation",
"title": "Request contract violation",
"status": 422,
"detail": "2 fields did not satisfy the contract for this endpoint.",
"code": "schema_violation",
"errors": [
{ "pointer": "/gas_limit", "code": "out_of_range",
"detail": "must be >= 21000 and <= 1000000000" },
{ "pointer": "/gas_price", "code": "unknown_field",
"detail": "this endpoint does not accept `gas_price`." }
],
"truncated": false,
"instance": "/api/v1/bevm/sessions/current/transactions",
"request_id": "req_01J..."
}
Items rejected at the front door carry three members:
| Member | What it is |
|---|---|
pointer |
an RFC 6901 JSON Pointer into the request document you sent — /gas_limit, /steps/2/to |
code |
which rule was violated, from the closed table below. Branch on this |
detail |
developer-facing English describing the rule. Not localized |
Guarantees you can build on:
- At most 20 items. When there were more violations than that,
truncatedistrue; fix what you can see and resubmit. - Pointers for non-body surfaces use a synthetic root: a query parameter is
/query/<param>, a header is/headers/<name>, and the request path is/path. Everything without one of those prefixes points into the JSON body. - No message describes the value you sent — not in
detail, not in the top-leveldetail. Messages name the field and the rule it broke. This is deliberate: an API that reflects your input back to you can be used to bounce hostile content through a reader, and the value is often the very thing that should not be copied around — a credential pasted into the wrong parameter is the single most common way a validation error happens. errorsmay be absent: rejections raised before the document could be evaluated field by field (malformed_body,body_too_large,unsupported_media_type, …) have nothing to point at. Always fall back to the top-leveldetail.
One shape, two sources of code
A request can also be rejected field by field by the service behind the front door. That
rejection arrives in the same shape — errors items with pointer, code and detail, and a
one-line summary in the top-level detail — so one handler reads both:
for (const e of problem.errors ?? []) {
showFieldError(e.pointer.replace(/^\//, "").replace(/\//g, "."), e.detail);
}
Two differences are worth knowing. Behind the front door, an item's code is that service's
own validation type (for example missing or string_type) rather than one of the codes in
the table below, so treat an unrecognised item code as invalid. And the limit there is 50
items rather than 20, with truncated: true beyond it. The value you sent is never echoed on
either side.
Which side rejects a request depends on where it was stopped, which can change as validation is switched on endpoint by endpoint (see Rollout).
Rollout¶
Field-level validation is being enabled endpoint by endpoint. On many endpoints today a request that violates the contract is still accepted, or is rejected by coarser checks that name no field — so do not count on receiving these rejections everywhere yet, and do not use them as a substitute for validating input yourself. Handle them as part of the error contract; more endpoints will start emitting them over time.
The violation codes¶
This table is closed and append-only, like the top-level catalogue. Every errors[].code in a
rejection raised at the front door is one of:
code |
Meaning | What to do |
|---|---|---|
unknown_field |
this endpoint does not accept the named member | remove it; check for a typo of a documented field, and check you are calling the endpoint you think you are |
missing_field |
a required member was absent | send it |
wrong_type |
the value is the wrong JSON type | send the type the reference documents — e.g. a number, not a numeric string, or vice versa |
out_of_range |
a number is outside the accepted bounds | detail states the bounds; clamp or reject the value before sending |
too_long |
a string exceeds its maximum length | shorten it; detail states the limit |
too_short |
a string is under its minimum length | usually an empty string where a value is required |
too_many |
an array has more items than accepted | send fewer, or split the work across multiple requests |
too_few |
an array has fewer items than required | usually an empty array where at least one item is required |
bad_format |
a string does not match its required format | check the documented shape — addresses, hashes and hex data all have exact spellings |
not_in_enum |
the value is not one of the accepted set | send one of the documented values; detail names the set or where to find it |
duplicated |
a value is repeated where entries must be unique | de-duplicate before sending |
too_deep |
the value nests deeper than accepted | flatten the structure |
too_large |
the element is bigger than accepted at this position | reduce it — this is a per-element bound, distinct from the whole-body body_too_large |
malformed_encoding |
the bytes do not decode as claimed | fix the encoding — invalid UTF-8, hex or base64 in a field that requires it |
forbidden_character |
the value contains a character not allowed here | strip control characters and other disallowed input; detail says what was refused |
not_allowed_here |
the member is recognised, but not permitted in this context | it conflicts with another field or the endpoint's mode; consult the reference for which combinations are valid |
invalid |
the value fails a rule not covered by a more specific code | read detail; treat like any other non-retryable rejection |
None of these are retryable: the same request will fail the same way. Fix the named fields and send a corrected request.
Consuming it¶
Map each pointer onto your own input (a form field, a config key), and fall back to the
top-level detail when errors is absent:
const body = await response.json();
if (Array.isArray(body.errors)) {
for (const e of body.errors) {
// e.pointer is an RFC 6901 JSON Pointer: "/gas_limit", "/steps/2/to",
// or "/query/<param>", "/headers/<name>", "/path" for non-body surfaces.
showFieldError(e.pointer, e.detail); // e.code for programmatic handling
}
if (body.truncated) showFormError("Additional fields are also invalid.");
} else {
showFormError(body.detail ?? "Request rejected."); // no field-level data
}
Reading an error safely¶
Not every error is a problem document. The idempotency layer's 502 bad_gateway is returned as
plain application/json with a minimal body and no code member:
A client that reads only body.code sees undefined for it and falls through to its "unknown
error" branch — which usually means retrying with a new key an operation that already ran.
Read the identifier defensively. This one expression is correct for every error the API returns,
because problem documents carry error as a legacy alias of code:
Do not sniff Content-Type for exactly application/json to decide whether a body is an error —
application/problem+json will fail that test, and it parses as ordinary JSON anyway.
What errors deliberately do not tell you¶
Three behaviours look like bugs and are not:
404 where you expected 403. A resource that exists but belongs to someone else is
reported exactly like one that does not exist. The API will not confirm another tenant's
resources to you, so absence and denial are intentionally indistinguishable.
401 where you expected 404. Authentication is checked before the path is resolved, so an
unauthenticated request to a path that does not exist answers 401. Route existence is not
disclosed to anonymous callers. Authenticate before concluding an endpoint is missing.
Terse 500 and 502 details. The detail on a server-side error is a fixed string.
Upstream URLs, internal hostnames and database text never reach a client. When you need to
escalate one of these, the request_id is what identifies it — the body deliberately carries
nothing else of diagnostic value.
Retrying¶
| Status | Retry? |
|---|---|
400, 403, 404, 405, 412, 413, 414, 415, 422 |
No. Fix the request; the same request will always fail. For 403 insufficient_scope, use a key with the scope named in WWW-Authenticate |
401 |
Only after refreshing the credential — otherwise no |
409 conflict |
Only if your own state changed |
409 idempotency_conflict |
Yes, after retry-after |
428 |
Yes, once, with the required precondition header |
429 |
Yes, after retry-after — see Rate limits |
501 not_implemented, 502 upstream_redirect_refused |
No. The answer will not change on retry |
500, 502, 503 |
Yes, with exponential backoff and jitter, honouring retry-after when present |
504 upstream_timeout |
A read, yes. A POST, PUT, PATCH or DELETE only with the same Idempotency-Key: the first attempt may still be running |
504 deadline_exceeded |
Yes, with the same Idempotency-Key. Nothing ran |
Any retry of a POST, PUT, PATCH or DELETE should carry the same Idempotency-Key as the
original attempt. A response with a status of 500 or above is never stored for replay, so a
retry after the service answered one genuinely re-executes. The exception is a failure after
your request reached the service — the front door timed out (504 upstream_timeout), or the
connection broke mid-response:
the first attempt may still be running, so the key stays locked and a retry answers 409
idempotency_conflict until the service's deadline plus 30 seconds has passed. See
Idempotency.
Streaming responses¶
A streamed response always ends with an explicit terminal record — never a silent close. If the connection simply stops, the stream was cut, not finished:
- The Composer replay (
POST /api/v1/bevm/sessions/{uid}/transactions:replay, NDJSON) ends with anendline or anerrorline whoseproblemmember is a problem document with the fields above. A replay is stateful and is not resumable: after a cut the fork may be partially mutated, so inspect the session before re-running it. The Composer SDK raisesReplayStepErrorfor both anerrorline and a cut. - Server-sent-event streams end with
event: errorwhosedatais a problem document (on the OpenAI-compatible endpoints, the OpenAI{"error": {…}}envelope instead). The front door adds one itself,502 bad_gateway, when the service behind it fails mid-stream. Every event carries anid; a stream that supports resuming replays the events after theLast-Event-IDyou send on a new request — without the originalIdempotency-Key— and never starts the work twice. Anything it cannot resume answers the single terminal eventresume_impossible. The server-sent-event streams belong to the AI surfaces, which are not yet available.
Read a stream promptly. A client that stops reading for 60 seconds is disconnected, and the front door stops reading from the service behind it.