Error catalog

Every /v1 error response uses one standardized envelope so SDKs and integrations can handle failures uniformly. The HTTP status code conveys the broad class; the machine-readable code is the stable contract you switch on.

This envelope and its documented code values are part of the frozen API v1 stability contract: within /v1 the envelope shape is stable, documented codes are never removed, and a documented response status code is not dropped from an operation.

Envelope

{
  "error": {
    "code": "spend_cap_exceeded",
    "message": "daily spend cap exceeded",
    "details": {
      "cap_usd": "100.00",
      "spent_usd": "104.20",
      "resets_at": "2026-06-22T00:00:00+00:00"
    }
  }
}
FieldTypeNotes
error.codestringStable, snake_case, machine-readable. Switch on this. Never renamed once published — new conditions get a new code.
error.messagestringHuman-readable, English. May change wording; do not parse it.
error.detailsobjectAlways present (an object). Carries structured, per-error context; {} when there is none. Field set depends on the code.

The envelope is produced centrally by one error type (the error module). Middleware that runs before extraction (auth, regional write-forwarding) emits the same shape via error::error_envelope(...). Unmatched routes return the envelope with code: "not_found" through the router fallback. The regression suite asserts the shape over live HTTP for representative 400/401/403/404 responses; the 402/429/500 code mappings are covered by unit tests. The validation_failed (422) code below covers both list sort/filter validation and broader field-level request validation via the shared ValidatedJson<T> extractor.

Codes

Each code links to its own section below, which carries the details field set and what to do about it. The code is the stable contract — switch on it, never on message.

codeTypical HTTP statusMeaning
bad_request400Malformed or semantically invalid request.
unauthorized401Missing or invalid bearer token.
key_expired401The API key has expired (or is not yet valid).
forbidden403Authenticated but not permitted (insufficient role).
missing_scope403The key lacks a scope required for the operation.
ip_not_allowed403Caller IP is outside the org's allow-list.
org_suspended403The organization is suspended.
residency_violation403Cross-region/residency rule refused the operation.
policy_blocked403Org policy blocked the attempted model/action.
policy_approval_required403Org policy requires approval before the action.
content_safety_blocked403A safety detector blocked the content.
not_found404Resource does not exist (or is not visible to the caller).
conflict409The operation conflicts with current state (e.g. a duplicate or an invalid state transition).
validation_failed422One or more request fields failed validation (an unknown ?sort= field, an out-of-range value, or a body that fails to parse).
gone410Resource permanently unavailable (e.g. revoked key).
payload_too_large413Request body exceeded the configured limit.
spend_cap_exceeded402Daily spend cap reached (or would be, pre-flight).
chain_budget_exceeded402 / 429Run-chain budget (cost/depth/tokens) exceeded.
budget_exceeded402A configured budget (or per-customer limit) for the period would be exceeded.
quota_exhausted402 / 429Prepaid quota is exhausted (402 at ingest; 429 via a rate-limit-policy quota window).
credits_exhausted402The end customer holds a live credit pack (doc 100) whose remaining credits cannot cover the charge.
rate_limited429Rate limit hit.
plan_limit_exceeded402The org consumed its monthly plan entitlement (runs or tokens) on a plan whose overage_policy is block.
insufficient_cohort_size400The requested benchmark cohort holds fewer contributing orgs than the k-anonymity floor, so no aggregate can be released.
policy_expression_error500A policy rule expression failed to evaluate.
no_model_passes_eval503No eligible model passed the eval gate.
service_unavailable503Dependency unavailable (e.g. primary down, queue full).
internal500Unexpected server error.
idempotency_key_conflict409The Idempotency-Key was reused with a different request body.
idempotency_key_processing409A request carrying this Idempotency-Key is still in flight.
session_revoked401The console session cookie is revoked or expired.
support_scope_denied403A support impersonation session attempted an operation outside its granted scope.

bad_request

HTTP 400. Malformed or semantically invalid request.

details: none ({}).

Fix the request. When the failure is field-level, the API returns validation_failed instead, with per-field detail.

unauthorized

HTTP 401. Missing or invalid bearer token.

details: none ({}).

Present a valid Authorization: Bearer <key>. Emitted by auth middleware before handler extraction.

key_expired

