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:
| Status | Code |
|---|---|
| 400 | BAD_REQUEST |
| 401 | UNAUTHORIZED |
| 402 | PAYMENT_REQUIRED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 405 | METHOD_NOT_ALLOWED |
| 408 | REQUEST_TIMEOUT |
| 409 | CONFLICT |
| 410 | GONE |
| 413 | PAYLOAD_TOO_LARGE |
| 415 | UNSUPPORTED_MEDIA_TYPE |
| 422 | INVALID_ARGUMENTS |
| 429 | RATE_LIMITED |
| 500 | INTERNAL_ERROR |
| 501 | NOT_IMPLEMENTED |
| 502 | UPSTREAM_ERROR |
| 503 | UNAVAILABLE |
| 504 | UPSTREAM_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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests permitted in the current window |
X-RateLimit-Remaining | Requests left in it |
X-RateLimit-Reset | Unix 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.