Skip to content
Back to Build Tutorials

API Integration

Call an external REST/GraphQL API with typed clients, retries, and rate-limit handling.

Why

Every external API call is a potential outage: the network drops, the provider rate-limits you, or a field rename breaks your parser at 2am. A typed client generated from the OpenAPI spec turns 'field renamed' into a compile error instead of a runtime undefined. Retries with exponential backoff handle transient failures without human intervention, and a rate-limit handler prevents you from getting 429-banned by the provider. Without these layers, a single provider hiccup cascades into a user-facing 500 because the call is fire-and-forget with no fallback.

When

The moment you call a non-trivial external API. Takes 1-2 h for a typed client + retry wrapper. Skip for one-off curl scripts.

How

1. Generate a typed client from the provider's OpenAPI spec
   - npx openapi-typescript-codegen --input spec.yaml --output src/api-client
   - If no spec exists, hand-write a zod schema for the response and parse every call

2. Wrap the client with a retry + timeout layer
   - Use p-retry or async-retry with 3 attempts, exponential backoff (1s, 2s, 4s)
   - Set a 10s timeout per call so a hung connection doesn't block the request

3. Handle rate limits by reading Retry-After and queuing
   - On 429: read the Retry-After header, sleep, retry — do NOT immediately retry
   - For high-volume: use a token-bucket limiter (p-limit) to stay under the limit

4. Add a circuit breaker for repeated failures
   - After 5 consecutive failures, stop calling for 60s (half-open probe)
   - This prevents cascading failures when the provider is down

5. Log every call with request id, latency, status — but redact auth headers
   - This is your debugging lifeline when the provider says "we never received your request"

Example

import { retry } from "p-retry";
import { client } from "./api-client";  // generated from OpenAPI

export async function getUser(id: string) {
  return retry(
    () => client.GET("/users/{id}", { params: { path: { id } } }),
    { retries: 3, minTimeout: 1000, maxTimeout: 4000, onFailedAttempt: (e) => {
      if (e.response?.status === 429) throw e;  // don't retry 429s
    }}
  );
}

// Rate-limit aware caller
import pLimit from "p-limit";
const limit = pLimit(5);  // max 5 concurrent
const results = await Promise.all(ids.map(id => limit(() => getUser(id))));

Common Problems

  • ⚠Retrying 429s immediately and getting IP-banned by the provider for 24 hours.
  • ⚠No timeout on the HTTP call, so a stalled provider connection hangs your server's worker pool.
  • ⚠Parsing the response with 'as any' instead of a schema, so a field rename silently returns undefined.
  • ⚠Calling the API on every request instead of caching idempotent results — you pay 10x the API quota cost.

Best Practices

  • ✓Regenerate the client in CI when the spec changes, so a breaking provider update fails the build before it reaches prod.
  • ✓Use a separate API key per environment so a leaked staging key can't touch prod data.
  • ✓Cache idempotent GETs with a short TTL (60s) using stale-while-revalidate — cuts API calls 80% with no staleness risk.
  • ✓Emit a custom metric (api.provider.latency) so you can alert on degradation before users notice.