HTTP 401. The API key has expired (or is not yet valid).

details: none ({}).

Mint a replacement key. Expiry is a property of the key, so retrying with the same credential cannot clear it.

forbidden

HTTP 403. Authenticated but not permitted (insufficient role).

details: none ({}).

Use a key whose role covers the operation. Distinct from missing_scope, which names the specific missing grant.

missing_scope

HTTP 403. The key lacks a scope required for the operation.

details: required

Mint a key carrying the scope named in details.required; scopes are fixed at mint time.

ip_not_allowed

HTTP 403. Caller IP is outside the org's allow-list.

details: none ({}).

Call from an allow-listed address, or update the org's IP allow-list.

org_suspended

HTTP 403. The organization is suspended.

details: none ({}).

Suspension is an operator/billing state; no client-side retry clears it.

residency_violation

HTTP 403. Cross-region/residency rule refused the operation.

details: none ({}).

Address the region that holds the data. Residency is refused, never silently satisfied by routing elsewhere.

policy_blocked

HTTP 403. Org policy blocked the attempted model/action.

details: reason, attempted, rule_id

details.rule_id names the deciding rule. Change the request, or amend the policy.

policy_approval_required

HTTP 403. Org policy requires approval before the action.

details: reason, attempted, rule_id

Not a permanent refusal: the action proceeds once approved. details.rule_id names the rule that gated it.

content_safety_blocked

HTTP 403. A safety detector blocked the content.

details: detector, sample

details.detector names the detector that fired; details.sample carries the offending excerpt.

not_found

HTTP 404. Resource does not exist (or is not visible to the caller).

details: none ({}).

Deliberately ambiguous: a resource belonging to another tenant is a 404, not a 403, so the code cannot be used to probe for existence across tenants.

conflict

HTTP 409. The operation conflicts with current state (e.g. a duplicate or an invalid state transition).

details: none ({}).

Re-read the resource and retry against its current state. On idempotent surfaces a same-key replay returns the original result rather than this code.

validation_failed

HTTP 422. One or more request fields failed validation (an unknown ?sort= field, an out-of-range value, or a body that fails to parse).

details: fields (array of { field, code, message })

details.fields carries one entry per offending field; see Field-level validation (422) for the per-field code set.

gone

HTTP 410. Resource permanently unavailable (e.g. revoked key).

details: none ({}).

Permanent by definition — retrying cannot succeed.

payload_too_large

HTTP 413. Request body exceeded the configured limit.

details: none ({}).

Split the payload. Bulk ingest surfaces accept batches; a single oversized body does not become acceptable on retry.

spend_cap_exceeded

HTTP 402. Daily spend cap reached (or would be, pre-flight).

details: cap_usd, spent_usd, resets_at, optional reason: "pre_flight"

details.resets_at is when the cap clears. reason: "pre_flight" means the request was refused before incurring cost, not after.

chain_budget_exceeded

HTTP 402 / 429. Run-chain budget (cost/depth/tokens) exceeded.

details: reason, current, attempted, limit

details.reason says which dimension bound — cost, depth or tokens.

budget_exceeded

HTTP 402. A configured budget (or per-customer limit) for the period would be exceeded.

details: budget_id, scope_kind, limit_usd, spent_usd, period_start, period_end

details.scope_kind discriminates the enforcing layer: org | workflow | deployment | department come from budgets; customer comes from a per-customer limit, and there budget_id is the customer-limit row id.

quota_exhausted

HTTP 402 / 429. Prepaid quota is exhausted (402 at ingest; 429 via a rate-limit-policy quota window).

details: quota fields

The 402 and 429 forms are the same exhaustion seen at different layers; neither is transient within the window.

credits_exhausted

HTTP 402. The end customer holds a live credit pack (doc 100) whose remaining credits cannot cover the charge.

details: available_usd, attempted_usd

Distinct from quota_exhausted, which is the org's prepaid dollar balance. A customer with no live pack is not on the credit model and never sees this code.

rate_limited

HTTP 429. Rate limit hit.

details: retry_after_seconds

Back off for the Retry-After header's interval — see Rate limiting. This is the one refusal in this catalog that a plain retry does clear.

plan_limit_exceeded

HTTP 402. The org consumed its monthly plan entitlement (runs or tokens) on a plan whose overage_policy is block.

