Error Handling & Retries

Handle errors gracefully with complete error code reference and retry strategies

15 mins
Intermediate

Production applications must handle errors gracefully. This guide covers all DoorFlow API error codes, common issues, and retry strategies.

HTTP Status Codes

The DoorFlow API uses standard HTTP status codes to indicate success or failure.

Success Codes

Code Status Meaning
200 OK Request succeeded, response contains data
201 Created Resource created successfully
204 No Content Request succeeded, no response body

Client Error Codes (4xx)

Code Status Meaning Action
400 Bad Request Malformed request Fix request format
401 Unauthorized Invalid/expired token Refresh token or re-authenticate
403 Forbidden Insufficient permissions Check OAuth scopes
404 Not Found Resource doesn't exist Verify resource ID
422 Unprocessable Entity Validation error Fix request data
429 Too Many Requests Rate limit exceeded Implement backoff

Server Error Codes (5xx)

Code Status Meaning Action
500 Internal Server Error Server error Retry with backoff
502 Bad Gateway Upstream error Retry with backoff
503 Service Unavailable Temporary outage Retry with backoff
504 Gateway Timeout Request timeout Retry with backoff

Error Response Format

All errors return JSON with this structure:

json
{
  "error": "error_code",
  "error_description": "Human-readable description",
  "errors": {
    "field_name": ["Specific validation error"]
  }
}

Example validation error:

json
{
  "error": "validation_failed",
  "error_description": "Validation failed",
  "errors": {
    "email": ["has already been taken"],
    "first_name": ["can't be blank"]
  }
}

Common Errors and Solutions

401 Unauthorized

Error response:

json
{
  "error": "invalid_token",
  "error_description": "The access token is invalid or has expired"
}

Causes

Access token expired (1-hour lifetime)
Token was revoked
Token format is incorrect
Missing Authorization header

Solutions:

javascript
async function handleTokenExpiration(error, refreshToken) {
  if (error.status === 401) {
    console.log('Token expired, refreshing...');

    // Refresh the token
    const newTokens = await refreshAccessToken(refreshToken);

    // Store new tokens
    await saveTokens(newTokens);

    // Retry the original request with new token
    return retryWithNewToken(newTokens.access_token);
  }

  throw error;
}

Best practice: Implement automatic token refresh on first 401 error, retry once, then fail if still unauthorized.


403 Forbidden

Error response:

json
{
  "error": "insufficient_scope",
  "error_description": "The access token does not have sufficient scope to perform this action"
}

Causes

OAuth application doesn't have required scope
User didn't grant required scope during authorization

Solutions:

javascript
function handle403Error(error, endpoint) {
  if (error.status === 403) {
    // Map endpoints to required scopes
    const scopeRequirements = {
      'POST /api/3/people': 'account.person',
      'POST /api/3/people/{id}/credentials': 'account.person',
      'POST /api/3/channels/{id}/admit': 'account.channel.admit',
    };

    const requiredScope = scopeRequirements[endpoint];

    throw new Error(
      `Missing required OAuth scope: ${requiredScope}. ` +
      `Please update your OAuth application and have users re-authorize.`
    );
  }
}

Best practice: Check scopes before making requests, provide clear error messages to users.


404 Not Found

Error response:

json
{
  "error": "not_found",
  "error_description": "The requested resource was not found"
}

Causes

Resource ID doesn't exist
Resource was deleted
Wrong endpoint URL
Typo in resource ID

Solutions:

javascript
async function getPerson(personId) {
  try {
    const response = await fetch(`/api/3/people/${personId}`, {
      headers: { 'Authorization': `Bearer ${token}` }
    });

    if (response.status === 404) {
      throw new Error(`Person ${personId} not found. They may have been deleted.`);
    }

    return await response.json();

  } catch (error) {
    // Log for debugging
    console.error(`Failed to fetch person ${personId}:`, error);

    // Return graceful fallback
    return null;
  }
}

Best practice: Validate resource IDs exist before operations. Handle deletions gracefully.


422 Unprocessable Entity (Validation Errors)

Error response:

json
{
  "error": "validation_failed",
  "error_description": "Validation failed",
  "errors": {
    "email": ["has already been taken"],
    "first_name": ["can't be blank"],
    "card_number": ["is invalid"]
  }
}

Causes

Required fields missing
Invalid field formats
Duplicate values (email, card number)
Business rule violations

Common validation errors:

