Skip to content

Errors

Errors are RFC 9457 problem documents, served as application/problem+json:

{
  "type": "https://docs.trilocore.ai/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) a documentation URL derived from 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.

The catalogue

Status code Meaning
400 validation_failed the request is malformed — most often a path parameter of the wrong shape
400 idempotency_key_required the endpoint requires Idempotency-Key and it was absent
401 unauthorized missing, malformed, expired or revoked credential
403 forbidden authenticated, but not permitted to perform this operation
403 csrf_required cookie-authenticated writing method with no x-csrf-token header, on a proxied prefix. On a gateway-native prefix (/api/v1/notes/*) the same rejection arrives as 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
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
412 precondition_failed an If-Match / If-None-Match precondition did not hold
413 payload_too_large the body exceeds the limit for this surface
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
500 internal an unexpected failure inside the API
502 upstream_unavailable the service behind the front door did not answer

Three further codes come from the idempotency layer and are covered in Idempotency: invalid_idempotency_key, idempotency_key_reuse and idempotency_conflict.

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.

Reading an error safely

Not every error is a problem document. Three of the idempotency errors are returned as plain application/json with a minimal body and no code member:

{ "error": "idempotency_conflict" }

A client that reads only body.code sees undefined for those three and falls through to its "unknown error" branch — which, for a 409 raised by a request that is still in flight, usually means retrying something it should have waited on.

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 idempotency errors."""
    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 idempotency errors.
    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, 422 No. Fix the request; the same request will always fail
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
500, 502 Yes, with exponential backoff and jitter

Any retry of a POST should carry the same Idempotency-Key as the original attempt. Note that a response with a status of 500 or above is never stored for replay, so a retry after one of those genuinely re-executes.