From Zero to AI

Lesson 5.4: Error Handling Strategies

Duration: 60 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  1. Understand different types of errors in data processing
  2. Implement the Result pattern for type-safe error handling
  3. Create custom error classes for specific failure scenarios
  4. Build resilient data pipelines with proper error recovery
  5. Apply retry strategies for transient failures

Introduction

When processing data from external sources, many things can go wrong: network failures, invalid data, rate limits, timeouts. Good error handling is the difference between a fragile application that crashes and a robust one that recovers gracefully.

// Without proper error handling - crashes on any failure
async function fetchUser(id: number) {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return data;
}

// What happens if:
// - Network is down?
// - Server returns 404?
// - Response is not valid JSON?
// - Data doesn't match expected shape?

Types of Errors

1. Network Errors

Connection failures, DNS issues, timeouts.

try {
  await fetch('https://api.example.com/data');
} catch (error) {
  // TypeError: Failed to fetch
  // - No internet connection
  // - DNS lookup failed
  // - CORS blocked
  // - Server not responding
}

2. HTTP Errors

Server responded, but with an error status.

const response = await fetch('/api/users/999');
// response.ok is false for 4xx and 5xx status codes
// BUT fetch doesn't throw - you must check!

if (!response.ok) {
  // 400 Bad Request
  // 401 Unauthorized
  // 403 Forbidden
  // 404 Not Found
  // 500 Internal Server Error
}

3. Parsing Errors

Response is not valid JSON or has unexpected format.

const response = await fetch('/api/data');
const text = await response.text();

try {
  const data = JSON.parse(text);
} catch (error) {
  // SyntaxError: Unexpected token...
  // Response might be HTML error page, empty, or malformed
}

4. Validation Errors

Data exists but doesn't match expected schema.

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

try {
  UserSchema.parse(data);
} catch (error) {
  // ZodError: id should be number, got string
}

5. Business Logic Errors

Data is valid but violates application rules.

function processOrder(order: Order): void {
  if (order.items.length === 0) {
    throw new Error('Order must have at least one item');
  }

  if (order.total < 0) {
    throw new Error('Order total cannot be negative');
  }
}

Custom Error Classes

Create specific error types for different failure scenarios.

Basic Custom Errors

class NetworkError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NetworkError';
  }
}

class HttpError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    message?: string
  ) {
    super(message || `HTTP ${status}: ${statusText}`);
    this.name = 'HttpError';
  }
}

