Skip to main content
DesignKey Studio

Development

5 API Integration Failure Modes and Fixes

Auth expiration, rate limiting, schema changes, timeout handling, webhook failures. Five API integration failure modes with how to detect and prevent each.

5 API Integration Failure Modes and Fixes - Development article hero

API integrations fail in predictable ways. The patterns are consistent enough that experienced backend engineers have pattern-matched names for each one. The frustrating part is that most of these failures are preventable - they happen not because the API is poorly designed, but because the integration was built without accounting for edge cases that were entirely foreseeable.

This guide covers the five most common API integration failure modes, with a technical breakdown of what breaks, how to detect it in production, and how to build the integration so it does not fail in the first place. The examples use TypeScript and async/await patterns consistent with a Node.js or NestJS backend.

The TL;DR

  • Authentication tokens expire - build token refresh logic before you need it, not after it breaks in production.
  • Rate limits hit without backoff cause cascading failures - implement exponential backoff with jitter.
  • Upstream schema changes break downstream consumers silently - validate API responses, do not assume shape.
  • Long-running operations need timeout handling and status polling, not a single blocking request.
  • Webhooks drop without retry logic and visibility - log every event, implement idempotent handlers.

Failure Mode 1: Authentication Expiration and Token Refresh Failures

What Breaks

OAuth 2.0 access tokens have expiration times, typically ranging from one hour (most REST APIs) to a few minutes (some real-time data APIs). When an access token expires, the API returns a 401 Unauthorized response. An integration that was not built to handle this will either fail silently, throw an unhandled error, or surface a cryptic error to the user.

Token refresh failures compound the problem. The integration may attempt to refresh the token but fail because the refresh token has also expired (common with long-lived user sessions), because the refresh endpoint itself is rate-limited, or because the integration logic incorrectly treats a 401 on the refresh endpoint as a regular API error and stops retrying.

How to Detect It

In production, token expiration failures typically surface as sporadic 401 errors in logs that correspond to user sessions that were idle for a period close to the token lifetime. They are frequently mistaken for authentication bugs when the real issue is token lifecycle management.

How to Build It

Implement proactive token refresh rather than reactive. Instead of waiting for a 401 to trigger a refresh, schedule a refresh before the token expires.

interface TokenStore {
  accessToken: string;
  refreshToken: string;
  expiresAt: number; // Unix timestamp ms
}

async function getValidAccessToken(store: TokenStore): Promise<string> {
  const bufferMs = 60_000; // refresh 60 seconds before expiry
  if (Date.now() < store.expiresAt - bufferMs) {
    return store.accessToken;
  }
  return refreshAccessToken(store.refreshToken);
}

async function refreshAccessToken(refreshToken: string): Promise<string> {
  const response = await fetch('/oauth/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    }),
  });

  if (!response.ok) {
    throw new Error(`Token refresh failed: ${response.status}`);
  }

  const data = await response.json();
  // persist updated tokens to store
  return data.access_token;
}

Always persist the new token pair after a successful refresh. If the token store is in-memory and the server restarts, the integration needs to re-authenticate from scratch - acceptable if handled gracefully, catastrophic if not anticipated.

Failure Mode 2: Rate Limiting Without Backoff

What Breaks

Most production APIs enforce rate limits - a maximum number of requests per second, minute, or hour. When the limit is exceeded, the API returns a 429 Too Many Requests response, typically with a Retry-After header indicating when requests can resume.

An integration without backoff logic will either retry immediately (flooding the API and extending the rate limit window), fail permanently after a single 429 (losing data), or queue up requests without bounds and exhaust memory.

The failure mode is frequently encountered in bulk operations - importing records, syncing data sets, or batch-sending notifications - where the volume of requests exceeds the API's limit for that time window.

How to Detect It

Rate limit failures appear as 429 responses in logs, often in bursts. If the integration is retrying without delay, the 429 responses will cluster - many 429s in a short window followed by successful responses when the window resets.

How to Build It

Implement exponential backoff with jitter. Exponential backoff increases the wait time between retries geometrically (1s, 2s, 4s, 8s...). Jitter adds randomness to the wait time to prevent synchronized retries when multiple instances hit the limit simultaneously.

async function fetchWithRetry(
  url: string,
  options: RequestInit,
  maxRetries = 4,
): Promise<Response> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.status !== 429) {
      return response;
    }

    if (attempt === maxRetries) {
      throw new Error(`Rate limited after ${maxRetries} retries`);
    }

    const retryAfter = response.headers.get('Retry-After');
    const baseDelay = retryAfter
      ? parseInt(retryAfter, 10) * 1000
      : Math.pow(2, attempt) * 1000;

    // add up to 20% jitter
    const jitter = baseDelay * 0.2 * Math.random();
    await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
  }

  throw new Error('Unreachable');
}

For bulk operations, implement request queuing with a configurable concurrency limit. Sending 1,000 requests in parallel against a 100-req/min API will hit the limit immediately. Sending them at a rate of 90 per minute (a conservative buffer below the limit) will complete without hitting the rate limiter.

Failure Mode 3: Upstream Schema Changes Breaking Downstream Consumers

What Breaks

External APIs change. Fields are renamed, deprecated fields are removed, response structures are reorganized, and new required fields appear in request bodies. If your integration assumes the shape of the API response without validation, a schema change in the upstream API can silently corrupt data or throw unhandled runtime errors.

