Caura / docs
Reference

Errors and Status Codes

The canonical error envelope shared by REST and MCP, plus the HTTP status → code mapping.

Both REST and MCP emit the same error shape. Source: core-api/src/core_api/errors.py (added in PR #58).

Canonical envelope

{
  "error": {
    "code": "<UPPER_SNAKE>",
    "message": "<human-readable>",
    "details": { "...": "optional" }
  }
}

Dispatch on error.code — it's the machine-readable signal and is identical across both surfaces.

REST back-compat

REST responses keep a top-level detail field alongside error so existing clients reading response.json()["detail"] keep working:

{
  "detail": "Memory not found",
  "error": { "code": "NOT_FOUND", "message": "Memory not found" }
}

detail is the deprecated mirror. New clients should switch to error.code.

For FastAPI request-validation failures (422), detail is the original list of validation errors and error.details.errors carries the same list aggregated.

MCP envelope

MCP tools return the same error envelope, JSON-serialized, plus _latency_ms:

{
  "error": {
    "code": "INVALID_ARGUMENTS",
    "message": "Unknown op 'wat'.",
    "details": { "op": "wat", "expected_ops": ["read", "update", "..."] }
  },
  "_latency_ms": 7
}

Pre-PR-#58 MCP tools returned bare "Error (XXX): ..." strings — that format is gone.

HTTP status → canonical code

From STATUS_TO_CODE in errors.py:

StatusCode
400BAD_REQUEST
401UNAUTHORIZED
402PAYMENT_REQUIRED
403FORBIDDEN
404NOT_FOUND
405METHOD_NOT_ALLOWED
408REQUEST_TIMEOUT
409CONFLICT
410GONE
413PAYLOAD_TOO_LARGE
415UNSUPPORTED_MEDIA_TYPE
422INVALID_ARGUMENTS
429RATE_LIMITED
500INTERNAL_ERROR
501NOT_IMPLEMENTED
502UPSTREAM_ERROR
503UNAVAILABLE
504UPSTREAM_TIMEOUT

Statuses not in the table fall back to HTTP_<status> (e.g. HTTP_418).

Rate-limit refusals (429)

A 429 comes from one of two limiters. Both answer with the same RATE_LIMITED envelope and both carry Retry-After, so a client can handle throttling in one branch — but they differ in whether they can tell you anything about your remaining budget.

The application (core-api)

Rate-limited routes are governed by a per-window limiter with response headers enabled. Those routes carry the quota headers on every response, not just refusals, so a well-behaved client can slow down before it is ever refused:

HeaderMeaning
X-RateLimit-LimitRequests permitted in the current window
X-RateLimit-RemainingRequests left in it
X-RateLimit-ResetUnix timestamp when the window rolls over

When the limit is hit, the 429 carries those same headers plus Retry-After, and a message naming the limit that fired.

The edge gateway

The nginx gateway that fronts managed deployments applies its own per-tenant and per-IP limits, and refuses a burst at the perimeter before the request reaches any service:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 1
{
  "detail": "Rate limit exceeded. Try again later.",
  "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Try again later." }
}

These refusals carry Retry-After but no X-RateLimit-* — nginx's limiters hold no per-tenant quota state to report, only a leaky bucket that is currently empty. So the absence of quota headers on a 429 is itself the signal that you were stopped at the edge rather than by the application.

Backing off

Honour Retry-After. The gateway's buckets refill per second, so the value is small (typically 1) and retrying sooner just burns another slot. Treat a missing Retry-After as "retry with your own backoff" rather than "retry immediately" — a 429 raised by an intermediary between you and the gateway carries neither the envelope nor the header.

Trust-level errors

Trust failures come from core_api.services.trust_service.parse_trust_error and are re-wrapped as canonical FORBIDDEN with the required vs. caller's level in details.

Idempotency

The write route (POST /api/v1/memories) accepts an Idempotency-Key header (IDEMPOTENCY_HEADER in core-api/src/core_api/middleware/idempotency.py). A retry within the cache window with the same key short-circuits to the original response without consuming a write slot.