class ValidationError extends Error {
  constructor(
    message: string,
    public field?: string,
    public details?: unknown
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

class NotFoundError extends HttpError {
  constructor(resource: string, id?: string | number) {
    const message = id ? `${resource} with id ${id} not found` : `${resource} not found`;
    super(404, 'Not Found', message);
    this.name = 'NotFoundError';
  }
}

Using Custom Errors

async function fetchUser(id: number): Promise<User> {
  let response: Response;

  try {
    response = await fetch(`/api/users/${id}`);
  } catch (error) {
    throw new NetworkError('Unable to connect to server');
  }

  if (response.status === 404) {
    throw new NotFoundError('User', id);
  }

  if (!response.ok) {
    throw new HttpError(response.status, response.statusText);
  }

  let data: unknown;
  try {
    data = await response.json();
  } catch {
    throw new ValidationError('Response is not valid JSON');
  }

  const result = UserSchema.safeParse(data);
  if (!result.success) {
    throw new ValidationError('Invalid user data', undefined, result.error);
  }

  return result.data;
}

// Handling specific errors
async function displayUser(id: number): Promise<void> {
  try {
    const user = await fetchUser(id);
    console.log(`User: ${user.name}`);
  } catch (error) {
    if (error instanceof NotFoundError) {
      console.log('User does not exist');
    } else if (error instanceof NetworkError) {
      console.log('Please check your internet connection');
    } else if (error instanceof ValidationError) {
      console.log('Received invalid data from server');
    } else {
      console.log('An unexpected error occurred');
    }
  }
}

The Result Pattern

Instead of throwing errors, return a result that explicitly represents success or failure.

Basic Result Type

type Result<T, E = Error> = { success: true; data: T } | { success: false; error: E };

// Helper functions
function ok<T>(data: T): Result<T, never> {
  return { success: true, data };
}

function err<E>(error: E): Result<never, E> {
  return { success: false, error };
}

Using the Result Pattern

async function fetchUserSafe(id: number): Promise<Result<User, string>> {
  try {
    const response = await fetch(`/api/users/${id}`);

    if (!response.ok) {
      if (response.status === 404) {
        return err('User not found');
      }
      return err(`Server error: ${response.status}`);
    }

    const data = await response.json();
    const parsed = UserSchema.safeParse(data);

    if (!parsed.success) {
      return err('Invalid user data');
    }

    return ok(parsed.data);
  } catch {
    return err('Network error');
  }
}

// Usage - no try/catch needed
async function main(): Promise<void> {
  const result = await fetchUserSafe(1);

  if (result.success) {
    console.log(`Found user: ${result.data.name}`);
  } else {
    console.log(`Error: ${result.error}`);
  }
}

Result with Detailed Errors

interface FetchError {
  type: 'network' | 'http' | 'parse' | 'validation';
  message: string;
  status?: number;
  details?: unknown;
}

async function fetchUser(id: number): Promise<Result<User, FetchError>> {
  let response: Response;

  try {
    response = await fetch(`/api/users/${id}`);
  } catch (error) {
    return err({
      type: 'network',
      message: 'Unable to connect to server',
    });
  }

  if (!response.ok) {
    return err({
      type: 'http',
      message: `Server returned ${response.status}`,
      status: response.status,
    });
  }

  let data: unknown;
  try {
    data = await response.json();
  } catch {
    return err({
      type: 'parse',
      message: 'Response is not valid JSON',
    });
  }

  const parsed = UserSchema.safeParse(data);
  if (!parsed.success) {
    return err({
      type: 'validation',
      message: 'Data does not match expected format',
      details: parsed.error.errors,
    });
  }

  return ok(parsed.data);
}

Chaining Results

function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  if (result.success) {
    return ok(fn(result.data));
  }
  return result;
}

function flatMap<T, U, E>(result: Result<T, E>, fn: (value: T) => Result<U, E>): Result<U, E> {
  if (result.success) {
    return fn(result.data);
  }
  return result;
}

// Usage
const userResult = await fetchUser(1);
const nameResult = map(userResult, (user) => user.name);

// Or chain operations
function validateAge(user: User): Result<User, FetchError> {
  if (user.age && user.age < 18) {
    return err({
      type: 'validation',
      message: 'User must be 18 or older',
    });
  }
  return ok(user);
}

const validatedUser = flatMap(userResult, validateAge);

Retry Strategies

For transient failures (network issues, rate limits), retrying can help.

Simple Retry

async function fetchWithRetry<T>(fn: () => Promise<T>, maxAttempts: number = 3): Promise<T> {
  let lastError: Error | undefined;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));
      console.log(`Attempt ${attempt} failed: ${lastError.message}`);

      if (attempt < maxAttempts) {
        console.log(`Retrying...`);
      }
    }
  }

  throw lastError;
}

// Usage
const data = await fetchWithRetry(() => fetch('/api/data').then((r) => r.json()));

Exponential Backoff

Wait longer between each retry to avoid overwhelming the server.

interface RetryOptions {
  maxAttempts: number;
  initialDelayMs: number;
  maxDelayMs: number;
  backoffMultiplier: number;
}

const defaultRetryOptions: RetryOptions = {
  maxAttempts: 3,
  initialDelayMs: 1000,
  maxDelayMs: 30000,
  backoffMultiplier: 2,
};

async function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchWithExponentialBackoff<T>(
  fn: () => Promise<T>,
  options: Partial<RetryOptions> = {}
): Promise<T> {
  const opts = { ...defaultRetryOptions, ...options };
  let lastError: Error | undefined;
  let delay = opts.initialDelayMs;

  for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));

      if (attempt < opts.maxAttempts) {
        console.log(`Attempt ${attempt} failed. Retrying in ${delay}ms...`);
        await sleep(delay);
        delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
      }
    }
  }

  throw new Error(`Failed after ${opts.maxAttempts} attempts: ${lastError?.message}`);
}

// Usage
const data = await fetchWithExponentialBackoff(() => fetch('/api/data').then((r) => r.json()), {
  maxAttempts: 5,
  initialDelayMs: 500,
});

Retry with Jitter

Add randomness to prevent thundering herd problem.

function addJitter(delay: number, jitterFactor: number = 0.1): number {
  const jitter = delay * jitterFactor * (Math.random() * 2 - 1);
  return Math.max(0, delay + jitter);
}

async function fetchWithJitter<T>(fn: () => Promise<T>, maxAttempts: number = 3): Promise<T> {
  let delay = 1000;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt < maxAttempts) {
        const actualDelay = addJitter(delay);
        console.log(`Retrying in ${Math.round(actualDelay)}ms...`);
        await sleep(actualDelay);
        delay *= 2;
      } else {
        throw error;
      }
    }
  }

  throw new Error('Unreachable');
}