Field Error Solution
email "has already been taken" Use different email or find existing person
email "is invalid" Validate email format
first_name "can't be blank" Provide first name
value (credential) "has already been taken" Card/PIN already issued
group_ids "is invalid" Group ID doesn't exist
channel_ids "can't be blank" Provide at least one channel

Handling validation errors:

javascript
async function createPerson(data) {
  try {
    const response = await fetch('/api/3/people', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(data),
    });

    if (response.status === 422) {
      const error = await response.json();

      // Extract validation errors
      const validationErrors = error.errors || {};

      // Display user-friendly messages
      if (validationErrors.email?.includes('has already been taken')) {
        throw new Error(`Email ${data.email} is already in use. Please use a different email.`);
      }

      if (validationErrors.first_name?.includes("can't be blank")) {
        throw new Error('First name is required.');
      }

      // Generic validation error
      throw new Error(`Validation failed: ${JSON.stringify(validationErrors)}`);
    }

    return await response.json();

  } catch (error) {
    console.error('Failed to create person:', error);
    throw error;
  }
}

Best practice: Validate data client-side before sending to API. Parse validation errors to show user-friendly messages.


429 Too Many Requests (Rate Limiting)

Error response:

json
{
  "error": "rate_limit_exceeded",
  "error_description": "API rate limit exceeded. Maximum 120 requests per minute."
}

Rate limits

120 requests per minute per access token
Counter resets every hour
Applies to all endpoints

Response headers:

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704070800

Handling rate limits:

javascript
async function callAPIWithRateLimit(url, options) {
  try {
    const response = await fetch(url, options);

    if (response.status === 429) {
      // Get reset time from header
      const resetTime = parseInt(response.headers.get('X-RateLimit-Reset'));
      const waitTime = (resetTime * 1000) - Date.now();

      console.log(`Rate limited. Waiting ${waitTime}ms until reset...`);

      // Wait until rate limit resets
      await new Promise(resolve => setTimeout(resolve, waitTime));

      // Retry request
      return await fetch(url, options);
    }

    return response;

  } catch (error) {
    console.error('API call failed:', error);
    throw error;
  }
}

Best practices

Monitor rate limit headers on every request: ```javascript function checkRateLimitHeaders(response) { const remaining = response.headers.get('X-RateLimit-Remaining');

if (remaining < 100) { console.warn(Warning: Only ${remaining} requests remaining this minute); } }


2. **Implement exponential backoff**:
```javascript
async function callAPIWithBackoff(url, options, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);

      if (response.status === 429) {
        const backoffTime = Math.pow(2, i) * 1000; // 1s, 2s, 4s
        console.log(`Rate limited. Retry ${i+1}/${maxRetries} after ${backoffTime}ms`);
        await new Promise(resolve => setTimeout(resolve, backoffTime));
        continue;
      }

      return response;

    } catch (error) {
      if (i === maxRetries - 1) throw error;
    }
  }
}
  1. Batch operations when possible: ```javascript // Bad: 100 separate requests for (const person of people) { await createPerson(person); }

// Better: Batch with delays async function batchCreatePeople(people, batchSize = 10) { for (let i = 0; i < people.length; i += batchSize) { const batch = people.slice(i, i + batchSize);

await Promise.all(batch.map(person => createPerson(person)));

// Delay between batches to avoid rate limit
await new Promise(resolve => setTimeout(resolve, 1000));

} }


---

### 500/502/503/504 Server Errors

**Error response:**
```json
{
  "error": "internal_server_error",
  "error_description": "An internal server error occurred"
}

Causes

Temporary server issues
Database problems
Upstream service failures
Deployment in progress

Handling server errors:

javascript
async function callAPIWithRetry(url, options, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Retry on server errors
      if (response.status >= 500 && response.status < 600) {
        if (attempt < maxRetries) {
          const backoffTime = Math.pow(2, attempt) * 1000; // Exponential backoff
          console.log(`Server error (${response.status}). Retry ${attempt}/${maxRetries} after ${backoffTime}ms`);
          await new Promise(resolve => setTimeout(resolve, backoffTime));
          continue;
        }

        throw new Error(`Server error after ${maxRetries} retries: ${response.status}`);
      }

      return response;

    } catch (error) {
      if (attempt === maxRetries) {
        throw new Error(`Request failed after ${maxRetries} retries: ${error.message}`);
      }

      // Retry on network errors too
      const backoffTime = Math.pow(2, attempt) * 1000;
      console.log(`Network error. Retry ${attempt}/${maxRetries} after ${backoffTime}ms`);
      await new Promise(resolve => setTimeout(resolve, backoffTime));
    }
  }
}

