Fetch, Retry e Timeout: O Código que Falta entre Sua API e a Realidade

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
Resumo rápido
Este post mostra como construir, do zero, um client HTTP resiliente em JavaScript/TypeScript usando a Fetch API nativa. Sem axios, sem got, sem ky. Cubro retry com backoff exponencial, timeouts com AbortController, interceptors de request/response, tratamento de erros por status code e tipagem forte com generics. Cada bloco de código é funcional e copy-paste ready.
O bug de 4 horas que me fez reescrever tudo
Em março de 2023, um serviço Node.js que eu mantinha para um cliente de e-commerce começou a retornar erros 503 intermitentes. O serviço consumia uma API de cálculo de frete de terceiro. O código era esse:
// ❌ O código que estava em produção
const response = await fetch('https://frete-api.exemplo.com/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json();Sem timeout. Sem retry. Sem checagem de response.ok. O fetch da Fetch API não rejeita a Promise em respostas 4xx/5xx. Ele resolve normalmente. Então o código seguia feliz com um body de erro parseado como se fosse o cálculo de frete. O resultado: pedidos sendo finalizados com frete zero. Quatro horas de vendas com frete grátis involuntário. O prejuízo foi de R$ 23.400.
A partir desse incidente, eu parei de tratar consumo de API como detalhe de implementação e comecei a tratar como infraestrutura crítica.
Por que o fetch nativo é suficiente (e por que axios não é mais necessário)
O axios resolveu problemas reais em 2016: interceptors, transformação automática de JSON, cancelamento de requests e um wrapper para XMLHttpRequest no browser. Em 2025, a Fetch API cobre quase tudo isso nativamente:
| Funcionalidade | axios | Fetch API nativa | Desde quando |
|---|---|---|---|
| Promises | ✅ | ✅ | Sempre |
| Rejeição em 4xx/5xx | ✅ automático | ❌ manual | — |
| Cancelamento | CancelToken (deprecated) / AbortController | AbortController | Node 18+ / Browsers 2019+ |
| Streaming de response | Parcial | ✅ ReadableStream | Node 18+ |
| Interceptors | ✅ built-in | ❌ manual | — |
| Timeout nativo | ✅ config.timeout | ❌ via AbortSignal.timeout() | Node 18.11+ |
| Bundle size | ~13kB min+gzip | 0kB (nativo) | — |
O que falta no fetch é conveniência, não capacidade. E conveniência a gente constrói em 150 linhas de código, sob nosso controle total.
Eu removi axios de 3 projetos em produção entre 2023 e 2024. Em um deles, um dashboard Next.js com 47 chamadas de API distintas, o bundle do client caiu de 312kB para 299kB (gzipped). Treze kilobytes parecem pouco até você lembrar que cada kilobyte conta no LCP de dispositivos móveis com 3G. O tempo de parse do JavaScript no Chrome caiu de 1.8s para 1.6s em um Moto G22 real.
A fundação: fetch que realmente verifica erros
O primeiro passo é criar uma função que trate response.ok de verdade. Eu chamo essa camada de safeFetch:
// src/http/safe-fetch.ts
export class HttpError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: unknown,
public readonly url: string,
) {
super(`HTTP ${status} ${statusText} at ${url}`);
this.name = 'HttpError';
}
}
export async function safeFetch<T>(
url: string,
init?: RequestInit,
): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
// Tenta parsear o body de erro para dar contexto ao chamador.
// APIs bem feitas retornam JSON com detalhes mesmo em 4xx/5xx.
let errorBody: unknown;
try {
errorBody = await response.json();
} catch {
errorBody = await response.text().catch(() => null);
}
throw new HttpError(
response.status,
response.statusText,
errorBody,
url,
);
}
// Respostas 204 No Content não têm body
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}Essa função já resolve 80% dos problemas que vejo em code reviews. O HttpError carrega status, body e URL, o que facilita logging estruturado e decisões de retry baseadas no status code.
Timeout com AbortController
O fetch sem timeout é uma bomba-relógio. Se o servidor do outro lado travar, sua request fica pendurada até o TCP timeout do sistema operacional, que no Linux é 120 segundos por padrão. Em um servidor Node.js com libuv gerenciando o event loop, uma request pendurada não bloqueia outras, mas consome um slot de conexão e memória do socket.
// src/http/with-timeout.ts
export function fetchWithTimeout(
url: string,
init: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
const { timeoutMs = 5000, signal: externalSignal, ...rest } = init;
// AbortSignal.timeout() cria um signal que aborta automaticamente
// após N milissegundos. Disponível em Node 18.11+ e browsers modernos.
const timeoutSignal = AbortSignal.timeout(timeoutMs);
// Se o chamador já passou um signal (ex: para cancelamento manual),
// combinamos os dois com AbortSignal.any(). O primeiro a disparar vence.
const combinedSignal = externalSignal
? AbortSignal.any([timeoutSignal, externalSignal as AbortSignal])
: timeoutSignal;
return fetch(url, { ...rest, signal: combinedSignal });
}AbortSignal.any() está disponível desde Node 20.3 e em todos os browsers evergreen desde 2024. Se você precisa suportar Node 18, a alternativa é criar um AbortController manualmente e ligar um setTimeout:
// src/http/with-timeout-legacy.ts
// Para Node 18.x onde AbortSignal.any() não existe
export function fetchWithTimeoutLegacy(
url: string,
init: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
const { timeoutMs = 5000, ...rest } = init;
const controller = new AbortController();
// setTimeout retorna um Timeout no Node (não number como no browser).
// Guardamos a referência para limpar depois e evitar leak.
const timer = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...rest, signal: controller.signal }).finally(() => {
clearTimeout(timer);
});
}Retry com backoff exponencial
Nem todo erro merece retry. Um 400 Bad Request vai retornar 400 nas próximas 10 tentativas. Um 429 Too Many Requests ou 503 Service Unavailable, por outro lado, são transitórios e candidatos ideais para retry.
// src/http/retry.ts
import { HttpError, safeFetch } from './safe-fetch';
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
retryableStatuses: Set<number>;
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 300,
maxDelayMs: 10_000,
// 429: rate limited. 502/503/504: problemas de infraestrutura upstream.
retryableStatuses: new Set([429, 502, 503, 504]),
};
function calculateDelay(attempt: number, config: RetryConfig): number {
// Exponential backoff: 300ms, 600ms, 1200ms, 2400ms...
// O jitter evita que N clientes sincronizem retries no mesmo instante
// (thundering herd). Sem jitter, um 503 que afeta 1000 clientes
// gera 1000 retries simultâneos exatamente 300ms depois.
const exponential = config.baseDelayMs * Math.pow(2, attempt);
const capped = Math.min(exponential, config.maxDelayMs);
const jitter = capped * (0.5 + Math.random() * 0.5);
return Math.floor(jitter);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function fetchWithRetry<T>(
url: string,
init?: RequestInit & { timeoutMs?: number },
config: Partial<RetryConfig> = {},
): Promise<T> {
const mergedConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
let lastError: Error | undefined;
for (let attempt = 0; attempt <= mergedConfig.maxRetries; attempt++) {
try {
return await safeFetch<T>(url, init);
} catch (error) {
lastError = error as Error;
const isRetryable =
error instanceof HttpError &&
mergedConfig.retryableStatuses.has(error.status);
// Erros de rede (DNS, conexão recusada) também são retryable.
// O fetch lança TypeError para falhas de rede, não HttpError.
const isNetworkError =
error instanceof TypeError && error.message.includes('fetch');
if ((!isRetryable && !isNetworkError) || attempt === mergedConfig.maxRetries) {
throw error;
}
const delay = calculateDelay(attempt, mergedConfig);
await sleep(delay);
}
}
throw lastError;
}O jitter é o detalhe que separa um retry amador de um retry de produção. Eu aprendi isso da pior forma em 2020, quando um serviço de notificações que eu mantinha fazia retry sem jitter contra uma API de push notification. Quando a API voltou de um downtime de 45 segundos, 12.000 retries dispararam simultaneamente no mesmo milissegundo. O serviço upstream caiu de novo. Com jitter, os retries se espalham numa janela temporal, e o upstream absorve a carga gradualmente.
O client completo: compondo as peças
Agora juntamos timeout, retry e interceptors num client coeso:
// src/http/api-client.ts
import { fetchWithTimeout } from './with-timeout';
import { HttpError } from './safe-fetch';
type Interceptor = (url: string, init: RequestInit) => RequestInit | Promise<RequestInit>;
interface ApiClientConfig {
baseUrl: string;
timeoutMs: number;
maxRetries: number;
defaultHeaders: Record<string, string>;
interceptors: Interceptor[];
retryableStatuses: Set<number>;
}
export function createApiClient(config: Partial<ApiClientConfig> = {}) {
const {
baseUrl = '',
timeoutMs = 5000,
maxRetries = 3,
defaultHeaders = { 'Content-Type': 'application/json' },
interceptors = [],
retryableStatuses = new Set([429, 502, 503, 504]),
} = config;
async function request<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const url = `${baseUrl}${path}`;
let finalInit: RequestInit = {
...init,
headers: { ...defaultHeaders, ...init.headers },
};
// Interceptors rodam em sequência. Cada um pode modificar headers,
// injetar tokens, adicionar tracing headers, etc.
for (const interceptor of interceptors) {
finalInit = await interceptor(url, finalInit);
}
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetchWithTimeout(url, {
...finalInit,
timeoutMs,
});
if (!response.ok) {
let errorBody: unknown;
try {
errorBody = await response.json();
} catch {
errorBody = null;
}
throw new HttpError(response.status, response.statusText, errorBody, url);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
} catch (error) {
lastError = error as Error;
const isRetryable =
error instanceof HttpError && retryableStatuses.has(error.status);
const isNetworkError =
error instanceof TypeError && error.message.includes('fetch');
const isTimeout =
error instanceof DOMException && error.name === 'TimeoutError';
if (
(!isRetryable && !isNetworkError && !isTimeout) ||
attempt === maxRetries
) {
throw error;
}
const delay = Math.min(300 * Math.pow(2, attempt), 10_000);
const jitter = delay * (0.5 + Math.random() * 0.5);
await new Promise((r) => setTimeout(r, jitter));
}
}
throw lastError;
}
return {
get: <T>(path: string, init?: RequestInit) =>
request<T>(path, { ...init, method: 'GET' }),
post: <T>(path: string, body: unknown, init?: RequestInit) =>
request<T>(path, { ...init, method: 'POST', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown, init?: RequestInit) =>
request<T>(path, { ...init, method: 'PUT', body: JSON.stringify(body) }),
delete: <T>(path: string, init?: RequestInit) =>
request<T>(path, { ...init, method: 'DELETE' }),
};
}Uso real:
// src/services/freight.ts
import { createApiClient } from '../http/api-client';
interface FreightQuote {
carrier: string;
price: number;
deliveryDays: number;
}
const freightApi = createApiClient({
baseUrl: 'https://frete-api.exemplo.com',
timeoutMs: 3000,
maxRetries: 2,
interceptors: [
// Injeta o token de autenticação em toda request
async (_url, init) => ({
...init,
headers: {
...init.headers,
Authorization: `Bearer ${process.env.FREIGHT_API_TOKEN}`,
},
}),
],
});
export async function getFreightQuote(
zipFrom: string,
zipTo: string,
weightKg: number,
): Promise<FreightQuote[]> {
return freightApi.post<FreightQuote[]>('/v2/calculate', {
origin: zipFrom,
destination: zipTo,
weight: weightKg,
});
}Depois de substituir o fetch nu pelo client resiliente naquele projeto de e-commerce, os erros de frete zero caíram de ~120 ocorrências/semana para zero. Os 503 transitórios passaram a ser absorvidos pelo retry, e os timeouts de 3 segundos evitaram que o checkout ficasse travado esperando uma resposta que nunca viria.
O que NÃO fazer
Anti-pattern 1: retry em POST sem idempotência
// ❌ PERIGOSO: retry em POST que cria recursos
await fetchWithRetry<Order>('/api/orders', {
method: 'POST',
body: JSON.stringify({ items, total }),
});
// Se o primeiro request criou o pedido mas a resposta deu timeout,
// o retry cria um SEGUNDO pedido. Cobrança duplicada.A correção é usar uma chave de idempotência:
// ✅ Idempotency key impede duplicação no servidor
import { randomUUID } from 'node:crypto';
const idempotencyKey = randomUUID();
await fetchWithRetry<Order>('/api/orders', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({ items, total }),
});
// O servidor reconhece a mesma key e retorna a resposta original
// sem criar um novo pedido.Isso exige que a API do outro lado suporte idempotency keys. Stripe, PayPal e a maioria das APIs de pagamento implementam isso. Se você controla a API, implemente. Se não controla, não faça retry em POST que muta estado. Eu detalho mais sobre esse tipo de decisão arquitetural em Design Patterns que Todo Dev Senior Deveria Dominar em TypeScript.
Anti-pattern 2: swallow silencioso de erros
// ❌ Engole o erro e retorna fallback sem logar nada
async function getUserProfile(id: string) {
try {
return await api.get(`/users/${id}`);
} catch {
return { name: 'Unknown', email: '' };
}
}Esse código esconde problemas de infraestrutura por semanas. O correto é logar o erro e decidir explicitamente se o fallback é aceitável para o caso de uso:
// ✅ Loga, distingue erros transitórios de permanentes, e retorna fallback
// apenas quando o contexto permite (ex: exibição, não transação financeira)
async function getUserProfile(id: string): Promise<UserProfile | null> {
try {
return await api.get<UserProfile>(`/users/${id}`);
} catch (error) {
if (error instanceof HttpError && error.status === 404) {
// 404 é esperado: usuário não existe. Não é um erro de infra.
return null;
}
// Qualquer outro erro é inesperado e precisa de visibilidade.
logger.error('Failed to fetch user profile', {
userId: id,
error: error instanceof HttpError ? error.status : String(error),
});
throw error;
}
}Para estratégias mais avançadas de controle de fluxo em APIs, vale conferir Rate Limiting Inteligente com Redis e Node.js e Lacunas Ocultas do JavaScript Avançado que Travam sua Evolução.
Quando esse client NÃO é suficiente
Esse client funciona bem para a maioria dos cenários de consumo de API em aplicações web e serviços Node.js. Ele não substitui:
- Circuit breaker: se a API upstream está fora por minutos, não por milissegundos, você precisa de um circuit breaker (ex:
opossum) que pare de fazer requests por um período. O retry com 3 tentativas não resolve downtime prolongado. - Connection pooling: o fetch nativo do Node.js (undici por baixo desde o Node 18) já faz pooling de conexões HTTP/1.1. Para HTTP/2, o multiplexing cuida disso. Mas se você está fazendo mais de 50k req/s, vale configurar o
dispatcherdo undici diretamente. - gRPC ou WebSockets: para comunicação bidirecional ou streaming de alta frequência, fetch não é a ferramenta certa. Veja WebSockets vs Server-Sent Events: quando usar cada um.
Para quem está montando a infraestrutura ao redor dessas APIs, DevOps para Devs: Docker, CI/CD, Kubernetes e AWS em Produção cobre o lado operacional, e Engenharia Backend com Node.js e TypeScript: o Guia Definitivo para 2026 complementa a visão de arquitetura.
Minha posição: pare de instalar HTTP clients
Eu mantenho esse tipo de client em 4 projetos de produção hoje. O código total, incluindo testes, fica abaixo de 200 linhas. A superfície de ataque de supply chain é zero, porque não há dependência externa. O comportamento é 100% auditável.
Axios foi essencial durante anos. Hoje, com fetch nativo em Node.js 18+, AbortSignal.timeout(), AbortSignal.any() e undici como engine HTTP por baixo, a justificativa para adicionar 13kB de dependência ao bundle é fraca. Se você está num projeto legado com Node 16, axios ainda faz sentido. Para qualquer projeto novo em Node 18+, construa o seu client. Você vai entender cada linha, debugar mais rápido e não depender do roadmap de manutenção de um pacote open source que já teve vulnerabilidades de SSRF reportadas.
O investimento de meio dia para construir e testar esse client se paga na primeira vez que um timeout em produção é tratado corretamente em vez de derrubar a experiência do usuário. No meu caso, se pagou em R$ 23.400.
FAQ
O fetch nativo do Node.js usa o mesmo código dos browsers?
Não. O Node.js usa o undici, um client HTTP/1.1 escrito em JavaScript puro que roda sobre o runtime. Browsers usam suas próprias implementações (Chromium usa o net stack em C++, Firefox usa necko). A API é a mesma (spec WHATWG Fetch), mas a implementação de rede por baixo é completamente diferente.
Devo usar AbortSignal.timeout() ou setTimeout + AbortController?
Use AbortSignal.timeout() se seu runtime suporta (Node 18.11+, browsers modernos). É mais limpo e não tem risco de memory leak por esquecer de limpar o timer. O setTimeout manual é fallback para ambientes mais antigos.
Como testo retry e timeout sem depender de APIs externas?
Use o msw (Mock Service Worker) para interceptar requests no nível de rede. Ele funciona tanto em testes Node.js (via interceptor de undici/http) quanto no browser. Configure handlers que retornam 503 nas primeiras N chamadas e 200 depois para validar o comportamento de retry.
Esse client funciona em Deno e Bun?
A Fetch API é nativa em ambos. AbortSignal.timeout() e AbortSignal.any() também estão disponíveis. O código funciona sem modificação. Para quem está explorando Deno, Deno + Deno KV + Deno Deploy mostra como usar fetch nativo no contexto serverless.
Preciso de interceptors se uso middleware no servidor? Interceptors no client HTTP são para requests de saída (outgoing). Middleware no servidor (Express, Fastify, Hono) é para requests de entrada (incoming). São camadas diferentes. Um interceptor típico injeta headers de autenticação ou tracing. Um middleware valida tokens, faz rate limiting, etc. Não se substituem.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando

API Gateway Patterns: Autenticação, Rate Limiting e Caching com Node.js

WebSockets na Prática: Sistema de Notificações em Tempo Real com Node.js e React

API Layers em Aplicações Fullstack: O Código que Falta entre Fetch e Produção
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.