Lesson 5.4: Error Handling Strategies
Duration: 60 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Understand different types of errors in data processing
- Implement the Result pattern for type-safe error handling
- Create custom error classes for specific failure scenarios
- Build resilient data pipelines with proper error recovery
- 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
- Custom error classes help distinguish between different failure types
- The Result pattern makes error handling explicit without try/catch
- Retry with backoff handles transient failures gracefully
- Only retry retryable errors (network, 5xx, 429) - not client errors (4xx)
- Fallback values and sources provide graceful degradation
- Partial success handling allows continuing despite some failures
- Combine strategies for robust data pipelines
- 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.