Common API Errors and How to Fix Them
Common API Errors and How to Fix Them
If you've spent any time building software that talks to external services, you know the frustration. You write what looks like perfectly valid code, hit send, and get back a cryptic error message that tells you almost nothing about what went wrong. API errors are the number one source of developer frustration — not because they're inherently difficult, but because the error messages are often vague, the documentation is scattered, and the fixes vary wildly depending on your environment.
This guide covers the most common API errors you'll encounter as a developer, explains exactly why each one happens, and gives you concrete code examples to fix them. Whether you're working with REST APIs, GraphQL endpoints, or third-party services, these errors and their solutions apply universally.
CORS Errors
CORS (Cross-Origin Resource Sharing) errors are arguably the most confusing API error for developers encountering them for the first time. You make a request from your frontend, it works perfectly in Postman or cURL, but your browser refuses to show the response.
What CORS Is and Why It Exists
Browsers enforce a security policy called the Same-Origin Policy. This means JavaScript running on https://myapp.com cannot freely make requests to https://api.othersite.comunless the server explicitly allows it. This prevents malicious websites from making requests to your bank's API using your cookies. CORS is the mechanism servers use to declare which origins are allowed to access their resources.
The Error Message
You'll typically see something like this in your browser console:
Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.Server-Side Fix
The fix must happen on the server. The API needs to include the Access-Control-Allow-Originheader in its response. Here's how to do it in Express.js:
// Express.js — install cors package first: npm install cors
const express = require('express');
const cors = require('cors');
const app = express();
// Allow specific origins
app.use(cors({
origin: ['https://myapp.com', 'http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.get('/api/data', (req, res) => {
res.json({ message: 'This response includes CORS headers' });
});In Next.js API routes, you set headers manually:
// app/api/data/route.ts (Next.js App Router)
import { NextResponse } from 'next/server';
export async function GET() {
const response = NextResponse.json({ message: 'Hello' });
response.headers.set('Access-Control-Allow-Origin', 'https://myapp.com');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}
export async function OPTIONS() {
const response = new NextResponse(null, { status: 204 });
response.headers.set('Access-Control-Allow-Origin', 'https://myapp.com');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}Development Proxy Workaround
If you don't control the API server, use a proxy during development. In a Vite or Next.js project, you can proxy API requests through your own dev server to avoid CORS entirely:
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/external/:path*',
destination: 'https://api.example.com/:path*',
},
];
},
};401 Unauthorized
A 401 error means the server cannot verify your identity. You either didn't send credentials, or the credentials you sent are invalid. This is an authentication failure — the server doesn't know who you are.
Common Causes
- Missing token: You forgot to include the Authorization header entirely.
- Expired token: Your JWT or session token has expired and needs to be refreshed.
- Malformed token:You included the token but used the wrong format (e.g., missing the "Bearer " prefix).
- Wrong API key:You're using a development key against a production endpoint, or vice versa.
Code Fix
// Wrong — missing Authorization header
const response = await fetch('https://api.example.com/user');
// Correct — include Bearer token
const response = await fetch('https://api.example.com/user', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
// With automatic token refresh on 401
async function fetchWithAuth(url: string, options: RequestInit = {}) {
let token = getStoredToken();
let response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
});
if (response.status === 401) {
token = await refreshToken();
response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
},
});
}
return response;
}403 Forbidden
A 403 is often confused with 401, but they mean different things. With a 401, the server doesn't know who you are. With a 403, the server knows exactly who you are — but you don't have permission to access the requested resource.
Common Causes
- Insufficient scopes: Your OAuth token was granted
read:userbut the endpoint requireswrite:user. - Role-based restrictions:Your account has a "viewer" role but the endpoint requires "admin".
- IP restrictions: The API only allows requests from whitelisted IP addresses.
- Resource ownership:You're trying to access another user's data.
How to Debug
First, check the response body. Many APIs include a detailed message explaining why access was denied. Then verify your token's scopes. If you're using a JWT, you can decode it to inspect the claims:
// Decode a JWT to check its claims (don't use this for verification)
function decodeJWT(token: string) {
const payload = token.split('.')[1];
return JSON.parse(atob(payload));
}
const claims = decodeJWT(myToken);
console.log('Scopes:', claims.scope);
console.log('Role:', claims.role);
console.log('Expires:', new Date(claims.exp * 1000));If the scopes are wrong, you'll need to re-authenticate with the correct permissions. Check the API documentation for the exact scopes each endpoint requires.
Rate Limiting (429 Too Many Requests)
A 429 status code means you've sent too many requests in a given time period. Almost every production API enforces rate limits to protect their infrastructure from abuse and ensure fair usage across all clients.
Reading Rate Limit Headers
Most APIs include headers that tell you exactly where you stand:
X-RateLimit-Limit: 100 # Max requests per window
X-RateLimit-Remaining: 0 # Requests left in current window
X-RateLimit-Reset: 1672531200 # Unix timestamp when the window resets
Retry-After: 30 # Seconds to wait before retryingImplementing Exponential Backoff
The correct way to handle rate limits is exponential backoff — wait progressively longer between retries. Never retry immediately in a tight loop, as this will only make the problem worse and could get your API key banned.
async function fetchWithRetry(
url: string,
options: RequestInit = {},
maxRetries = 3
): 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('Max retries exceeded due to rate limiting');
}
// Check for Retry-After header first
const retryAfter = response.headers.get('Retry-After');
let waitTime: number;
if (retryAfter) {
waitTime = parseInt(retryAfter, 10) * 1000;
} else {
// Exponential backoff: 1s, 2s, 4s, 8s...
waitTime = Math.pow(2, attempt) * 1000;
}
// Add jitter to prevent thundering herd
waitTime += Math.random() * 1000;
console.log(`Rate limited. Retrying in ${Math.round(waitTime / 1000)}s...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
throw new Error('Unexpected: loop should not exit');
}Timeout Errors
Timeout errors occur when a request takes too long to complete. These are especially frustrating because they can be intermittent — a request that works fine one minute might time out the next under heavy server load.
Types of Timeout Errors
- ETIMEDOUT:The connection attempt timed out. The server didn't respond to the initial TCP handshake within the expected time.
- ECONNREFUSED:The server actively refused the connection. This usually means the service isn't running on the expected port.
- ECONNRESET: The connection was established but then the server unexpectedly closed it. This often happens with load balancers or proxy timeouts.
- AbortError: Your client-side code explicitly cancelled the request after a deadline.
Fixing Timeout Issues
Use AbortControllerto set explicit timeouts on fetch requests. This gives you control over how long you're willing to wait:
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs = 10000
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// Combined: timeout + retry
async function resilientFetch(
url: string,
options: RequestInit = {},
timeoutMs = 10000,
maxRetries = 2
): Promise<Response> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fetchWithTimeout(url, options, timeoutMs);
} catch (error) {
if (attempt === maxRetries) throw error;
const delay = Math.pow(2, attempt) * 500;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Unreachable');
}For server-side timeouts (ECONNREFUSED, ECONNRESET), check that the service is actually running and healthy. Use a health check endpoint if available, and make sure firewalls or security groups aren't blocking traffic.
Request Body Errors (400 Bad Request / 422 Unprocessable Entity)
A 400 or 422 status means the server understood your request but found something wrong with the data you sent. This is the most common error during active development — you sent the wrong field name, used the wrong type, or missed a required field.
Common Causes
- Missing required fields: The API expects
emailbut you sentemailAddress. - Wrong data types: You sent
"age": "25"(string) instead of"age": 25(number). - Invalid format: A date field expects ISO 8601 format but you sent a human-readable string.
- Missing Content-Type header: You forgot to set
Content-Type: application/jsonso the server couldn't parse the body.
Debugging Workflow
Always read the full response body on 400/422 errors. Most well-designed APIs return detailed validation messages:
async function makeApiRequest(url: string, data: unknown) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
console.error('Status:', response.status);
console.error('Error details:', JSON.stringify(errorBody, null, 2));
// Many APIs return structured validation errors
// Example: { errors: [{ field: "email", message: "is required" }] }
if (errorBody?.errors) {
for (const err of errorBody.errors) {
console.error(`Field "${err.field}": ${err.message}`);
}
}
throw new Error(`API error ${response.status}: ${errorBody?.message || 'Unknown'}`);
}
return response.json();
}
// Validate before sending to catch issues early
function validatePayload(data: Record<string, unknown>, requiredFields: string[]) {
const missing = requiredFields.filter(field => !(field in data));
if (missing.length > 0) {
throw new Error(`Missing required fields: ${missing.join(', ')}`);
}
}A common mistake is not setting the Content-Type header. Without it, the server may interpret your JSON body as plain text and fail to parse it. Always include Content-Type: application/json when sending JSON data.
SSL/TLS Errors
SSL errors happen when the secure connection between your client and the server can't be established. These are especially common when working with internal APIs, staging environments, or services using self-signed certificates.
Common SSL Errors
- UNABLE_TO_VERIFY_LEAF_SIGNATURE:The certificate chain is incomplete. The server isn't sending intermediate certificates.
- CERT_HAS_EXPIRED: The SSL certificate has expired and needs to be renewed.
- SELF_SIGNED_CERT_IN_CHAIN:The server is using a self-signed certificate that your client doesn't trust.
- ERR_TLS_CERT_ALTNAME_INVALID:The certificate doesn't match the domain name you're connecting to.
Proper Fixes
Many developers "fix" SSL errors by disabling certificate verification entirely (like using cURL's -k flag or setting NODE_TLS_REJECT_UNAUTHORIZED=0). This is dangerous because it makes you vulnerable to man-in-the-middle attacks. Here are the proper fixes:
// For self-signed certs in Node.js — provide the CA certificate
import https from 'https';
import fs from 'fs';
const agent = new https.Agent({
ca: fs.readFileSync('/path/to/custom-ca.pem'),
});
const response = await fetch('https://internal-api.company.com/data', {
agent: agent as any,
});
// For expired certificates: renew them!
// Using certbot for Let's Encrypt:
// sudo certbot renew --force-renewal
// For incomplete certificate chains: include intermediate certs
// in your server configuration. Most providers offer a "full chain"
// file that includes both your certificate and intermediate certs.Warning:Never disable SSL verification in production. If you're tempted to set
NODE_TLS_REJECT_UNAUTHORIZED=0, stop and fix the certificate issue properly. Disabling verification exposes your application and your users to serious security risks.
Debugging Checklist
When you encounter any API error, work through this checklist systematically. Most errors can be resolved by the third or fourth step:
- Read the full error response.Don't just check the status code — read the response body. Most APIs include a detailed error message, error code, or link to documentation.
- Reproduce with cURL or Postman. Strip away your application code and make the raw request. If it works in cURL but not in your app, the issue is in your code (likely CORS or header formatting).
- Check the request headers.Open your browser's Network tab or log the outgoing request. Verify
Content-Type,Authorization, and any custom headers are present and correctly formatted. - Validate the request body.Log the exact JSON you're sending. Check for typos in field names, wrong data types, and missing required fields. Compare against the API documentation character by character.
- Check the API documentation for changes. APIs evolve. An endpoint that worked last month might have new required fields, changed authentication, or deprecated parameters.
- Inspect your environment. Are you hitting the right base URL (staging vs. production)? Are your environment variables loaded? Is your API key valid and not expired?
- Check rate limits and quotas. Look at the response headers for rate limit information. Check your API dashboard for usage stats and billing status.
- Test with minimal input. Send the simplest possible valid request. If that works, gradually add complexity until you find which field or parameter causes the error.
- Check the API's status page. The issue might be on their end. Most services publish uptime status at
status.servicename.com. - Search for the exact error message. Copy the full error message and search for it. Stack Overflow, GitHub Issues, and developer forums often have the exact solution.
Putting It All Together
Here's a production-ready fetch wrapper that handles the most common API errors gracefully:
interface FetchOptions extends RequestInit {
timeout?: number;
maxRetries?: number;
}
async function apiFetch<T>(url: string, options: FetchOptions = {}): Promise<T> {
const { timeout = 15000, maxRetries = 3, ...fetchOptions } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
});
if (response.ok) {
return await response.json() as T;
}
// Handle rate limiting with retry
if (response.status === 429 && attempt < maxRetries) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter
? parseInt(retryAfter, 10) * 1000
: Math.pow(2, attempt) * 1000 + Math.random() * 1000;
await new Promise(r => setTimeout(r, delay));
continue;
}
// Handle server errors with retry
if (response.status >= 500 && attempt < maxRetries) {
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
continue;
}
// Client errors — don't retry, throw immediately
const errorBody = await response.json().catch(() => ({}));
throw new Error(
`API Error ${response.status}: ${errorBody.message || response.statusText}`
);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
if (attempt < maxRetries) continue;
throw new Error(`Request timed out after ${timeout}ms`);
}
if (attempt === maxRetries) throw error;
} finally {
clearTimeout(timeoutId);
}
}
throw new Error('Request failed after all retries');
}Conclusion
API errors are inevitable, but they don't have to be time-consuming. The key is systematic debugging: read the full error, reproduce it in isolation, and work through the checklist. Most errors fall into a handful of categories — CORS, authentication, permissions, rate limits, timeouts, and malformed requests — and once you recognize the pattern, the fix is usually straightforward.
Build resilience into your code from the start. Use the retry wrapper pattern shown above, always set explicit timeouts, handle token refresh automatically, and log enough detail to diagnose issues quickly. Your future self will thank you when that 3 AM production alert turns out to be a simple expired token instead of a mystery.
Tools like Pastefix can help you decode error messages instantly — paste any error and get a plain-English explanation of what went wrong and how to fix it. The less time you spend puzzling over cryptic error responses, the more time you can spend building features that matter.
Try it yourself
Use Pastefix's Error Decoder to instantly analyze and understand your own data — no signup required.
Open Error Decoder→