Conditional Retry

Only retry for specific error types.

function isRetryable(error: unknown): boolean {
  // Network errors are retryable
  if (error instanceof TypeError && error.message.includes('fetch')) {
    return true;
  }

  // Certain HTTP status codes are retryable
  if (error instanceof HttpError) {
    const retryableStatuses = [408, 429, 500, 502, 503, 504];
    return retryableStatuses.includes(error.status);
  }

  return false;
}

async function fetchWithConditionalRetry<T>(
  fn: () => Promise<T>,
  maxAttempts: number = 3
): Promise<T> {
  let lastError: Error | undefined;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));

      if (!isRetryable(error) || attempt === maxAttempts) {
        throw lastError;
      }

      console.log(`Retryable error on attempt ${attempt}, retrying...`);
      await sleep(1000 * attempt);
    }
  }

  throw lastError;
}

Error Recovery Patterns

Fallback Values

async function fetchConfig(): Promise<Config> {
  const defaultConfig: Config = {
    theme: 'light',
    language: 'en',
    notifications: true,
  };

  try {
    const response = await fetch('/api/config');
    if (!response.ok) {
      return defaultConfig;
    }
    return await response.json();
  } catch {
    return defaultConfig;
  }
}

Fallback Sources

async function fetchWithFallback<T>(
  primaryFn: () => Promise<T>,
  fallbackFn: () => Promise<T>
): Promise<T> {
  try {
    return await primaryFn();
  } catch (primaryError) {
    console.log('Primary source failed, trying fallback...');
    try {
      return await fallbackFn();
    } catch (fallbackError) {
      throw new Error(`Both sources failed. Primary: ${primaryError}. Fallback: ${fallbackError}`);
    }
  }
}

// Usage
const data = await fetchWithFallback(
  () => fetch('https://api.primary.com/data').then((r) => r.json()),
  () => fetch('https://api.backup.com/data').then((r) => r.json())
);

Partial Success

When processing multiple items, continue despite individual failures.

interface ProcessResult<T> {
  successful: T[];
  failed: { item: unknown; error: string }[];
}

async function processItems<T, R>(
  items: T[],
  processor: (item: T) => Promise<R>
): Promise<ProcessResult<R>> {
  const successful: R[] = [];
  const failed: { item: unknown; error: string }[] = [];

  for (const item of items) {
    try {
      const result = await processor(item);
      successful.push(result);
    } catch (error) {
      failed.push({
        item,
        error: error instanceof Error ? error.message : String(error),
      });
    }
  }

  return { successful, failed };
}

// Usage
const userIds = [1, 2, 999, 3, 4]; // 999 doesn't exist

const results = await processItems(userIds, async (id) => {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) throw new Error(`User ${id} not found`);
  return response.json();
});

console.log(`Loaded ${results.successful.length} users`);
console.log(`Failed: ${results.failed.map((f) => f.error).join(', ')}`);

Combining Strategies

Robust Fetch Wrapper

interface FetchOptions {
  timeout?: number;
  retries?: number;
  retryDelay?: number;
}

async function robustFetch<T>(
  url: string,
  schema: z.ZodSchema<T>,
  options: FetchOptions = {}
): Promise<Result<T, FetchError>> {
  const { timeout = 10000, retries = 3, retryDelay = 1000 } = options;

  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      // Create abort controller for timeout
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeout);

      const response = await fetch(url, { signal: controller.signal });
      clearTimeout(timeoutId);

      if (!response.ok) {
        // Don't retry client errors (4xx)
        if (response.status >= 400 && response.status < 500) {
          return err({
            type: 'http',
            message: `Server returned ${response.status}`,
            status: response.status,
          });
        }

        // Retry server errors (5xx)
        if (attempt < retries) {
          await sleep(retryDelay * attempt);
          continue;
        }

        return err({
          type: 'http',
          message: `Server returned ${response.status}`,
          status: response.status,
        });
      }

      const data = await response.json();
      const parsed = schema.safeParse(data);

      if (!parsed.success) {
        return err({
          type: 'validation',
          message: 'Invalid response data',
          details: parsed.error.errors,
        });
      }

      return ok(parsed.data);
    } catch (error) {
      if (error instanceof Error && error.name === 'AbortError') {
        if (attempt < retries) {
          await sleep(retryDelay * attempt);
          continue;
        }
        return err({
          type: 'network',
          message: 'Request timed out',
        });
      }

      if (attempt < retries) {
        await sleep(retryDelay * attempt);
        continue;
      }

      return err({
        type: 'network',
        message: error instanceof Error ? error.message : 'Network error',
      });
    }
  }

  return err({
    type: 'network',
    message: 'All retry attempts failed',
  });
}

