Skip to content

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.

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, truncated is true; 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-level detail. 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.
  • errors may 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-level detail.

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:

{ "error": "bad_gateway", "message": "upstream body read failed" }

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:

def error_code(response: httpx.Response) -> str | None:
    """Correct for both problem+json and the plain-JSON bad_gateway error."""
    if response.is_success:
        return None
    try:
        body = response.json()
    except ValueError:
        return None
    return body.get("code") or body.get("error")
async function errorCode(r: Response): Promise<string | undefined> {
  if (r.ok) return undefined;
  try {
    const body = await r.json();
    // `code` for problem+json, `error` for the plain-JSON bad_gateway error.
    return body.code ?? body.error;
  } catch {
    return undefined;
  }
}

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 an end line or an error line whose problem member 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 raises ReplayStepError for both an error line and a cut.
  • Server-sent-event streams end with event: error whose data is 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 an id; a stream that supports resuming replays the events after the Last-Event-ID you send on a new request — without the original Idempotency-Key — and never starts the work twice. Anything it cannot resume answers the single terminal event resume_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.