ObjectStackObjectStack

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: Always false for errors
  • error.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 debugging
  • error.timestamp: When error occurred (ISO 8601)

HTTP Status Codes

ObjectStack uses standard HTTP status codes:

StatusMeaningWhen Used
400Bad RequestInvalid input, validation failure
401UnauthorizedAuthentication required or failed
403ForbiddenAuthenticated but insufficient permissions
404Not FoundResource doesn't exist
409ConflictResource already exists or version mismatch
422Unprocessable EntitySemantic validation failed (e.g. metadata spec validation); also an absent controlled_by_parent master reference — see MISSING_REQUIRED_FIELD
429Too Many RequestsRate limit exceeded
500Internal Server ErrorServer-side error
503Service UnavailableServer 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 Authorization header
  • 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 aud claim)
  • 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 asRefused byStatuscodefields
master_detail + required, not readonly/systemrequired-field validation400VALIDATION_FAILEDyes
master_detail with no requiredthe master-access gate422MISSING_REQUIRED_FIELDabsent
master_detail + required + readonlythe master-access gate422MISSING_REQUIRED_FIELDabsent
master_detail + required + systemthe master-access gate422MISSING_REQUIRED_FIELDabsent
a required lookup, when the object declares no master_detailthe master-access gate422MISSING_REQUIRED_FIELDabsent

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: 45

How 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: 300

Error 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 45ms

Use 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_ERROR rate > 0.1%
  • SERVICE_UNAVAILABLE > 0
  • Database connection failures

Warning alerts:

  • RATE_LIMIT_EXCEEDED spike (may indicate DDoS or integration bug)
  • UNAUTHENTICATED spike (credential leakage?)
  • VALIDATION_ERROR spike 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 left

When 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"
  }
}

Next Steps

On this page