// Usage
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

const result = await robustFetch('https://api.example.com/users/1', UserSchema, {
  timeout: 5000,
  retries: 3,
});

if (result.success) {
  console.log(`User: ${result.data.name}`);
} else {
  console.log(`Error (${result.error.type}): ${result.error.message}`);
}

Exercises

Exercise 1: Custom Error Hierarchy

Create a hierarchy of custom errors for an e-commerce application:

  • OrderError (base class)
  • OutOfStockError (with productId and requestedQuantity)
  • PaymentError (with paymentMethod and reason)
  • ShippingError (with address and reason)
Solution
class OrderError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'OrderError';
  }
}

class OutOfStockError extends OrderError {
  constructor(
    public productId: string,
    public requestedQuantity: number,
    public availableQuantity: number
  ) {
    super(
      `Product ${productId}: requested ${requestedQuantity}, only ${availableQuantity} available`
    );
    this.name = 'OutOfStockError';
  }
}

class PaymentError extends OrderError {
  constructor(
    public paymentMethod: string,
    public reason: string
  ) {
    super(`Payment failed (${paymentMethod}): ${reason}`);
    this.name = 'PaymentError';
  }
}

class ShippingError extends OrderError {
  constructor(
    public address: string,
    public reason: string
  ) {
    super(`Cannot ship to "${address}": ${reason}`);
    this.name = 'ShippingError';
  }
}

// Usage
function processOrder(order: Order): void {
  // Check stock
  if (order.quantity > inventory[order.productId]) {
    throw new OutOfStockError(order.productId, order.quantity, inventory[order.productId]);
  }

  // Process payment
  if (!validateCard(order.payment)) {
    throw new PaymentError(order.payment.type, 'Card declined');
  }

  // Validate shipping
  if (!canShipTo(order.address)) {
    throw new ShippingError(order.address, 'Address not serviceable');
  }
}

// Handling
try {
  processOrder(order);
} catch (error) {
  if (error instanceof OutOfStockError) {
    console.log(`Sorry, only ${error.availableQuantity} items available`);
  } else if (error instanceof PaymentError) {
    console.log(`Payment issue: ${error.reason}`);
  } else if (error instanceof ShippingError) {
    console.log(`Shipping issue: ${error.reason}`);
  }
}

Exercise 2: Result Type Implementation

Implement a complete Result type with helper functions:

// Implement these:
// 1. ok<T>(data: T) - creates success result
// 2. err<E>(error: E) - creates error result
// 3. isOk(result) - type guard for success
// 4. isErr(result) - type guard for error
// 5. map(result, fn) - transform success value
// 6. mapError(result, fn) - transform error value
// 7. unwrap(result) - get value or throw
// 8. unwrapOr(result, defaultValue) - get value or default
Solution
type Result<T, E = Error> = { success: true; data: T } | { success: false; error: E };

function ok<T>(data: T): Result<T, never> {
  return { success: true, data };
}

function err<E>(error: E): Result<never, E> {
  return { success: false, error };
}

function isOk<T, E>(result: Result<T, E>): result is { success: true; data: T } {
  return result.success;
}

function isErr<T, E>(result: Result<T, E>): result is { success: false; error: E } {
  return !result.success;
}

function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
  if (isOk(result)) {
    return ok(fn(result.data));
  }
  return result;
}

function mapError<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
  if (isErr(result)) {
    return err(fn(result.error));
  }
  return result;
}

function unwrap<T, E>(result: Result<T, E>): T {
  if (isOk(result)) {
    return result.data;
  }
  throw result.error;
}

function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
  if (isOk(result)) {
    return result.data;
  }
  return defaultValue;
}

// Usage examples
const successResult = ok(42);
const errorResult = err('Something went wrong');

console.log(isOk(successResult)); // true
console.log(isErr(errorResult)); // true

const doubled = map(successResult, (x) => x * 2);
console.log(unwrap(doubled)); // 84

const withDefault = unwrapOr(errorResult, 0);
console.log(withDefault); // 0

Exercise 3: Retry with Rate Limit Handling

Implement a fetch function that handles rate limiting (HTTP 429) with Retry-After header:

