Error Handling Guide¶
How to handle API errors gracefully.
Error Response Structure¶
All errors follow this format:
{
"status": "error",
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message",
"details": {}
},
"meta": {
"timestamp": "2025-01-06T12:00:00.000Z",
"request_id": "uuid-for-debugging"
}
}
Error Code Reference¶
Authentication Errors (401)¶
| Code | Message | Cause | Solution |
|---|---|---|---|
AUTH_INVALID_CREDENTIALS |
Invalid credentials | Wrong API key or token | Check API key format |
AUTH_TOKEN_EXPIRED |
Token expired | JWT past expiration | Refresh token or re-authenticate |
AUTH_TOKEN_INVALID |
Invalid token | Malformed or tampered JWT | Re-authenticate |
AUTH_UNAUTHORIZED |
Unauthorized | Missing Authorization header | Add Bearer token |
Handling:
if response.status_code == 401:
error_code = response.json().get("error", {}).get("code")
if error_code == "AUTH_TOKEN_EXPIRED":
# Refresh token and retry
new_token = refresh_token(refresh_token)
retry_request(new_token)
else:
# Re-authenticate
authenticate()
Forbidden Errors (403)¶
| Code | Message | Cause | Solution |
|---|---|---|---|
AUTH_INSUFFICIENT_PERMISSIONS |
Insufficient permissions | Token lacks required scope | Request token with correct scopes |
AUTH_FORBIDDEN |
Cannot create/set {type} via this endpoint | Attempted to create or update a memory with a protected type (identity_core, personality_trait) |
Use /api/v2/personality endpoints instead |
Protected Memory Types:
identity_core and personality_trait cannot be created or updated via POST /api/v2/memories or PUT /api/v2/memories/{id}. These types are managed exclusively through the /api/v2/personality endpoints.
{
"status": "error",
"error": {
"code": "AUTH_FORBIDDEN",
"message": "Cannot create identity_core memories via this endpoint. Use /personality endpoints instead.",
"details": null,
"field_errors": null
},
"meta": {
"timestamp": "2026-02-11T10:13:32.467536+00:00",
"version": "2.0.0",
"request_id": "uuid-for-debugging"
}
}
Handling:
if response.status_code == 403:
error_code = response.json().get("error", {}).get("code")
if error_code == "AUTH_FORBIDDEN":
# Protected memory type or insufficient permissions
# Use /personality endpoints for identity_core and personality_trait
pass
else:
# Token is valid but lacks required permissions
request_elevated_access()
Validation Errors (422)¶
| Code | Message | Cause | Solution |
|---|---|---|---|
VAL_VALIDATION_FAILED |
Validation failed | Request body invalid | Check request format |
VAL_FIELD_REQUIRED |
Required field missing | Missing required parameter | Add required field |
VAL_FIELD_TOO_LONG |
Field too long | Content exceeds max length | Truncate, split into multiple memories, upload as a document, or store as an artifact with a summary memory linking to it |
VAL_FIELD_INVALID |
Invalid field value | Wrong type or format | Check field type |
VAL_UUID_INVALID |
Invalid UUID format | Malformed UUID | Use valid UUID v4 |
VAL_ENUM_INVALID |
Invalid enum value | Value not in allowed list | Use valid enum value |
Field-Level Errors:
Validation errors include a field_errors object that identifies exactly which fields failed:
{
"status": "error",
"error": {
"code": "VAL_VALIDATION_FAILED",
"message": "Request validation failed",
"details": null,
"field_errors": {
"content": ["Field required"],
"importance": ["Input should be a valid number, unable to parse string as a number"]
}
},
"meta": {...}
}
Each key in field_errors is the field name, and the value is an array of error messages for that field.
Handling:
if response.status_code == 422:
error = response.json().get("error", {})
field_errors = error.get("field_errors", {})
# Check specific field errors
if "content" in field_errors:
print(f"Content error: {field_errors['content']}")
# Or iterate all field errors
for field, messages in field_errors.items():
print(f"{field}: {', '.join(messages)}")
Not Found Errors (404)¶
| Code | Message | Cause | Solution |
|---|---|---|---|
RES_NOT_FOUND |
Resource not found | Memory/document doesn't exist | Verify resource ID |
RES_MEMORY_NOT_FOUND |
Memory not found | Memory ID doesn't exist | Check memory UUID |
RES_DOCUMENT_NOT_FOUND |
Document not found | Document ID doesn't exist | Check document UUID |
Handling:
Conflict Errors (409)¶
| Code | Message | Cause | Solution |
|---|---|---|---|
RES_ALREADY_EXISTS |
Resource already exists | Duplicate creation attempt | Use existing resource |
RES_RELATIONSHIP_EXISTS |
Relationship exists | Duplicate relationship | Skip or update existing |
Checkpoint Name Uniqueness:
Checkpoint names are unique per tenant. Attempting to save a context with a name that already exists returns a 409 with RES_ALREADY_EXISTS:
{
"status": "error",
"error": {
"code": "RES_ALREADY_EXISTS",
"message": "Context with identifier 'My Checkpoint' already exists",
"details": {
"resource_type": "Context",
"identifier": "My Checkpoint"
},
"field_errors": null
},
"meta": {
"timestamp": "2026-02-11T09:58:31.131531+00:00",
"version": "2.0.0",
"request_id": "uuid-for-debugging"
}
}
The MCP save_context tool surfaces this as a DUPLICATE_NAME error code. See the MCP Integration Guide for details.
Rate Limit Errors (429)¶
| Code | Message | Cause | Solution |
|---|---|---|---|
BIZ_RATE_LIMIT_EXCEEDED |
Rate limit exceeded | Too many requests | Wait and retry |
Headers on 429 responses:
| Header | Description |
|---|---|
x-ratelimit-limit |
Max requests per window |
x-ratelimit-remaining |
Requests left in window |
x-ratelimit-reset |
Unix timestamp when window resets |
retry-after |
Seconds to wait before retrying |
Handling:
if response.status_code == 429:
retry_after = int(response.headers.get("retry-after", 60))
time.sleep(retry_after)
retry_request()
Server Errors (500, 502, 503)¶
| Code | Message | Cause | Solution |
|---|---|---|---|
SYS_INTERNAL_ERROR |
Internal server error | Server-side issue | Retry with backoff |
SYS_SERVICE_UNAVAILABLE |
Service unavailable | Maintenance or overload | Retry later |
SYS_BAD_GATEWAY |
Bad gateway | Upstream service issue | Retry later |
Handling:
if response.status_code >= 500:
# Exponential backoff retry
for attempt in range(max_retries):
time.sleep(2 ** attempt)
response = retry_request()
if response.status_code < 500:
break
Retry Strategy¶
Recommended retry logic:
import time
from typing import Callable
def with_retry(
fn: Callable,
max_retries: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0
):
"""Execute with exponential backoff retry."""
last_error = None
for attempt in range(max_retries):
try:
response = fn()
# Don't retry client errors (except rate limits)
if response.status_code < 500 and response.status_code != 429:
return response
# Handle rate limits
if response.status_code == 429:
retry_after = int(response.headers.get("retry-after", base_delay))
time.sleep(min(retry_after, max_delay))
continue
# Server error - exponential backoff
delay = min(base_delay * (2 ** attempt), max_delay)
time.sleep(delay)
except Exception as e:
last_error = e
delay = min(base_delay * (2 ** attempt), max_delay)
time.sleep(delay)
raise last_error or Exception("Max retries exceeded")
Error Details¶
Some errors include additional context in details:
{
"error": {
"code": "VAL_FIELD_TOO_LONG",
"message": "Content exceeds maximum length",
"details": {
"field": "content",
"max_length": 10000,
"actual_length": 15234
}
}
}
Common detail fields:
| Field | Description |
|---|---|
field |
Which field caused the error |
resource_type |
Type of resource (Memory, Document, etc.) |
resource_id |
ID of affected resource |
max_length |
Maximum allowed length |
allowed_values |
List of valid enum values |
Debugging Tips¶
- Check
request_id- Include in support requests - Log full error response - Don't just log status code
- Validate before sending - Catch errors client-side
- Use proper content types - Always set
Content-Type: application/json - Check token expiration - Refresh proactively before expiry