API Integration Patterns: Handling Rate Limits, Retries, and Failures
What You’ll Learn
- The API integration patterns I rely on most in production work
- How to treat retries and rate limits without making failures worse
- Why failure classification matters more than generic catch blocks
- A simple TypeScript wrapper pattern for external API calls
- What makes integrations feel reliable to clients and teams
API integrations fail in boring ways.
They timeout. They rate limit. They return partial junk. They succeed twice. They change a field name with no warning. They work beautifully in staging and fail under real traffic because the happy path was the only path anyone implemented.
That is why I care less about “how to call an API” and more about how to survive an API.
These are the patterns I keep reusing in production systems.
1. Put the Integration Behind a Thin Adapter
I do not want business logic talking to raw vendor responses all over the codebase.
I want one integration boundary with one contract.
export async function fetchCustomer(customerId: string) {
const response = await fetch(`${process.env.CRM_BASE_URL}/customers/${customerId}`, {
headers: {
Authorization: `Bearer ${process.env.CRM_API_KEY}`,
},
});
if (!response.ok) {
throw new Error(`CRM request failed with ${response.status}`);
}
return response.json();
}
This is the place where retries, error mapping, and response normalization should evolve later.
2. Retry the Right Failures, Not All Failures
One of the worst integration habits is retrying everything blindly.
Retries make sense for:
- temporary network failures
- timeouts
- transient 5xx responses
- some rate-limit responses when the API indicates retry timing
Retries do not make sense for:
- malformed requests
- auth failures
- validation errors
- permanent 4xx problems
The wrapper should know the difference.
function shouldRetry(status: number) {
return status === 429 || status >= 500;
}
That tiny distinction prevents a lot of noise.
3. Respect Rate Limits Explicitly
Rate limits are not edge cases. They are part of the contract.
If an API gives you a 429, I want the integration layer to treat that as a first-class scenario, not a surprise.
At minimum:
- detect it explicitly
- back off
- log it clearly
- avoid hammering the endpoint harder
Even a small retry helper is better than nothing:
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (error) {
attempt += 1;
if (attempt > maxRetries) throw error;
await sleep(attempt * 1000);
}
}
}
Real systems usually need more nuance, but this already encodes the right default: do not slam the provider when it is telling you to slow down.
4. Add Timeouts on Purpose
If an external API hangs and your code waits forever, that is not resilience. That is denial.
I want timeout behavior to be explicit because it affects:
- user experience
- queue depth
- background job throughput
- retry behavior
In JavaScript and TypeScript, AbortController is often enough for the first version.
5. Normalize Responses Before the Rest of the App Sees Them
Third-party payloads often contain fields your app should not have to care about directly.
I prefer mapping them into my own shape once:
type CustomerSummary = {
id: string;
email: string;
plan: string;
};
function normalizeCustomer(data: any): CustomerSummary {
return {
id: data.id,
email: data.email,
plan: data.subscription_plan,
};
}
This reduces vendor leakage into the rest of the product.
6. Design Idempotency for Mutating Calls
If an integration creates charges, tickets, messages, or orders, retries can become dangerous unless the mutation is idempotent.
That usually means one of two things:
- pass the provider’s supported idempotency key
- persist your own dedupe key locally
This is one of the cheapest ways to avoid embarrassing integration bugs.
7. Classify Failures for Humans Too
I like my integration layer to tell me what kind of failure happened, not just that something failed.
Examples:
network_errorrate_limitedtimeoutauth_failedbad_requestprovider_error
That classification makes operational debugging much easier, especially once multiple providers are involved.
Final Thought
Reliable integrations are rarely about calling the API correctly one time. They are about handling the full lifecycle of failure well enough that the rest of the product can stay sane.
If you isolate the adapter, retry selectively, respect rate limits, normalize responses, and design for idempotency, your integration layer gets much more trustworthy very quickly.
If you need help building API integrations, internal tooling, or automation systems that survive real provider behavior, take a look at my portfolio: voidcraft-site.vercel.app.