Best practice: Always retry server errors with exponential backoff. Log errors for monitoring.


Complete Error Handler

Here's a production-ready error handler that handles all scenarios:

javascript
class DoorFlowAPIError extends Error {
  constructor(message, status, response) {
    super(message);
    this.name = 'DoorFlowAPIError';
    this.status = status;
    this.response = response;
  }
}

async function callDoorFlowAPI(endpoint, options = {}, retryConfig = {}) {
  const {
    maxRetries = 3,
    retryOnServerError = true,
    retryOnRateLimit = true,
  } = retryConfig;

  const url = `https://api.doorflow.com${endpoint}`;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, {
        ...options,
        headers: {
          'Authorization': `Bearer ${getAccessToken()}`,
          'Content-Type': 'application/json',
          ...options.headers,
        },
      });

      // Check rate limit headers
      const remaining = response.headers.get('X-RateLimit-Remaining');
      if (remaining && parseInt(remaining) < 50) {
        console.warn(`Rate limit warning: ${remaining} requests remaining`);
      }

      // Handle specific status codes
      if (response.status === 401) {
        // Token expired - refresh and retry
        console.log('Token expired, refreshing...');
        await refreshToken();

        if (attempt < maxRetries) {
          continue; // Retry with new token
        }
      }

      if (response.status === 403) {
        const error = await response.json();
        throw new DoorFlowAPIError(
          `Insufficient permissions: ${error.error_description}`,
          403,
          error
        );
      }

      if (response.status === 404) {
        const error = await response.json();
        throw new DoorFlowAPIError(
          `Resource not found: ${endpoint}`,
          404,
          error
        );
      }

      if (response.status === 422) {
        const error = await response.json();
        const validationMessages = Object.entries(error.errors || {})
          .map(([field, messages]) => `${field}: ${messages.join(', ')}`)
          .join('; ');

        throw new DoorFlowAPIError(
          `Validation failed: ${validationMessages}`,
          422,
          error
        );
      }

      if (response.status === 429 && retryOnRateLimit) {
        const resetTime = parseInt(response.headers.get('X-RateLimit-Reset'));
        const waitTime = (resetTime * 1000) - Date.now();

        if (attempt < maxRetries) {
          console.log(`Rate limited. Waiting ${Math.round(waitTime/1000)}s...`);
          await new Promise(resolve => setTimeout(resolve, waitTime));
          continue;
        }
      }

      if (response.status >= 500 && retryOnServerError) {
        if (attempt < maxRetries) {
          const backoffTime = Math.pow(2, attempt) * 1000;
          console.log(`Server error. Retry ${attempt}/${maxRetries} after ${backoffTime}ms`);
          await new Promise(resolve => setTimeout(resolve, backoffTime));
          continue;
        }
      }

      // Success or unhandled error
      if (!response.ok) {
        const error = await response.json().catch(() => ({}));
        throw new DoorFlowAPIError(
          error.error_description || `HTTP ${response.status}`,
          response.status,
          error
        );
      }

      // Success - return parsed response
      if (response.status === 204) {
        return null; // No content
      }

      return await response.json();

    } catch (error) {
      // Network errors or other exceptions
      if (attempt === maxRetries) {
        console.error('Request failed after all retries:', error);
        throw error;
      }

      if (error instanceof DoorFlowAPIError) {
        throw error; // Don't retry client errors (4xx)
      }

      // Retry network errors
      const backoffTime = Math.pow(2, attempt) * 1000;
      console.log(`Network error. Retry ${attempt}/${maxRetries} after ${backoffTime}ms`);
      await new Promise(resolve => setTimeout(resolve, backoffTime));
    }
  }
}

// Usage
try {
  const person = await callDoorFlowAPI('/api/3/people/12345', {
    method: 'GET',
  });
  console.log('[OK] Person retrieved:', person);

} catch (error) {
  if (error instanceof DoorFlowAPIError) {
    if (error.status === 404) {
      console.log('Person not found - they may have been deleted');
    } else if (error.status === 403) {
      console.log('Missing required permissions');
    } else {
      console.error('API error:', error.message);
    }
  } else {
    console.error('Unexpected error:', error);
  }
}

