Error handling | Ory

On this page

Ory Talos API errors follow Google AIP-193, which wraps google.rpc.Status in a top-level error object.

Error response format

Every non-2xx response uses the AIP-193 envelope:

{
  "error": {
    "code": 404,
    "message": "API key not found",
    "status": "NOT_FOUND",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.ErrorInfo",
        "reason": "API_KEY_NOT_FOUND",
        "domain": "www.ory.com/talos",
        "metadata": {
          "key_id": "01J9X7…"
        }
      },
      {
        "@type": "type.googleapis.com/google.rpc.RequestInfo",
        "requestId": "0d7f1a4c-1b4e-4f0f-9d2f-4f0e2b6f1a3c"
      }
    ]
  }
}
Field Description
error.code HTTP status code (integer). Matches the response status line.
error.message Human-readable summary. Suitable for logging, not for end-user display.
error.status Canonical gRPC status name (for example NOT_FOUND, PERMISSION_DENIED).
error.details List of typed details. google.rpc.ErrorInfo carries the machine-readable reason and domain.

details is an open list. Select entries by @type instead of by position, and ignore types you don't handle: Talos can add detail types without a breaking change.

The HTTP status code follows the canonical gRPC-to-HTTP mapping. For example, NOT_FOUND returns HTTP 404; PERMISSION_DENIED returns HTTP 403.

error.status comes from the error's canonical gRPC status, not from the HTTP code, so two errors that share an HTTP code stay distinguishable. A state conflict returns FAILED_PRECONDITION while a duplicate resource returns ALREADY_EXISTS, even though both use HTTP 409.

Reading the reason

The stable, machine-readable identifier is error.details[*].reason on the ErrorInfo detail. Match on reason — never on message, which can change between releases. The domain is www.ory.com/talos. For client errors, the optional metadata map carries contextual string values. Treat keys as advisory and don't require their presence.

REASON=$(echo "$RESPONSE" | jq -er '.error.details[]? | select(."@type" | endswith("ErrorInfo")).reason')

Correlating a request

When a request carries an X-Request-ID header, Ory Talos echoes it in the response and repeats it in a google.rpc.RequestInfo detail as requestId. Ory Talos doesn't generate request IDs: without the header, the response has neither the header nor the RequestInfo detail.

REQUEST_ID=$(echo "$RESPONSE" | jq -r '.error.details[]? | select(."@type" | endswith("RequestInfo")).requestId')

Treat request IDs as opaque. They aren't guaranteed to be unique, and they carry no authorization meaning.

Ory Network

The Ory Network edge sets X-Request-ID on every request and replaces any value you send, so you can't correlate with your own ID. When you contact Ory support, quote the cf-ray response header, which Cloudflare adds to every response, or the requestId.

Self-hosted

Set X-Request-ID at your proxy or load balancer, or send it from the client. Ory Talos drops an ID longer than 128 bytes instead of shortening it. Search your Ory Talos logs for the ID to find the matching entry. A request that arrives without X-Request-ID has no request ID in Ory Talos logs, spans, or error responses, so set one at the proxy to correlate a client error with server logs.

Browser clients need CORS to send and read the header. When serve.http.cors.enabled is true, the default serve.http.cors.allowed_headers and serve.http.cors.exposed_headers already cover X-Request-ID. If you set either list yourself, it replaces the default, so include X-Request-ID in both: the allowed list lets the browser send the ID, and the exposed list lets it read the echoed ID.

Server errors are opaque

5xx responses never describe what failed inside the server. error.message is always the generic message An internal server error occurred, please contact the system administrator. The metadata map is omitted, and reason is one of INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE, or GATEWAY_TIMEOUT:

{
  "error": {
    "code": 503,
    "message": "An internal server error occurred, please contact the system administrator",
    "status": "UNAVAILABLE",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.ErrorInfo",
        "reason": "SERVICE_UNAVAILABLE",
        "domain": "www.ory.com/talos"
      },
      {
        "@type": "type.googleapis.com/google.rpc.RequestInfo",
        "requestId": "0d7f1a4c-1b4e-4f0f-9d2f-4f0e2b6f1a3c"
      }
    ]
  }
}

error.code and error.status still identify the condition, so keep using them to decide whether to retry. The underlying error stays in the server logs.

Verification errors

The verify endpoint (POST /v2alpha1/admin/apiKeys:verify) is an exception. A verification failure is part of the normal verification result, not a transport-level error, so it returns 200 OK with is_valid: false and a structured error code:

{
  "is_valid": false,
  "error_code": "VERIFICATION_ERROR_REVOKED",
  "error_message": "The API key has been revoked."
}

Treat the response as successful; act on is_valid and error_code. Only fall back to the AIP-193 envelope above when the HTTP status isn't 2xx (for example, when the verify request itself is malformed).

For the complete list of verification error codes (VERIFICATION_ERROR_*), see the error codes reference.

HTTP status codes

Key categories:

Retry strategy

Safe to retry

Not safe to retry without an idempotency key

Idempotency key

When issuing API keys, include request_id in the request body to deduplicate retries:

# request_id is only available via the HTTP API.

talos keys issue "my-service" --actor user_1 -e "$TALOS_URL"
curl -s -X POST "$TALOS_URL/v2alpha1/admin/issuedApiKeys" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-service",
    "actor_id": "user_1",
    "request_id": "unique-request-id-123"
  }' | jq .

Repeating the same request_id returns the originally created key instead of issuing a new one. Use a stable, client-generated UUID per logical issuance. The value is capped at 36 characters.

Recommended backoff

attempt 1: wait 100ms

attempt 2: wait 200ms

attempt 3: wait 400ms

attempt 4: wait 800ms

attempt 5: wait 1600ms (give up after this)

Add jitter (random 0-50% of the wait time) to avoid thundering-herd effects.