Designing Resilient TypeScript APIs for Scalable Distributed Systems
Learn how to build resilient TypeScript APIs that effectively handle errors, ensuring your distributed system scales smoothly and remains robust.
Building scalable distributed systems requires APIs that can gracefully handle errors. When services fail or become slow, your system should recover or degrade gracefully without crashing. In this article, we'll explore beginner-friendly approaches to designing resilient TypeScript APIs focusing on error handling, retries, and timeouts.
### 1. Centralize Error Handling Using Custom Error Classes Start by defining custom error classes to classify common API errors. This helps you distinguish between client errors, server errors, and network failures easily.
class ApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = 'ApiError';
this.statusCode = statusCode;
}
}
class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}### 2. Handle API Calls with Try-Catch and Return Clear Results Always use try-catch blocks around your asynchronous calls to handle errors gracefully. Returning a consistent response shape with error details helps your frontend or consumer services take appropriate action.
async function fetchUserData(userId: string) {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new ApiError('Failed to fetch user data', response.status);
}
return await response.json();
} catch (error) {
if (error instanceof ApiError) {
// Handle known API errors
console.error('API error:', error.message);
} else {
// Handle network errors or unknown issues
console.error('Unexpected error:', error);
}
return null; // Return null or a fallback to keep the system stable
}
}### 3. Implement Retries with Exponential Backoff For transient errors like network hiccups, retries can improve resilience. Exponential backoff gradually increases wait times between retries to reduce load on your services during outages.
async function retry<T>(fn: () => Promise<T>, retries = 3, delay = 500): Promise<T> {
try {
return await fn();
} catch (error) {
if (retries === 0) throw error;
await new Promise(resolve => setTimeout(resolve, delay));
return retry(fn, retries - 1, delay * 2);
}
}
// Usage example:
async function getUser() {
return retry(() => fetchUserData('123'));
}### 4. Use Timeouts to Avoid Hanging Requests In distributed systems, requests can hang if a service doesn't respond. Wrapping API calls in timeouts protects your system from waiting indefinitely.
function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new NetworkError('Request timed out'));
}, ms);
promise
.then(value => {
clearTimeout(timer);
resolve(value);
})
.catch(err => {
clearTimeout(timer);
reject(err);
});
});
}
// Usage example:
async function fetchWithTimeout() {
try {
const data = await timeout(fetchUserData('123'), 3000); // 3 seconds timeout
return data;
} catch (error) {
console.error(error);
return null;
}
}### Summary By centralizing error handling with custom errors, using try-catch blocks properly, implementing retries with exponential backoff, and adding request timeouts, your TypeScript APIs will be more resilient in distributed systems. These techniques help maintain system stability and provide a better experience for users and services that depend on your APIs.