Defensive Error Message Extraction from API Responses

πŸ“– 3 minutes read

When consuming external APIs, error responses come in all shapes and sizes. Some vendors nest errors deeply (error.response.data.error.message), others put them at the root (error.message), and some just throw generic HTTP status text.

If you’re not defensive about extracting these messages, your users will see unhelpful errors like “undefined” or “Cannot read property ‘message’ of undefined” instead of the actual problem.

The Pattern: Fallback Chains with Existence Checks

Build a chain of fallback attempts, each guarded by existence checks:

// Frontend API call (Vue.js, React, plain JS)
try {
  const response = await axios.get('/api/products/123')
  this.product = response.data
} catch (error) {
  const message = (
    error.response?.data?.error?.message ||  // Try nested first
    error.response?.data?.message ||         // Then less nested
    error.message ||                         // Generic error message
    'Failed to load product data'            // Hardcoded fallback
  )
  
  this.showError(message)
}

The same pattern works server-side in PHP/Laravel:

use Illuminate\Support\Facades\Http;

try {
    $response = Http::get('https://api.vendor.com/products/123');
    return $response->json();
} catch (\Exception $e) {
    $message = $e->response['error']['message'] 
        ?? $e->response['message']
        ?? $e->getMessage()
        ?? 'Failed to fetch product from external API';
    
    Log::error('External API error', ['message' => $message]);
    throw new \RuntimeException($message);
}

Why This Matters

This pattern:

  • Prevents runtime errors: Each level is optional-chained (?. or ??), so missing properties don’t crash
  • Provides useful fallbacks: Users see the most specific error available, never “undefined”
  • Works across vendors: Different API error formats are handled gracefully
  • Debuggable: Hardcoded fallback tells you “we got an error but couldn’t extract a message”

Common API Error Formats

Here are the patterns you’ll encounter:

// Format 1: Deeply nested (Stripe-style)
{
  "error": {
    "type": "invalid_request_error",
    "message": "No such customer: cus_xxxxx"
  }
}

// Format 2: Root-level message (Laravel-style)
{
  "message": "The given data was invalid.",
  "errors": { ... }
}

// Format 3: HTTP status text only
// (no JSON body, just 500 Internal Server Error)

Your fallback chain handles all three:

  1. Try error.response.data.error.message β†’ catches Format 1
  2. Try error.response.data.message β†’ catches Format 2
  3. Try error.message β†’ catches Format 3 (Axios wraps HTTP status text here)
  4. Use hardcoded fallback β†’ catches unexpected formats

Laravel HTTP Client Version

Laravel’s HTTP client throws different exceptions, so adjust your chain:

use Illuminate\Http\Client\RequestException;

try {
    $response = Http::timeout(10)->get('https://api.vendor.com/data');
    return $response->throw()->json();
} catch (RequestException $e) {
    // Laravel wraps the response
    $message = data_get($e->response, 'error.message')
        ?? data_get($e->response, 'message')
        ?? $e->getMessage()
        ?? 'API request failed';
    
    throw new \RuntimeException($message);
}

Using data_get() is cleaner than ?? chains for deeply nested arrays.

When to Use This

  • Any external API integration (payment gateways, shipping APIs, third-party services)
  • Frontend API calls where you display errors to users
  • Background jobs that need to log meaningful error messages
  • Webhook handlers that receive errors from external systems

Remember: Never assume error responses will match the API docs. Build fallback chains so your users (and logs) always see a meaningful message, even when the API returns something unexpected.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *