Back to Blog
Backend

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.

Adsesugh Igba
March 28, 2025
9 min read

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:

  1. Request enters priority queue (Pattern 3)
  2. Circuit breaker checks service health (Pattern 2)
  3. Idempotency key attached (Pattern 4)
  4. Request sent with retry logic (Pattern 1)
  5. 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
    5 API Integration Patterns That Save You From Headaches — Sughware Blog | Sughware Technologies