async function fetchWithRateLimitHandling<T>(url: string, maxRetries: number = 3): Promise<T> {
  // Implement:
  // 1. Make the request
  // 2. If 429, read Retry-After header (seconds to wait)
  // 3. Wait and retry
  // 4. Throw after max retries
}
Solution
async function fetchWithRateLimitHandling<T>(url: string, maxRetries: number = 3): Promise<T> {
  let lastError: Error | undefined;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url);

      if (response.status === 429) {
        const retryAfter = response.headers.get('Retry-After');
        let waitTime = 60000; // Default: 60 seconds

        if (retryAfter) {
          // Retry-After can be seconds or a date
          const seconds = parseInt(retryAfter, 10);
          if (!isNaN(seconds)) {
            waitTime = seconds * 1000;
          } else {
            // It's a date
            const retryDate = new Date(retryAfter).getTime();
            waitTime = Math.max(0, retryDate - Date.now());
          }
        }

        if (attempt < maxRetries) {
          console.log(`Rate limited. Waiting ${waitTime / 1000}s before retry...`);
          await sleep(waitTime);
          continue;
        }

        throw new Error(`Rate limited after ${maxRetries} attempts`);
      }

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));

      // Only retry on network errors, not HTTP errors (except 429)
      if (error instanceof TypeError && attempt < maxRetries) {
        console.log(`Network error, retrying in ${attempt * 1000}ms...`);
        await sleep(attempt * 1000);
        continue;
      }

      throw lastError;
    }
  }

  throw lastError || new Error('Unknown error');
}

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

// Usage
try {
  const data = await fetchWithRateLimitHandling<User[]>('https://api.example.com/users');
  console.log(`Loaded ${data.length} users`);
} catch (error) {
  console.error('Failed to load users:', error);
}

Exercise 4: Partial Success Handler

Create a function that processes items in parallel, collecting both successes and failures:

interface BatchResult<T> {
  results: T[];
  errors: { index: number; error: string }[];
  successRate: number;
}

async function processBatch<T, R>(
  items: T[],
  processor: (item: T) => Promise<R>,
  concurrency?: number
): Promise<BatchResult<R>> {
  // Process items with optional concurrency limit
  // Return successes, failures, and success rate
}
Solution
interface BatchResult<T> {
  results: T[];
  errors: { index: number; item: unknown; error: string }[];
  successRate: number;
}

async function processBatch<T, R>(
  items: T[],
  processor: (item: T) => Promise<R>,
  concurrency: number = items.length
): Promise<BatchResult<R>> {
  const results: R[] = [];
  const errors: { index: number; item: unknown; error: string }[] = [];

  // Process in chunks for concurrency control
  for (let i = 0; i < items.length; i += concurrency) {
    const chunk = items.slice(i, i + concurrency);

    const chunkPromises = chunk.map(async (item, chunkIndex) => {
      const actualIndex = i + chunkIndex;

      try {
        const result = await processor(item);
        return { success: true as const, index: actualIndex, result };
      } catch (error) {
        return {
          success: false as const,
          index: actualIndex,
          item,
          error: error instanceof Error ? error.message : String(error),
        };
      }
    });

    const chunkResults = await Promise.all(chunkPromises);

    for (const result of chunkResults) {
      if (result.success) {
        results.push(result.result);
      } else {
        errors.push({
          index: result.index,
          item: result.item,
          error: result.error,
        });
      }
    }
  }

  const successRate = items.length > 0 ? (results.length / items.length) * 100 : 100;

  return { results, errors, successRate };
}

// Usage
const userIds = [1, 2, 999, 3, 4, 998, 5];

const batchResult = await processBatch(
  userIds,
  async (id) => {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error(`User ${id} not found`);
    return response.json();
  },
  3 // Process 3 at a time
);

console.log(`Success rate: ${batchResult.successRate.toFixed(1)}%`);
console.log(`Loaded: ${batchResult.results.length} users`);
console.log(`Failed: ${batchResult.errors.length} items`);

for (const error of batchResult.errors) {
  console.log(`  - Index ${error.index}: ${error.error}`);
}

Key Takeaways

  1. Custom error classes help distinguish between different failure types
  2. The Result pattern makes error handling explicit without try/catch
  3. Retry with backoff handles transient failures gracefully
  4. Only retry retryable errors (network, 5xx, 429) - not client errors (4xx)
  5. Fallback values and sources provide graceful degradation
  6. Partial success handling allows continuing despite some failures
  7. Combine strategies for robust data pipelines
  8. Always log errors with context for debugging

Resources

Resource Type Level
TypeScript Error Handling Documentation Intermediate
Exponential Backoff Article Intermediate
Error Handling in Node.js Documentation Intermediate
neverthrow - Result type for TS Library Intermediate

Next Lesson

Now let us put everything together and build a complete data processing pipeline.

Continue to Lesson 5.5: Practice - Data Pipeline