Skip to main content

Error Handling & Retry Strategy

Overview

API calls fail for various reasons: network timeouts, rate limits, server errors, authentication issues, and invalid inputs. This guide covers how to identify errors, decide whether to retry, and implement resilient integration patterns.


HTTP Status Codes

2xx - Success (No retry needed)

Code Meaning Action
200 OK Request succeeded, response body included
201 Created Resource created successfully
204 No Content Success, no response body (DELETE, PATCH)

Action: Continue normally.


4xx - Client Errors (Don't retry)

Code Meaning Cause Action
400 Bad Request Invalid parameters, malformed JSON Fix request, don't retry
401 Unauthorized Token expired, invalid credentials Refresh token or re-authenticate
403 Forbidden User lacks required permissions Check roles/permissions
404 Not Found Resource doesn't exist Verify ID, don't retry
409 Conflict Duplicate record (same email, phone) Check for existing record

Action: Fix the request and try again. Retrying won't help.


5xx - Server Errors (Retry)

Code Meaning Typical Cause Action
500 Internal Server Error Server-side exception Retry with backoff
502 Bad Gateway Proxy/gateway error Retry with backoff
503 Service Unavailable Temporary outage, maintenance Retry with backoff
504 Gateway Timeout Timeout during processing Retry with longer backoff

Action: Retry with exponential backoff.


429 - Rate Limit Exceeded (Retry with timing)

Code: 429
Meaning: Too many requests in time window
Response Headers:

  • X-Rate-Limit-Limit: Max requests per window
  • X-Rate-Limit-Remaining: Requests left
  • X-Rate-Limit-Reset: Unix timestamp when window resets
  • Retry-After: Seconds to wait before retrying

Action: Wait and retry. Use Retry-After header if present, otherwise calculate from X-Rate-Limit-Reset.


Retry Decision Matrix

Is the error...

├─ 2xx (Success)?
│  └─ No: continue
│
├─ 4xx (Client error)?
│  ├─ 401 (Auth)?
│  │  └─ Yes: Refresh token, retry once
│  ├─ 409 (Conflict)?
│  │  └─ Yes: Check for existing record, skip or update
│  └─ Other 4xx?
│     └─ No: Fix request, don't retry
│
├─ 429 (Rate limit)?
│  └─ Yes: Wait (see Retry-After), retry
│
└─ 5xx (Server error)?
   └─ Yes: Exponential backoff, retry 3-4 times

Exponential Backoff Pattern

Strategy

  1. First retry: 3 seconds
  2. Second retry: 9 seconds (3² )
  3. Third retry: 27 seconds (3³)
  4. Fourth retry: 300 seconds (5 minutes)
  5. Fifth+: Give up

Formula: delay = 3^(attempt_number) capped at 5 minutes

Why Exponential?

  • Gives server time to recover
  • Reduces load during outages
  • Prevents thundering herd (everyone retrying at same time)
  • Each retry waits progressively longer

Implementation: Exponential Backoff

JavaScript/Node.js

/**
 * Retry logic with exponential backoff
 */
async function requestWithRetry(
  method,
  endpoint,
  body = null,
  maxAttempts = 4
) {
  const delays = [3000, 9000, 27000, 300000]; // 3s, 9s, 27s, 5min

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const response = await makeRequest(method, endpoint, body);

      // Check status code
      if (response.ok) {
        return response; // Success
      }

      const status = response.status;

      // Determine if retryable
      const isRetryable = [429, 500, 502, 503, 504].includes(status);

      if (!isRetryable || attempt >= maxAttempts - 1) {
        // Non-retryable error or last attempt
        throw new Error(
          `API Error ${status}: ${response.statusText}`
        );
      }

      // Is 401? Try refreshing token
      if (status === 401 && attempt === 0) {
        console.log('Token expired, refreshing...');
        await refreshToken();
        // Retry immediately
        continue;
      }

      // Calculate wait time
      const delayMs = delays[attempt];
      const delaySec = delayMs / 1000;

      // Check Retry-After header
      const retryAfter = response.headers.get('Retry-After');
      if (retryAfter) {
        const waitSec = parseInt(retryAfter);
        console.warn(
          `Rate limited. Waiting ${waitSec}s before retry (attempt ${attempt + 1}/${maxAttempts})`
        );
        await sleep(waitSec * 1000);
      } else {
        console.warn(
          `Error ${status}. Retrying in ${delaySec}s (attempt ${attempt + 1}/${maxAttempts})`
        );
        await sleep(delayMs);
      }

    } catch (error) {
      // Network error (no response)
      const isRetryable = [
        'ECONNREFUSED', // Connection refused
        'ENOTFOUND',    // DNS lookup failed
        'ETIMEDOUT'     // Request timeout
      ].some(e => error.code?.includes(e));

      if (!isRetryable || attempt >= maxAttempts - 1) {
        throw error;
      }

      const delayMs = delays[attempt];
      const delaySec = delayMs / 1000;
      console.warn(
        `Network error: ${error.code}. Retrying in ${delaySec}s (attempt ${attempt + 1}/${maxAttempts})`
      );
      await sleep(delayMs);
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Implementation: Rate Limit Detection

/**
 * Monitor rate limit status
 */
async function monitorRateLimit(response) {
  const limit = parseInt(response.headers.get('X-Rate-Limit-Limit'));
  const remaining = parseInt(response.headers.get('X-Rate-Limit-Remaining'));
  const reset = parseInt(response.headers.get('X-Rate-Limit-Reset'));

  const percentRemaining = (remaining / limit) * 100;

  console.log(`Rate Limit: ${remaining}/${limit} (${percentRemaining.toFixed(1)}%)`);

  // Warning at 20% remaining
  if (percentRemaining < 20) {
    console.warn('⚠️ Approaching rate limit! Slow down requests.');
  }

  // Critical at 5% remaining
  if (percentRemaining < 5) {
    console.error('🛑 Critical: Rate limit nearly exceeded!');
    const waitSeconds = reset - Math.floor(Date.now() / 1000);
    console.error(`Must wait ${waitSeconds} seconds.`);
  }

  return {
    remaining,
    limit,
    percentRemaining,
    resetAt: new Date(reset * 1000)
  };
}

Handling Specific Errors

401 - Token Expired

async function handleAuthError() {
  console.log('Refreshing authentication token...');
  
  try {
    const newToken = await refreshAccessToken(
      process.env.GENESYS_CLIENT_ID,
      process.env.GENESYS_CLIENT_SECRET
    );
    
    this.accessToken = newToken;
    console.log('✅ Token refreshed successfully');
    
    // Retry the original request
    return await makeRequest(...originalRequest);
  } catch (error) {
    console.error('❌ Token refresh failed:', error);
    throw new Error('Authentication failed. Manual re-authentication required.');
  }
}

409 - Duplicate Record

async function handleConflictError(contactData) {
  console.log('Contact may already exist. Searching...');
  
  try {
    const existing = await searchContact(contactData.email);
    
    if (existing) {
      console.log(`Found existing contact: ${existing.id}`);
      // Update instead of create
      return await updateContact(existing.id, contactData);
    } else {
      console.log('No duplicate found. Retrying create...');
      return await createContact(contactData);
    }
  } catch (error) {
    console.error('Could not resolve conflict:', error);
    throw error;
  }
}

429 - Rate Limit

async function handleRateLimit(response) {
  let waitSeconds = 60; // Default

  // Check Retry-After header first
  const retryAfter = response.headers.get('Retry-After');
  if (retryAfter) {
    waitSeconds = parseInt(retryAfter);
  } else {
    // Calculate from X-Rate-Limit-Reset
    const reset = parseInt(response.headers.get('X-Rate-Limit-Reset'));
    const now = Math.floor(Date.now() / 1000);
    waitSeconds = Math.max(1, reset - now);
  }

  console.warn(`Rate limited. Waiting ${waitSeconds}s...`);
  await sleep(waitSeconds * 1000);
  console.log('Resuming requests...');
}

5xx - Server Error

async function handleServerError(status, response) {
  if (status === 503) {
    // Service Unavailable - check Retry-After
    const retryAfter = response.headers.get('Retry-After');
    if (retryAfter) {
      const seconds = parseInt(retryAfter);
      console.warn(`Service unavailable. Retry after ${seconds}s`);
      return { retryAfter: seconds };
    }
  }

  if (status === 504) {
    // Gateway Timeout - likely temporary
    console.warn('Gateway timeout. Retrying with longer backoff...');
    return { shouldRetry: true, backoff: 'long' };
  }

  // Generic 5xx
  console.error(`Server error ${status}. Retrying...`);
  return { shouldRetry: true, backoff: 'exponential' };
}

Data Validation (Catch Errors Early)

Validate data BEFORE calling API to avoid 400 errors:

/**
 * Validate contact before creating
 */
function validateContact(contact) {
  const errors = [];

  // Required fields
  if (!contact.firstName || contact.firstName.trim() === '') {
    errors.push('firstName is required');
  }
  if (!contact.lastName || contact.lastName.trim() === '') {
    errors.push('lastName is required');
  }

  // Email format
  if (contact.email) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(contact.email)) {
      errors.push('email format is invalid');
    }
  }

  // Phone format (if present)
  if (contact.phoneNumbers && contact.phoneNumbers.length > 0) {
    contact.phoneNumbers.forEach((phone, i) => {
      if (!/^\+?[1-9]\d{1,14}$/.test(phone.number)) {
        errors.push(`phoneNumbers[${i}].number is not E.164 format`);
      }
      if (!['WORK', 'MOBILE', 'HOME', 'OTHER'].includes(phone.type)) {
        errors.push(`phoneNumbers[${i}].type must be WORK, MOBILE, HOME, or OTHER`);
      }
    });
  }

  return {
    valid: errors.length === 0,
    errors
  };
}

// Usage
const validation = validateContact(contactData);
if (!validation.valid) {
  console.error('Invalid contact:', validation.errors);
  return; // Don't call API
}

// Safe to call API
await createContact(contactData);

Idempotency: Preventing Duplicates on Retry

Use externalId to link records and prevent duplicates:

/**
 * Create contact with idempotency
 */
async function createContactIdempotent(sfContact) {
  const contactData = {
    firstName: sfContact.FirstName,
    lastName: sfContact.LastName,
    email: sfContact.Email,
    externalId: sfContact.Id  // ← Salesforce ID
  };

  try {
    return await createContact(contactData);
  } catch (error) {
    if (error.status === 409) {
      // Conflict - might already exist
      // Search by externalId
      const existing = await getContactByExternalId(sfContact.Id);
      if (existing) {
        console.log(`Contact already exists: ${existing.id}`);
        return existing;
      }
    }
    throw error;
  }
}

Logging & Monitoring

Log ALL API calls for debugging:

/**
 * Log API request and response
 */
async function loggedRequest(method, endpoint, body) {
  const startTime = Date.now();
  
  console.log(`[${new Date().toISOString()}] ${method} ${endpoint}`);
  if (body && method !== 'GET') {
    console.log('  Request:', JSON.stringify(body).substring(0, 200));
  }

  try {
    const response = await makeRequest(method, endpoint, body);
    const duration = Date.now() - startTime;
    
    console.log(`  ✅ ${response.status} (${duration}ms)`);
    
    // Log rate limit status
    const remaining = response.headers.get('X-Rate-Limit-Remaining');
    if (remaining) {
      console.log(`  Rate limit: ${remaining} remaining`);
    }
    
    return response;
  } catch (error) {
    const duration = Date.now() - startTime;
    console.error(`  ❌ ${error.status || 'NETWORK'} (${duration}ms)`);
    console.error(`  Error: ${error.message}`);
    throw error;
  }
}

