5 API Integration Patterns That Save You From Headaches
From retry logic to circuit breakers, these are the patterns that separate hobby projects from production-ready systems.
Why Integration Patterns Matter
Every modern application depends on external services — payment processors, email providers, AI APIs, analytics platforms. These integrations are the most fragile points in your system. Networks fail, services go down, rate limits get hit, and responses arrive malformed.
The difference between a system that degrades gracefully and one that cascades into total failure comes down to a handful of patterns. Here are the five we implement on every project.
Pattern 1: Exponential Backoff with Jitter
When an API call fails, the worst thing you can do is retry immediately in a tight loop. This amplifies load on an already-struggling service and often triggers rate limiting.
Exponential backoff increases the delay between retries geometrically. Jitter adds randomness to prevent thundering herd problems when multiple clients retry simultaneously.
interface RetryConfig {
maxRetries: number;
baseDelay: number; // milliseconds
maxDelay: number; // milliseconds
backoffFactor: number;
}
async function withRetry<T>(
fn: () => Promise<T>,
config: RetryConfig = { maxRetries: 3, baseDelay: 1000, maxDelay: 30000, backoffFactor: 2 }
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === config.maxRetries) break;
if (!isRetryable(error)) break;
const delay = Math.min(
config.baseDelay * Math.pow(config.backoffFactor, attempt),
config.maxDelay
);
// Add jitter: random value between 0 and delay
const jitter = delay * Math.random();
await sleep(delay + jitter);
}
}
throw lastError!;
}
function isRetryable(error: unknown): boolean {
if (error instanceof HTTPError) {
// Retry on 429 (rate limit), 502, 503, 504 (infrastructure issues)
return [429, 502, 503, 504].includes(error.status);
}
// Retry on network errors
if (error instanceof TypeError && error.message.includes("fetch")) {
return true;
}
return false;
}
When to use: Every external API call. No exceptions.
Pattern 2: Circuit Breaker
The circuit breaker pattern prevents your application from repeatedly calling a service that's clearly down. Like an electrical circuit breaker, it "trips" after a threshold of failures and stops sending requests for a cooldown period.
enum CircuitState {
CLOSED = "closed", // Normal operation
OPEN = "open", // Blocking requests
HALF_OPEN = "half_open" // Testing recovery
}
class CircuitBreaker {
private state: CircuitState = CircuitState.CLOSED;
private failureCount = 0;
private lastFailureTime: number = 0;
constructor(
private readonly failureThreshold: number = 5,
private readonly resetTimeout: number = 60000, // 1 minute
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === CircuitState.OPEN) {
if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
this.state = CircuitState.HALF_OPEN;
} else {
throw new CircuitBreakerOpenError("Circuit breaker is OPEN");
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess() {
this.failureCount = 0;
this.state = CircuitState.CLOSED;
}
private onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = CircuitState.OPEN;
}
}
}
When to use: Any service where downtime is likely and you have a meaningful fallback (cached data, default values, or graceful feature disabling).
Pattern 3: Request Queuing with Priority
When integrating with rate-limited APIs (Stripe, OpenAI, SendGrid), a naive approach sends requests as they come and handles 429 errors reactively. A better approach queues requests proactively and processes them at a controlled rate.
class PriorityQueue<T> {
private queues: Map<number, Array<{
task: () => Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
}>> = new Map();
private processing = false;
private requestsPerSecond: number;
constructor(requestsPerSecond: number) {
this.requestsPerSecond = requestsPerSecond;
}
enqueue(task: () => Promise<T>, priority: number = 5): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.queues.has(priority)) {
this.queues.set(priority, []);
}
this.queues.get(priority)!.push({ task, resolve, reject });
this.processNext();
});
}
private async processNext() {
if (this.processing) return;
this.processing = true;
while (this.hasItems()) {
const item = this.dequeueHighestPriority();
if (!item) break;
try {
const result = await item.task();
item.resolve(result);
} catch (error) {
item.reject(error as Error);
}
// Rate limiting delay
await sleep(1000 / this.requestsPerSecond);
}
this.processing = false;
}
}
When to use: Bulk operations (sending 1,000 emails), AI inference queues, or any integration with strict rate limits.
Pattern 4: Idempotency Keys
Network failures can leave you uncertain whether a request was processed. Did the payment go through? Did the email send? Without idempotency, retrying could charge a customer twice or send duplicate notifications.
import { randomUUID } from "crypto";
class IdempotentClient {
private keyStore: Map<string, { response: unknown; expiresAt: number }> = new Map();
async execute<T>(
operationId: string,
fn: (idempotencyKey: string) => Promise<T>
): Promise<T> {
// Generate deterministic key from operation context
const key = this.generateKey(operationId);
// Check if we already have a result for this operation
const cached = this.keyStore.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.response as T;
}
// Execute with idempotency key header
const result = await fn(key);
// Cache the result
this.keyStore.set(key, {
response: result,
expiresAt: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
});
return result;
}
private generateKey(operationId: string): string {
return `idem_${operationId}_${randomUUID()}`;
}
}
// Usage with Stripe
const client = new IdempotentClient();
await client.execute(`charge_${orderId}`, (key) =>
stripe.charges.create(
{ amount: 2000, currency: "usd", source: tokenId },
{ idempotencyKey: key }
)
);
When to use: Any operation with side effects — payments, data mutations, notifications, resource provisioning.
Pattern 5: Response Normalization & Validation
External APIs change without warning. Fields get renamed, types shift, new required fields appear. Trusting raw API responses without validation is a recipe for runtime crashes in production.
import { z } from "zod";
// Define strict schemas for external API responses
const StripePaymentIntentSchema = z.object({
id: z.string().startsWith("pi_"),
status: z.enum([
"requires_payment_method",
"requires_confirmation",
"requires_action",
"processing",
"succeeded",
"canceled",
]),
amount: z.number().positive(),
currency: z.string().length(3),
created: z.number(),
metadata: z.record(z.string()).optional(),
});
// Normalize into your internal domain model
interface PaymentResult {
externalId: string;
status: "pending" | "completed" | "failed";
amountCents: number;
currency: string;
createdAt: Date;
}
function normalizePaymentIntent(raw: unknown): PaymentResult {
const parsed = StripePaymentIntentSchema.parse(raw);
return {
externalId: parsed.id,
status: mapStripeStatus(parsed.status),
amountCents: parsed.amount,
currency: parsed.currency.toUpperCase(),
createdAt: new Date(parsed.created * 1000),
};
}
function mapStripeStatus(status: string): PaymentResult["status"] {
switch (status) {
case "succeeded": return "completed";
case "canceled": return "failed";
default: return "pending";
}
}
When to use: Every external API response. Parse, validate, and normalize at the boundary.
Putting It All Together
In practice, these patterns compose. A production API client typically looks like:
- Request enters priority queue (Pattern 3)
- Circuit breaker checks service health (Pattern 2)
- Idempotency key attached (Pattern 4)
- Request sent with retry logic (Pattern 1)
- Response validated and normalized (Pattern 5)
This layered approach means your application handles failures gracefully at every level — from transient network blips to extended service outages. The user experience remains smooth, and your engineering team sleeps better at night.
Final Advice
Don't implement all five patterns on day one for a new integration. Start with retry logic and response validation (the highest-ROI patterns), then add circuit breakers and queuing as your traffic grows and failure modes become clear. Let production data guide your investment in resilience.
Ready to build something extraordinary?
We help startups and enterprises ship production-grade software powered by AI. Let's discuss your next project.
Get In Touch