Idempotency

Some operations should be safe to retry. Use idempotency strategies:

Check Before Create

javascript
async function createPersonIdempotent(personData) {
  // Check if person already exists
  const existingPeople = await callDoorFlowAPI(
    `/api/3/people?email=${encodeURIComponent(personData.email)}`
  );

  if (existingPeople.people.length > 0) {
    console.log('Person already exists, returning existing record');
    return existingPeople.people[0];
  }

  // Create new person
  return await callDoorFlowAPI('/api/3/people', {
    method: 'POST',
    body: JSON.stringify(personData),
  });
}

Update Instead of Create

javascript
async function upsertPerson(personData) {
  try {
    // Try to create
    return await createPerson(personData);

  } catch (error) {
    if (error.status === 422 && error.response.errors.email?.includes('has already been taken')) {
      // Find existing person and update
      const existingPeople = await callDoorFlowAPI(
        `/api/3/people?email=${encodeURIComponent(personData.email)}`
      );

      const existingPerson = existingPeople.people[0];

      return await callDoorFlowAPI(`/api/3/people/${existingPerson.id}`, {
        method: 'PUT',
        body: JSON.stringify(personData),
      });
    }

    throw error;
  }
}

Logging and Monitoring

Implement comprehensive logging for debugging:

javascript
function logAPICall(method, endpoint, status, duration, error = null) {
  const logEntry = {
    timestamp: new Date().toISOString(),
    method,
    endpoint,
    status,
    duration_ms: duration,
    error: error ? error.message : null,
  };

  // Log to console
  console.log(JSON.stringify(logEntry));

  // Send to monitoring service
  // monitoringService.trackAPICall(logEntry);

  // Alert on errors
  if (status >= 500) {
    // alertService.notify('DoorFlow API server error', logEntry);
  }
}

// Usage
async function callAPIWithLogging(endpoint, options) {
  const startTime = Date.now();

  try {
    const response = await callDoorFlowAPI(endpoint, options);
    const duration = Date.now() - startTime;

    logAPICall(options.method || 'GET', endpoint, 200, duration);

    return response;

  } catch (error) {
    const duration = Date.now() - startTime;

    logAPICall(
      options.method || 'GET',
      endpoint,
      error.status || 0,
      duration,
      error
    );

    throw error;
  }
}

Testing Error Scenarios

Test how your app handles errors:

javascript
// Mock DoorFlow API responses for testing
const mockAPIResponses = {
  '/api/3/people/12345': {
    status: 404,
    body: { error: 'not_found', error_description: 'Person not found' },
  },
  '/api/3/people': {
    status: 422,
    body: {
      error: 'validation_failed',
      errors: { email: ['has already been taken'] },
    },
  },
};

// Test error handling
async function testErrorHandling() {
  // Test 404
  try {
    await getPerson(99999);
    console.error('Should have thrown 404 error');
  } catch (error) {
    console.log('[OK] 404 handled correctly');
  }

  // Test validation error
  try {
    await createPerson({ first_name: 'John', email: 'duplicate@example.com' });
    console.error('Should have thrown validation error');
  } catch (error) {
    console.log('[OK] Validation error handled correctly');
  }

  // Test rate limit
  // ... (make 1001 requests to trigger rate limit)

  console.log('All error scenarios tested successfully');
}

Error Handling Checklist

Before going to production:

  • Implement token refresh on 401 errors
  • Check OAuth scopes before API calls (prevent 403)
  • Validate data client-side (reduce 422 errors)
  • Implement exponential backoff for retries
  • Handle rate limiting gracefully
  • Log all errors with context
  • Monitor error rates
  • Set up alerts for high error rates
  • Test all error scenarios
  • Provide user-friendly error messages

Quick Reference

Retry these errors

401 (once, after token refresh)
429 (with wait until reset)
500, 502, 503, 504 (with exponential backoff)

Don't retry these errors

400, 403, 404, 422 (client errors - fix request first)

Exponential backoff formula:

javascript
const backoffTime = Math.pow(2, attempt) * 1000;
// Attempt 1: 2s
// Attempt 2: 4s
// Attempt 3: 8s

Next Steps

  • [OAuth Setup] - Implement token refresh
  • [Common Workflows] - See error handling in context
  • [Events and Audit Trail] - Monitor API usage
  • API Reference - Check error responses for each endpoint