Complete Example: Safe Contact Sync

/**
 * Complete contact sync with error handling
 */
async function safeSyncContact(sfContact) {
  // 1. Validate
  const validation = validateContact({
    firstName: sfContact.FirstName,
    lastName: sfContact.LastName,
    email: sfContact.Email
  });

  if (!validation.valid) {
    console.error(`Skip contact ${sfContact.Id}: ${validation.errors.join(', ')}`);
    return { status: 'SKIPPED', reason: validation.errors[0] };
  }

  // 2. Prepare
  const contactData = {
    firstName: sfContact.FirstName,
    lastName: sfContact.LastName,
    email: sfContact.Email,
    externalId: sfContact.Id  // For idempotency
  };

  // 3. Try to create with retry logic
  try {
    const result = await requestWithRetry('POST', '/contacts', contactData);
    console.log(`✅ Created: ${result.id}`);
    return { status: 'CREATED', id: result.id };
  } catch (error) {
    if (error.status === 409) {
      // Might already exist - check
      const existing = await getContactByExternalId(sfContact.Id);
      if (existing) {
        console.log(`ℹ️ Already exists: ${existing.id}`);
        return { status: 'EXISTS', id: existing.id };
      }
    }

    console.error(`❌ Failed: ${error.message}`);
    return { status: 'ERROR', reason: error.message };
  }
}

Best Practices

  1. Always check status codes before retrying
  2. Use exponential backoff for 5xx errors
  3. Respect rate limits - check headers, slow down if needed
  4. Validate early - catch 400 errors before calling API
  5. Use external IDs - prevent duplicates on retry
  6. Log everything - need data for debugging
  7. Implement timeouts - don't wait forever
  8. Monitor rate limits - adjust request frequency proactively

Common Mistakes

Retrying on 400 - Invalid input won't be fixed by retrying
Only retry on 429, 5xx

Immediate retry on error - Doesn't fix server problems
Wait with exponential backoff

Creating duplicate records - No idempotency
Use external IDs for deduplication

Silent failures - No visibility into errors
Log all requests and responses

Ignoring rate limits - Keep hammering API
Monitor headers, slow down proactively


  • Chapter 11: API Endpoints Reference
  • Chapter 11: Rate Limiting & Throttling
  • Chapter 11: OAuth Client Management