details: limit_kind (runs|tokens), limit, used

A 402 and deliberately not a 429: the condition is not transient and retrying cannot clear it — only a plan change or the month rolling over will.

insufficient_cohort_size

HTTP 400. The requested benchmark cohort holds fewer contributing orgs than the k-anonymity floor, so no aggregate can be released.

details: minimum_cohort_size — the floor the cohort fell short of.

Widen the cohort. The floor is a privacy guarantee of the benchmark network, not a permission to be granted: a thinner slice is refused rather than published, and the read re-checks the floor independently of how the cohort was requested.

policy_expression_error

HTTP 500. A policy rule expression failed to evaluate.

details: rule_id

A server-side fault in the rule named by details.rule_id, not a caller error — the request was neither allowed nor cleanly denied.

no_model_passes_eval

HTTP 503. No eligible model passed the eval gate.

details: none ({}).

Routing found no candidate meeting the configured eval bar. Relax the gate or add an eligible model.

service_unavailable

HTTP 503. Dependency unavailable (e.g. primary down, queue full).

details: none ({}).

Transient by intent — retry with backoff.

internal

HTTP 500. Unexpected server error.

details: none ({}).

Quote the x-solvano-trace-id response header when reporting: it is the id that locates this exact request in the service's logs.

idempotency_key_conflict

HTTP 409. The Idempotency-Key was reused with a different request body.

details: none ({}).

The key is bound to the body it first succeeded with, so this is a client bug rather than a race: either reuse the key with the original body to replay the stored response, or use a fresh key for the new request.

idempotency_key_processing

HTTP 409. A request carrying this Idempotency-Key is still in flight.

details: none ({}).

Retry after a short delay. A first call that crashed mid-flight is reclaimed automatically once the reservation ages past SOLVANO_IDEMPOTENCY_INFLIGHT_TAKEOVER_SECS, so this does not persist until a GC sweep.

session_revoked

HTTP 401. The console session cookie is revoked or expired.

details: none ({}).

A console-surface code, not an API-key one — the response also clears the stale cookie. Sign in again. Distinct from unauthorized, which is about a bearer token.

support_scope_denied

HTTP 403. A support impersonation session attempted an operation outside its granted scope.

details: reason, scope, method

The grant's scope is fixed when it is issued and deliberately narrower than the impersonated org's own permissions; widening it needs a new grant, not a retry. Every impersonated request is audited whether allowed or denied.

Rate limiting

On rate_limited (and the auth back-off variant), the response also carries a Retry-After header in seconds, mirroring details.retry_after_seconds.

Edge enforcement decisions

The internal edge decision endpoint (POST /v1/edge/decision) denies with these same codes and envelopes — budget_exceeded / quota_exhausted / spend_cap_exceeded (402) and rate_limited (429) — so the proxy relays the identical body and status to its caller. An allow is a 200 JSON verdict, never an error envelope. See docs/user/40-budgets-and-spend-enforcement.md §"Edge enforcement parity".

Field-level validation (422)

validation_failed carries details.fields, one entry per offending field:

{ "error": { "code": "validation_failed", "message": "validation failed",
    "details": { "fields": [
      { "field": "to", "code": "out_of_range",
        "message": "date range 366 days exceeds the 90-day maximum" }
    ] } } }

Each field's code distinguishes parse failures — invalid_json, invalid_type, invalid_content_type, invalid_body — from semantic ones such as out_of_range, empty, or too_many, so SDKs can map them to form/field state. Every endpoint that validates a body emits it through the same path, so the shape is identical wherever you meet it. Current adopters include the bulk run ingest (POST /v1/runs/bulk), the retention-policy upsert (PUT /v1/orgs/{id}/retention-policy), the audit-log SIEM export window (GET /v1/orgs/{id}/audit-log/export), and list ?sort= / filter parsing.

Stability contract

  • code values are append-only: a published code is never renamed or repurposed. New failure conditions introduce a new code.
  • message is informational and may be reworded; clients must not match on it.
  • details may gain fields; clients should ignore unknown ones.

Out of scope

Browser-facing auth flows under /saml/* and /sso/* (OIDC) are interactive redirect endpoints, not part of the JSON /v1 API surface, and keep their own flow-specific response bodies.