Fetch, Retry e Timeout: O Código que Falta entre Sua API e a Realidade
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%
Leia o artigo completo em https://vivodecodigo.com.br/backend/fetch-retry-timeout-client-http-resiliente-javascript