Error Handling
Global error codes, response formats, and debugging strategies for ObjectStack APIs
The Error Handling Protocol defines standardized error codes, response formats, and debugging strategies across all ObjectStack APIs (HTTP, WebSocket).
Why Standardized Errors Matter
Problem: Traditional APIs return errors inconsistently:
// Different error structures across endpoints 😱
API 1: { error: "Not found" }
API 2: { errors: [{ message: "Not found" }] }
API 3: { success: false, msg: "Not found", code: 404 }
API 4: HTTP 200 OK with { status: "error", ... }Developer pain:
- Every endpoint requires custom error handling logic
- Difficult to show user-friendly messages
- Debugging is nightmare (where did this error originate?)
- Monitoring/alerting is inconsistent
Solution: ObjectStack enforces a single error format across all communication channels. Every error has a machine-readable code, human-readable message, and context for debugging.
Business Value Delivered
Better User Experience
Consistent error messages guide users to resolution. No generic 'Something went wrong' nonsense.
Faster Debugging
Error codes + request IDs = find root cause in seconds. Save hours of log hunting.
Automated Monitoring
Alert on specific error codes (e.g., RATE_LIMIT_EXCEEDED spikes = upgrade prompts working).
Security Hardening
Never leak sensitive info in errors. Attackers can't probe your system via error messages.
Standard Error Response
Every error follows this structure:
{
"success": false,
"error": {
"code": "error_code",
"message": "Human-readable description",
"details": { /* Additional context */ },
"requestId": "req_abc123",
"timestamp": "2024-01-16T14:30:00Z"
}
}Fields:
success: Alwaysfalsefor errorserror.code: Machine-readable error code (use for conditionals)error.message: Human-readable message (show to users or developers)error.details: Additional context (field names, constraints, etc.)error.requestId: Unique request identifier for debuggingerror.timestamp: When error occurred (ISO 8601)
HTTP Status Codes
ObjectStack uses standard HTTP status codes:
| Status | Meaning | When Used |
|---|---|---|
| 400 | Bad Request | Invalid input, validation failure |
| 401 | Unauthorized | Authentication required or failed |
| 403 | Forbidden | Authenticated but insufficient permissions |
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Resource already exists or version mismatch |
| 422 | Unprocessable Entity | Semantic validation failed (e.g. metadata spec validation); also an absent controlled_by_parent master reference — see MISSING_REQUIRED_FIELD |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server-side error |
| 503 | Service Unavailable | Server overloaded or maintenance |
Important: Even on error, response body always includes JSON error object.
Error Codes
Authentication & Authorization
UNAUTHENTICATED
HTTP Status: 401
Meaning: No authentication credentials provided or invalid credentials
Example:
{
"success": false,
"error": {
"code": "UNAUTHENTICATED",
"message": "Authentication required",
"details": {
"hint": "Include 'Authorization: Bearer <token>' header"
}
}
}How to fix:
- Include valid JWT token in
Authorizationheader - Refresh expired tokens
- Re-authenticate user
INVALID_TOKEN
HTTP Status: 401
Meaning: Token is malformed or invalid
Example:
{
"success": false,
"error": {
"code": "INVALID_TOKEN",
"message": "JWT token is invalid",
"details": {
"reason": "signature_verification_failed"
}
}
}How to fix:
- Check token hasn't been tampered with
- Verify token is meant for this API (check
audclaim) - Ensure server secret key is correct
EXPIRED_TOKEN
HTTP Status: 401
Meaning: JWT token has expired
Example:
{
"success": false,
"error": {
"code": "EXPIRED_TOKEN",
"message": "JWT token expired",
"details": {
"expired_at": "2024-01-16T10:00:00Z",
"current_time": "2024-01-16T14:30:00Z"
}
}
}How to fix:
- Refresh token using refresh token flow
- Re-authenticate user
- Check token lifetime settings (typically 15-60 minutes)
PERMISSION_DENIED
HTTP Status: 403
Meaning: Authenticated but insufficient permissions
Example:
{
"success": false,
"error": {
"code": "PERMISSION_DENIED",
"message": "Insufficient permissions to access this resource",
"details": {
"required_permission": "account:write",
"user_permissions": ["account:read"]
}
}
}How to fix:
- Request permission from administrator
- Check row-level security rules
- Verify user role assignments
Validation Errors
VALIDATION_ERROR
HTTP Status: 400
Meaning: Input validation failed (schema validation)
Example:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed for 2 fields",
"details": {
"fields": [
{
"field": "email",
"message": "Invalid email format",
"constraint": "format",
"value": "not-an-email"
},
{
"field": "age",
"message": "Must be at least 18",
"constraint": "min",
"value": 15,
"expected": 18
}
]
}
}
}How to fix:
- Check field constraints in object schema (
GET /api/v1/meta/object/{object}) - Validate input client-side before submission
- Show field-specific errors in UI
Client-side handling:
if (error.code === 'VALIDATION_ERROR') {
error.details.fields.forEach(({ field, message }) => {
showFieldError(field, message);
});
}MISSING_REQUIRED_FIELD
HTTP Status: 400 — with one documented exception, which answers 422 (see below)
Meaning: Required field is missing
Example:
{
"success": false,
"error": {
"code": "MISSING_REQUIRED_FIELD",
"message": "Missing required field: name",
"details": {
"field": "name",
"constraint": "required"
}
}
}Exception — an absent controlled_by_parent master reference answers 422 with no fields.
An object whose sharingModel is controlled_by_parent derives its access from a master
record, so the gate that authorizes writes to it must resolve that master before the
executor runs — and the executor is where required-field validation lives. When the master
reference is absent, whichever of the two refuses first decides the envelope, and that
depends on how the reference is declared:
| Master reference declared as | Refused by | Status | code | fields |
|---|---|---|---|---|
master_detail + required, not readonly/system | required-field validation | 400 | VALIDATION_FAILED | yes |
master_detail with no required | the master-access gate | 422 | MISSING_REQUIRED_FIELD | absent |
master_detail + required + readonly | the master-access gate | 422 | MISSING_REQUIRED_FIELD | absent |
master_detail + required + system | the master-access gate | 422 | MISSING_REQUIRED_FIELD | absent |
a required lookup, when the object declares no master_detail | the master-access gate | 422 | MISSING_REQUIRED_FIELD | absent |
Only the first row is the documented 400 shape. On the other four, messages are prefixed
[Security] Missing master reference: and name the object and the field, but no fields
array rides along.
The same 422 MISSING_REQUIRED_FIELD also answers an update or delete by id whose
stored master reference is null, whatever the declaration. That is a different path: the
caller supplied no such field, so there is no request field to name in fields and no
payload that would fix it.
Why the gate refuses rather than handing over. Required-field validation skips
provenance-flagged fields before its required check is reached (system and readonly
fields are skipped outright) and never fires on a field that is not required at all. On
those four shapes the gate is the only thing refusing the write, and letting it through was
measured to create a detail record whose master reference is null — a record the
controlled_by_parent read filter (fk IN (readable masters)) can never match, so it is
readable by nobody and answers 422 on every later write by id. The refusal is correct; only
its status departs from the rule above.
These shapes are authorable today. Publish-time lint reports a master_detail without
required as a warning (relationship/master-detail-required), and does not report the
readonly, system, or fallback-lookup shapes at all — so a stack can publish clean and
still reach the 422. Branch on code, and read the status off the response rather than
deriving it from this page.
INVALID_FIELD
HTTP Status: 400
Meaning: Field value has wrong type
Example:
{
"success": false,
"error": {
"code": "INVALID_FIELD",
"message": "Field 'age' must be a number",
"details": {
"field": "age",
"expected_type": "number",
"actual_type": "string",
"value": "twenty-five"
}
}
}Resource Errors
RESOURCE_NOT_FOUND
HTTP Status: 404
Meaning: Requested resource doesn't exist
Example:
{
"success": false,
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Account with id 'acc_999' not found",
"details": {
"resource": "account",
"resource_id": "acc_999"
}
}
}How to fix:
- Verify resource ID is correct
- Check user has permission to see resource (row-level security)
- Resource may have been deleted
DUPLICATE_RECORD
HTTP Status: 409
Meaning: Resource with unique constraint already exists
Example:
{
"success": false,
"error": {
"code": "DUPLICATE_RECORD",
"message": "Account with email 'john@acme.com' already exists",
"details": {
"resource": "account",
"constraint": "unique",
"field": "email",
"value": "john@acme.com"
}
}
}How to fix:
- Check for existing resource before creating
- Update existing resource instead of creating new one
- Use different value for unique field
DELETE_RESTRICTED
HTTP Status: 409
Meaning: Operation violates database constraint
Example:
{
"success": false,
"error": {
"code": "DELETE_RESTRICTED",
"message": "Cannot delete account with active opportunities",
"details": {
"resource": "account",
"resource_id": "acc_123",
"constraint": "foreign_key",
"related_object": "opportunity",
"related_count": 5
}
}
}How to fix:
- Delete related records first
- Enable cascade delete on object schema
- Archive instead of delete (soft delete)
Rate Limiting
RATE_LIMIT_EXCEEDED
HTTP Status: 429
Meaning: Too many requests, rate limit exceeded
Example:
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded",
"details": {
"limit": 1000,
"window": "1m",
"retry_after": 45,
"quota_reset": "2024-01-16T14:31:00Z"
}
}
}HTTP Headers:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705412460
Retry-After: 45How to fix:
- Implement exponential backoff
- Batch requests to reduce call count
- Upgrade to higher tier for increased limits
- Cache responses to avoid repeated calls
Client-side handling:
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const response = await fetch(url, options);
if (response.status !== 429) {
return response;
}
const retryAfter = response.headers.get('Retry-After');
await sleep(retryAfter * 1000);
}
throw new Error('Max retries exceeded');
}QUOTA_EXCEEDED
HTTP Status: 429
Meaning: Monthly/daily quota exceeded
Example:
{
"success": false,
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Monthly API quota exceeded",
"details": {
"quota": 10000,
"used": 10000,
"period": "monthly",
"reset": "2024-02-01T00:00:00Z",
"upgrade_url": "https://app.acme.com/billing/upgrade"
}
}
}How to fix:
- Wait for quota reset
- Upgrade to higher plan
- Optimize API usage
Server Errors
INTERNAL_ERROR
HTTP Status: 500
Meaning: Internal server error
Example:
{
"success": false,
"error": {
"code": "INTERNAL_ERROR",
"message": "An internal error occurred",
"details": {
"requestId": "req_abc123",
"support_url": "https://support.acme.com/request/req_abc123"
}
}
}Important: Never leak stack traces or sensitive internals to clients.
How to fix:
- Retry request (may be transient)
- Check server status page
- Contact support with
requestId
SERVICE_UNAVAILABLE
HTTP Status: 503
Meaning: Server temporarily unavailable
Example:
{
"success": false,
"error": {
"code": "SERVICE_UNAVAILABLE",
"message": "Service temporarily unavailable",
"details": {
"reason": "database_maintenance",
"retry_after": 300,
"estimated_completion": "2024-01-16T15:00:00Z"
}
}
}HTTP Headers:
HTTP/1.1 503 Service Unavailable
Retry-After: 300Error Response Examples
Validation Error (Multiple Fields)
Request:
POST /api/v1/data/account
Content-Type: application/json
{
"name": "",
"email": "not-an-email",
"revenue": -1000
}Response:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed for 3 fields",
"details": {
"fields": [
{
"field": "name",
"message": "Name is required",
"constraint": "required",
"value": ""
},
{
"field": "email",
"message": "Invalid email format",
"constraint": "format",
"value": "not-an-email"
},
{
"field": "revenue",
"message": "Revenue must be positive",
"constraint": "min",
"value": -1000,
"expected": 0
}
]
},
"requestId": "req_abc123",
"timestamp": "2024-01-16T14:30:00Z"
}
}Permission Denied
Request:
DELETE /api/v1/data/account/acc_123
Authorization: Bearer <token>Response:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"success": false,
"error": {
"code": "PERMISSION_DENIED",
"message": "Insufficient permissions to delete accounts",
"details": {
"resource": "account",
"resource_id": "acc_123",
"required_permission": "account:delete",
"user_permissions": ["account:read", "account:write"],
"hint": "Contact your administrator to request delete permission"
},
"requestId": "req_def456",
"timestamp": "2024-01-16T14:35:00Z"
}
}Rate Limit Exceeded
Request:
GET /api/v1/data/task
Authorization: Bearer <token>Response:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705412460
Retry-After: 45
Content-Type: application/json
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded: 1000 requests per minute",
"details": {
"limit": 1000,
"window": "1m",
"retry_after": 45,
"quota_reset": "2024-01-16T14:31:00Z",
"upgrade_url": "https://app.acme.com/billing/upgrade"
},
"requestId": "req_ghi789",
"timestamp": "2024-01-16T14:30:15Z"
}
}Error Handling Best Practices
✅ Use Error Codes, Not Messages
Bad:
if (error.message.includes('not found')) {
// Brittle - breaks if message changes
}Good:
if (error.code === 'RESOURCE_NOT_FOUND') {
// Reliable - code never changes
}✅ Show User-Friendly Messages
Bad:
alert(error.message); // "VALIDATION_ERROR: Field 'email' constraint 'format' failed"Good:
const userMessages = {
'VALIDATION_ERROR': 'Please check your input and try again',
'UNAUTHENTICATED': 'Please log in to continue',
'RATE_LIMIT_EXCEEDED': 'Too many requests. Please wait a moment.',
};
showToast(userMessages[error.code] || 'An error occurred');✅ Handle Field-Specific Validation Errors
Good:
async function handleSubmit(data) {
try {
const response = await api.createAccount(data);
return response.data;
} catch (error) {
if (error.code === 'VALIDATION_ERROR') {
// Show errors next to fields
error.details.fields.forEach(({ field, message }) => {
setFieldError(field, message);
});
} else {
// Show general error
showToast(error.message, 'error');
}
throw error;
}
}✅ Implement Retry Logic
Good:
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options);
const data = await response.json();
if (!response.ok) {
// Check if error is retryable
if (data.error.code === 'RATE_LIMIT_EXCEEDED') {
const retryAfter = data.error.details.retry_after || 1;
await sleep(retryAfter * 1000);
continue;
} else if (data.error.code === 'INTERNAL_ERROR') {
// Exponential backoff
await sleep(Math.pow(2, attempt) * 1000);
continue;
} else {
// Don't retry validation errors, auth errors, etc.
throw new APIError(data.error);
}
}
return data;
} catch (error) {
lastError = error;
}
}
throw lastError;
}✅ Log Request IDs for Debugging
Good:
try {
await api.createAccount(data);
} catch (error) {
console.error('Account creation failed', {
requestId: error.requestId,
code: error.code,
message: error.message,
timestamp: error.timestamp
});
// Send to error tracking service
Sentry.captureException(error, {
extra: { requestId: error.requestId }
});
}✅ Handle Network Errors
Good:
try {
const response = await fetch('/api/v1/data/task');
const data = await response.json();
if (!data.success) {
throw new APIError(data.error);
}
return data.data;
} catch (error) {
if (error instanceof TypeError && error.message === 'Failed to fetch') {
// Network error - server unreachable
showToast('Network error. Please check your connection.', 'error');
} else if (error instanceof APIError) {
// API returned error
handleAPIError(error);
} else {
// Unknown error
console.error('Unexpected error:', error);
showToast('An unexpected error occurred', 'error');
}
}Debugging with Request IDs
Every API response includes a requestId for debugging:
Request:
POST /api/v1/data/account
Content-Type: application/json
X-Request-ID: my-custom-id-123
{ "name": "Acme Corp" }Response:
{
"success": true,
"data": { ... },
"requestId": "my-custom-id-123"
}Server logs:
[2024-01-16T14:30:00Z] INFO [my-custom-id-123] POST /api/v1/data/account
[2024-01-16T14:30:00Z] DEBUG [my-custom-id-123] Validating input
[2024-01-16T14:30:00Z] DEBUG [my-custom-id-123] Executing query: INSERT INTO accounts...
[2024-01-16T14:30:00Z] INFO [my-custom-id-123] Request completed in 45msUse request IDs to:
- Trace request through logs
- Debug distributed systems
- Report issues to support
- Correlate frontend errors with backend logs
Monitoring & Alerting
Error Rate Monitoring
Track error rates by code:
// Metrics dashboard
{
"error_rates": {
"VALIDATION_ERROR": 0.05, // 5% of requests
"UNAUTHENTICATED": 0.02, // 2% of requests
"RATE_LIMIT_EXCEEDED": 0.01, // 1% of requests
"INTERNAL_ERROR": 0.0001 // 0.01% of requests (🚨 alert if > 0.01%)
}
}Alert Conditions
Critical alerts:
INTERNAL_ERRORrate > 0.1%SERVICE_UNAVAILABLE> 0- Database connection failures
Warning alerts:
RATE_LIMIT_EXCEEDEDspike (may indicate DDoS or integration bug)UNAUTHENTICATEDspike (credential leakage?)VALIDATION_ERRORspike on new form (bad client-side validation)
Error Budgets
Set error budgets per service:
error_budget:
target_success_rate: 99.9%
measurement_window: 30d
budget_remaining: 97.2% # Still have 97.2% of error budget leftWhen budget exhausted:
- Freeze feature releases
- Focus on reliability improvements
- Investigate root causes
Security Considerations
❌ Never Leak Sensitive Info
Bad:
{
"error": {
"code": "UNAUTHENTICATED",
"message": "Password incorrect for user john@acme.com",
"details": {
"attempted_password": "Password123!", // 🚨 NEVER DO THIS
"actual_password_hash": "bcrypt$..." // 🚨 NEVER DO THIS
}
}
}Good:
{
"error": {
"code": "UNAUTHENTICATED",
"message": "Invalid credentials",
"details": null
}
}❌ Don't Confirm Resource Existence
Bad:
// Attacker probes: DELETE /api/v1/data/account/acc_123
{
"error": {
"code": "PERMISSION_DENIED",
"message": "You don't have permission to delete this account"
}
}
// Attacker learns: Account acc_123 exists! 🚨Good:
// Return RESOURCE_NOT_FOUND for both "doesn't exist" and "exists but no permission"
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Account not found"
}
}✅ Rate Limit Error Responses
Even error responses can be abused:
// Attacker tries to enumerate user emails
for (let i = 0; i < 1000000; i++) {
await register({ email: `user${i}@example.com` });
// Response: "DUPLICATE_RECORD" or "VALIDATION_ERROR"
}Solution: Rate limit failed registration attempts:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many failed registration attempts"
}
}