The silent corruption case is the most dangerous. If a field is renamed from user_id to userId, and your integration reads user_id from the response, it receives undefined - which may be written to the database without error, causing a downstream data quality problem that is not detected for weeks.

How to Detect It

Validate API response shapes before using them. Log validation failures with the raw response body so that schema changes are immediately visible when they happen, rather than discovered through data quality issues.

How to Build It

Use runtime schema validation on all API responses. Zod is well-suited for this in TypeScript projects:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  displayName: z.string(),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(userId: string): Promise<User> {
  const response = await fetch(`/api/users/${userId}`);
  const raw = await response.json();

  const result = UserSchema.safeParse(raw);
  if (!result.success) {
    // log the raw response and the validation error for debugging
    console.error('User schema validation failed', {
      errors: result.error.issues,
      raw,
    });
    throw new Error('Upstream API response did not match expected schema');
  }

  return result.data;
}

When the upstream API adds new optional fields, the schema should use .passthrough() or explicitly add the field with .optional() to avoid false negatives. The goal is to catch breaking changes, not to reject non-breaking additions.

Subscribe to API changelog notifications or versioning feeds if the provider offers them. Schema changes that break consumers should not be discovered by reading production error logs.

Failure Mode 4: Timeout Handling in Long-Running Operations

What Breaks

Some operations - generating reports, processing file uploads, running data exports - take too long to complete within a standard HTTP request timeout (typically 30-60 seconds for most infrastructure configurations). An integration that submits a request and waits synchronously for a response will time out, leaving the operation in an indeterminate state: the server may have completed the work, may be partway through, or may have failed.

The indeterminate state is the core problem. The calling application does not know whether to retry (which may create a duplicate), log a failure, or wait. If it retries, it risks duplicate processing. If it fails silently, it misses completed work.

How to Build It

Long-running operations should use an async job pattern: submit the request and receive a job ID, then poll for completion status.

async function submitExportJob(params: ExportParams): Promise<string> {
  const response = await fetch('/api/exports', {
    method: 'POST',
    body: JSON.stringify(params),
  });
  const { jobId } = await response.json();
  return jobId;
}

async function waitForJobCompletion(
  jobId: string,
  timeoutMs = 300_000,
  intervalMs = 5_000,
): Promise<ExportResult> {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const response = await fetch(`/api/exports/${jobId}`);
    const job = await response.json();

    if (job.status === 'completed') return job.result;
    if (job.status === 'failed') throw new Error(`Job failed: ${job.error}`);

    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }

  throw new Error(`Job ${jobId} timed out after ${timeoutMs}ms`);
}

Set a maximum poll duration that is realistic for the operation and surfaced in the UI. "Your export is being prepared" is a better user experience than a silent loading state that eventually times out.

Failure Mode 5: Webhook Delivery Failures With No Retry Logic

What Breaks

Webhooks are push notifications from an API to your application. When an event occurs (a payment succeeds, a user joins, a file processes), the API sends an HTTP POST to your webhook endpoint. If your endpoint is down, returns an error, or takes too long to respond, the webhook is lost - unless the sending API has its own retry logic, which not all do.

Beyond delivery failures, webhook handlers without idempotency logic produce incorrect results when the same event is delivered more than once - which happens on retry, or when the API deduplicates events imperfectly.

How to Build It

Handle webhooks in two phases: acknowledge immediately, process asynchronously.

// Webhook handler endpoint
async function handleWebhook(req: Request, res: Response): Promise<void> {
  const signature = req.headers['x-webhook-signature'];
  if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET!)) {
    res.status(401).send('Invalid signature');
    return;
  }

  // acknowledge receipt immediately - before any processing
  res.status(200).send('OK');

  // enqueue for async processing
  await queue.add('process-webhook', {
    eventId: req.body.id,
    eventType: req.body.type,
    payload: req.body,
    receivedAt: new Date().toISOString(),
  });
}

// Async processor with idempotency
async function processWebhookEvent(job: WebhookJob): Promise<void> {
  // check if this event was already processed
  const alreadyProcessed = await db.webhookEvents.findOne({
    where: { eventId: job.data.eventId },
  });

  if (alreadyProcessed) {
    console.log(`Duplicate webhook event ${job.data.eventId} skipped`);
    return;
  }

  // process the event
  await handleEventByType(job.data.eventType, job.data.payload);

  // record as processed
  await db.webhookEvents.create({
    eventId: job.data.eventId,
    processedAt: new Date(),
  });
}

Log every webhook event received, including duplicates, with the raw payload. When a webhook-driven process produces unexpected results, the event log is the primary debugging tool. Without it, the debugging session starts with "did the event even arrive?" - a question that should be answerable in seconds, not hours.

Building Integrations That Hold Up

The common thread across these five failure modes is that none of them are surprising in production - they are all foreseeable from the API documentation and common production experience. The integrations that fail are the ones built against the happy path only, without designing for the error cases.

Our API integration services and backend and cloud engineering work includes designing for these failure modes as a standard practice. If you are building or inheriting an integration that is producing intermittent failures in production, the debugging starting point is almost always one of these five patterns.

For a broader treatment of how API integration fits into a full SaaS architecture, see our custom software and SaaS development guide.

api-integrationsoftware-developmentweb-developmentsaas
Written byDaniel Killyevo8 min read

Share this article

Your next project?

Whether it's an internal tool for your company or a highly available Software-as-a-Service - we help you to get your ideas off the ground!