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.