Fazer uma requisição funcionar no ambiente de desenvolvimento é fácil. O verdadeiro desafio é fazer sua aplicação se comportar bem quando as coisas dão errado — e elas sempre dão. O servidor fica fora do ar. A internet cai. O token expira. A API retorna dados inesperados. O usuário está com conexão 2G em um trem.
Um código que só funciona quando tudo está certo não está pronto para produção. Este artigo ensina a construir um sistema de requisições robusto que lida graciosamente com qualquer tipo de falha.
Os tipos de erro em requisições HTTP
Antes de tratar, precisamos classificar:
┌─────────────────────────────────────────────────────┐
│ TIPOS DE ERRO EM REQUISIÇÕES │
│ │
│ 1. Erros de REDE │
│ - Sem internet │
│ - DNS não resolvido │
│ - Timeout de conexão │
│ - CORS bloqueado │
│ → Fetch REJEITA a Promise (TypeError) │
│ │
│ 2. Erros HTTP (servidor respondeu, mas com erro) │
│ - 400 Bad Request │
│ - 401 Unauthorized │
│ - 403 Forbidden │
│ - 404 Not Found │
│ - 422 Unprocessable Entity │
│ - 429 Too Many Requests │
│ - 500 Internal Server Error │
│ - 503 Service Unavailable │
│ → Fetch RESOLVE a Promise (response.ok = false) │
│ │
│ 3. Erros de PARSE │
│ - JSON malformado │
│ - Tipo inesperado de resposta │
│ → response.json() REJEITA a Promise │
│ │
│ 4. Erros de NEGÓCIO │
│ - Validação falhou │
│ - Regra de negócio violada │
│ - Recurso em estado inválido │
│ → status 200 mas com campo de erro no corpo │
└─────────────────────────────────────────────────────┘
Erros HTTP — o que cada código significa
const ERROS_HTTP = {
// 4xx — Erros do cliente
400: "Requisição inválida — verifique os dados enviados.",
401: "Não autenticado — faça login para continuar.",
403: "Acesso negado — você não tem permissão para esta ação.",
404: "Recurso não encontrado.",
405: "Método HTTP não permitido.",
408: "Timeout — o servidor demorou para responder.",
409: "Conflito — o recurso já existe ou está em estado incompatível.",
410: "Recurso removido permanentemente.",
422: "Dados inválidos — verifique os campos do formulário.",
429: "Muitas requisições — aguarde antes de tentar novamente.",
// 5xx — Erros do servidor
500: "Erro interno do servidor — tente novamente mais tarde.",
502: "Gateway inválido — serviço intermediário com problema.",
503: "Serviço indisponível — servidor sobrecarregado ou em manutenção.",
504: "Timeout do gateway — o servidor demorou para responder.",
};
function mensagemDeErro(status) {
return ERROS_HTTP[status] || `Erro inesperado (${status}).`;
}
Criando uma classe de erro HTTP personalizada
class ErroHTTP extends Error {
constructor(status, mensagem, dados = null) {
super(mensagem);
this.name = "ErroHTTP";
this.status = status;
this.dados = dados; // corpo da resposta de erro, se houver
}
get eClientError() { return this.status >= 400 && this.status < 500; }
get eServerError() { return this.status >= 500; }
get eNaoAutorizado() { return this.status === 401; }
get eProibido() { return this.status === 403; }
get eNaoEncontrado() { return this.status === 404; }
get eMuitasRequisicoes() { return this.status === 429; }
}
class ErroRede extends Error {
constructor(mensagem = "Falha de rede. Verifique sua conexão.") {
super(mensagem);
this.name = "ErroRede";
}
}
class ErroTimeout extends Error {
constructor(ms) {
super(`A requisição excedeu o tempo limite de ${ms}ms.`);
this.name = "ErroTimeout";
}
}
class ErroParse extends Error {
constructor(mensagem) {
super(mensagem);
this.name = "ErroParse";
}
}
O cliente HTTP robusto
Vamos construir um cliente HTTP completo que centraliza todo o tratamento de erros:
class ClienteHTTP {
constructor(baseURL = "", opcoesPadrao = {}) {
this.baseURL = baseURL;
this.opcoesPadrao = {
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
timeout: 10000, // 10 segundos
...opcoesPadrao,
};
this.interceptadores = {
requisicao: [],
resposta: [],
erro: [],
};
}
// ── Interceptadores ─────────────────────────────
adicionarInterceptadorRequisicao(fn) {
this.interceptadores.requisicao.push(fn);
}
adicionarInterceptadorResposta(fn) {
this.interceptadores.resposta.push(fn);
}
adicionarInterceptadorErro(fn) {
this.interceptadores.erro.push(fn);
}
// ── Requisição principal ─────────────────────────
async requisitar(endpoint, opcoes = {}) {
const url = `${this.baseURL}${endpoint}`;
// Mescla opções
let configuracao = {
...this.opcoesPadrao,
...opcoes,
headers: {
...this.opcoesPadrao.headers,
...opcoes.headers,
},
};
// Aplica interceptadores de requisição
for (const interceptador of this.interceptadores.requisicao) {
configuracao = await interceptador(configuracao);
}
// Timeout com AbortController
const { timeout, ...fetchOpcoes } = configuracao;
const controlador = new AbortController();
const idTimeout = setTimeout(() => controlador.abort(), timeout);
try {
const response = await fetch(url, {
...fetchOpcoes,
signal: controlador.signal,
});
clearTimeout(idTimeout);
// Trata erros HTTP
if (!response.ok) {
let dadosErro = null;
try {
dadosErro = await response.json();
} catch {
// Sem corpo JSON no erro
}
const mensagem = dadosErro?.mensagem
|| dadosErro?.message
|| dadosErro?.error
|| mensagemDeErro(response.status);
throw new ErroHTTP(response.status, mensagem, dadosErro);
}
// Sem conteúdo
if (response.status === 204) return null;
// Parse do corpo
const contentType = response.headers.get("content-type") || "";
let dados;
try {
dados = contentType.includes("application/json")
? await response.json()
: await response.text();
} catch (e) {
throw new ErroParse(`Falha ao parsear resposta: ${e.message}`);
}
// Aplica interceptadores de resposta
let resultado = dados;
for (const interceptador of this.interceptadores.resposta) {
resultado = await interceptador(resultado, response);
}
return resultado;
} catch (erro) {
clearTimeout(idTimeout);
// Transforma erros conhecidos
if (erro.name === "AbortError") {
throw new ErroTimeout(timeout);
}
// Os parênteses são obrigatórios: `!erro instanceof X` é lido como
// `(!erro) instanceof X`, que é sempre false — a condição inteira
// nunca seria verdadeira e nenhum erro de rede seria convertido.
if (erro instanceof TypeError && !(erro instanceof ErroHTTP)) {
throw new ErroRede(erro.message);
}
// Aplica interceptadores de erro
let erroFinal = erro;
for (const interceptador of this.interceptadores.erro) {
erroFinal = await interceptador(erroFinal) || erroFinal;
}
throw erroFinal;
}
}
// ── Métodos HTTP ────────────────────────────────
get(endpoint, opcoes = {}) {
return this.requisitar(endpoint, { ...opcoes, method: "GET" });
}
post(endpoint, corpo, opcoes = {}) {
return this.requisitar(endpoint, {
...opcoes,
method: "POST",
body: JSON.stringify(corpo),
});
}
put(endpoint, corpo, opcoes = {}) {
return this.requisitar(endpoint, {
...opcoes,
method: "PUT",
body: JSON.stringify(corpo),
});
}
patch(endpoint, corpo, opcoes = {}) {
return this.requisitar(endpoint, {
...opcoes,
method: "PATCH",
body: JSON.stringify(corpo),
});
}
delete(endpoint, opcoes = {}) {
return this.requisitar(endpoint, { ...opcoes, method: "DELETE" });
}
}
Configurando o cliente com interceptadores
// Criando a instância do cliente
const api = new ClienteHTTP("https://jsonplaceholder.typicode.com");
// Interceptador de requisição — adiciona token de autenticação
api.adicionarInterceptadorRequisicao((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
console.log(`[API] ${config.method || "GET"} ${config.url || ""}`);
return config;
});
// Interceptador de resposta — logging
api.adicionarInterceptadorResposta((dados, response) => {
console.log(`[API] ✅ ${response.status} — ${response.url}`);
return dados;
});
// Interceptador de erro — tratamento global
api.adicionarInterceptadorErro(async (erro) => {
if (erro instanceof ErroHTTP) {
// Token expirado — redireciona para login
if (erro.eNaoAutorizado) {
localStorage.removeItem("token");
console.warn("[API] Sessão expirada. Redirecionando para login...");
// window.location.href = "/login";
}
// Rate limiting. Atenção: o interceptador de erro apenas OBSERVA —
// ele não repete a requisição. Esperar aqui só atrasaria a mensagem
// de falha em 5 segundos. Quem retenta é o comRetry, mais adiante.
if (erro.eMuitasRequisicoes) {
// O servidor costuma dizer quanto esperar; use isso em vez de chutar.
console.warn(`[API] Rate limit atingido. Retry-After: ${erro.dados?.retryAfter ?? "não informado"}`);
}
console.error(`[API] ❌ Erro ${erro.status}: ${erro.message}`);
}
if (erro instanceof ErroRede) {
console.error("[API] ❌ Sem conexão:", erro.message);
}
if (erro instanceof ErroTimeout) {
console.error("[API] ⏰ Timeout:", erro.message);
}
return erro; // repropaga
});
Retry automático com backoff exponencial
async function comRetry(fn, opcoes = {}) {
const {
tentativas = 3,
delayBase = 1000,
fatorMultiplicador = 2,
errosRetentaveis = [408, 429, 500, 502, 503, 504],
aoTentar = null,
} = opcoes;
let ultimoErro;
for (let tentativa = 1; tentativa <= tentativas; tentativa++) {
try {
return await fn();
} catch (erro) {
ultimoErro = erro;
// Verifica se vale a pena tentar de novo
const deveRetentar =
erro instanceof ErroRede ||
erro instanceof ErroTimeout ||
(erro instanceof ErroHTTP && errosRetentaveis.includes(erro.status));
if (!deveRetentar || tentativa === tentativas) {
throw erro;
}
// Calcula delay com backoff exponencial + jitter
const delay = delayBase * Math.pow(fatorMultiplicador, tentativa - 1);
const jitter = Math.random() * 200; // evita thundering herd
const espera = Math.round(delay + jitter);
if (aoTentar) {
aoTentar(tentativa, tentativas, espera, erro);
} else {
console.warn(`[Retry] Tentativa ${tentativa}/${tentativas} falhou. Aguardando ${espera}ms...`);
}
await new Promise(r => setTimeout(r, espera));
}
}
throw ultimoErro;
}
// Uso
async function buscarComRetry(id) {
return comRetry(
() => api.get(`/users/${id}`),
{
tentativas: 3,
delayBase: 1000,
aoTentar: (atual, total, espera, erro) => {
console.warn(`Tentativa ${atual}/${total} — ${erro.message} — aguardando ${espera}ms`);
},
}
);
}
const usuario = await buscarComRetry(1);
Circuit Breaker — protegendo o sistema
O Circuit Breaker é um padrão que "abre o circuito" quando muitas falhas acontecem, evitando sobrecarregar um serviço que já está com problemas:
class CircuitBreaker {
constructor(opcoes = {}) {
this.limite = opcoes.limite || 5; // falhas para abrir
this.timeout = opcoes.timeout || 60000; // ms até tentar fechar
this.falhas = 0;
this.ultimaFalha = null;
this.estado = "fechado"; // fechado | aberto | semi-aberto
}
async executar(fn) {
if (this.estado === "aberto") {
const tempoPassado = Date.now() - this.ultimaFalha;
if (tempoPassado > this.timeout) {
this.estado = "semi-aberto";
console.log("[CircuitBreaker] Semi-aberto — testando serviço...");
} else {
throw new Error(
`[CircuitBreaker] Circuito aberto. Aguarde ${Math.ceil((this.timeout - tempoPassado) / 1000)}s.`
);
}
}
try {
const resultado = await fn();
// Sucesso — fecha o circuito
if (this.estado === "semi-aberto") {
this.resetar();
console.log("[CircuitBreaker] Serviço recuperado. Circuito fechado.");
}
return resultado;
} catch (erro) {
this.falhas++;
this.ultimaFalha = Date.now();
if (this.falhas >= this.limite) {
this.estado = "aberto";
console.error(`[CircuitBreaker] Circuito ABERTO após ${this.falhas} falhas.`);
}
throw erro;
}
}
resetar() {
this.falhas = 0;
this.ultimaFalha = null;
this.estado = "fechado";
}
get estaAberto() { return this.estado === "aberto"; }
}
// Uso
const breaker = new CircuitBreaker({ limite: 3, timeout: 30000 });
async function buscarComBreaker(id) {
return breaker.executar(() => api.get(`/users/${id}`));
}
Tratamento de erros na interface
Todo erro deve ter um tratamento visual adequado:
// Sistema de feedback de erros para o usuário
const UI = {
mostrarErro(erro, contexto = "") {
let mensagemUsuario;
let acao = null;
if (erro instanceof ErroHTTP) {
switch (erro.status) {
case 401:
mensagemUsuario = "Sua sessão expirou. Faça login novamente.";
acao = { label: "Fazer login", fn: () => window.location.href = "/login" };
break;
case 403:
mensagemUsuario = "Você não tem permissão para esta ação.";
break;
case 404:
mensagemUsuario = `${contexto || "O recurso"} não foi encontrado.`;
break;
case 422:
mensagemUsuario = erro.dados?.erros
? `Dados inválidos:
${erro.dados.erros.join("\n")}`
: "Os dados enviados são inválidos.";
break;
case 429:
mensagemUsuario = "Muitas tentativas. Aguarde alguns segundos.";
break;
case 500:
case 503:
mensagemUsuario = "Problema no servidor. Tente novamente em instantes.";
acao = { label: "Tentar novamente", fn: () => window.location.reload() };
break;
default:
mensagemUsuario = erro.message || "Algo deu errado.";
}
} else if (erro instanceof ErroRede) {
mensagemUsuario = "Sem conexão com a internet. Verifique sua rede.";
} else if (erro instanceof ErroTimeout) {
mensagemUsuario = "A requisição demorou muito. Tente novamente.";
acao = { label: "Tentar novamente", fn: () => window.location.reload() };
} else {
mensagemUsuario = "Erro inesperado. Por favor, tente novamente.";
}
this.exibirToast(mensagemUsuario, "erro", acao);
console.error("[UI Error]", erro); // log técnico para o desenvolvedor
},
exibirToast(mensagem, tipo = "info", acao = null) {
const toast = document.createElement("div");
toast.className = `toast toast-${tipo}`;
// Nada de innerHTML aqui, por dois motivos. A mensagem pode conter
// texto vindo do servidor, e interpolar `acao.fn` num atributo onclick
// não funciona: a função vira o texto do próprio código-fonte, que o
// navegador avalia como expressão e descarta — o botão não faz nada.
const texto = document.createElement("span");
texto.textContent = mensagem;
toast.appendChild(texto);
if (acao) {
const botao = document.createElement("button");
botao.textContent = acao.label;
botao.addEventListener("click", acao.fn); // a função em si, não o texto dela
toast.appendChild(botao);
}
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
},
mostrarLoading(elemento, ativo) {
if (ativo) {
elemento.disabled = true;
elemento.dataset.textoOriginal = elemento.textContent;
elemento.textContent = "Carregando...";
} else {
elemento.disabled = false;
elemento.textContent = elemento.dataset.textoOriginal || "Confirmar";
}
},
};
Exemplo completo — formulário com tratamento robusto
const btnSalvar = document.querySelector("#btn-salvar");
const form = document.querySelector("#form-usuario");
form.addEventListener("submit", async (e) => {
e.preventDefault();
UI.mostrarLoading(btnSalvar, true);
const dados = Object.fromEntries(new FormData(form).entries());
try {
const usuario = await comRetry(
() => api.post("/users", dados),
{ tentativas: 2, delayBase: 800 }
);
UI.exibirToast(`Usuário ${usuario.name} criado com sucesso!`, "sucesso");
form.reset();
} catch (erro) {
UI.mostrarErro(erro, "Usuário");
} finally {
UI.mostrarLoading(btnSalvar, false);
}
});
Checklist de tratamento de erros em produção
✅ Verificar response.ok em toda requisição fetch
✅ Distinguir erros de rede, HTTP e parse
✅ Nunca expor mensagens técnicas ao usuário final
✅ Sempre logar o erro completo no console (ou serviço de monitoramento)
✅ Implementar retry para erros transitórios (500, 503, timeout)
✅ Não fazer retry em erros permanentes (400, 401, 403, 404)
✅ Exibir feedback visual adequado para cada tipo de erro
✅ Manter o estado do formulário em caso de erro (não limpar os campos)
✅ Implementar timeout em todas as requisições
✅ Usar AbortController em buscas ao vivo
✅ Tratar o caso de ausência de internet (offline)
✅ Testar os estados de erro, não só o caminho feliz
Tarefa para você
Estenda o ClienteHTTP com um sistema de cache em memória:
// Adicione ao ClienteHTTP:
// - cache com tempo de expiração configurável
// - método get() verifica cache antes de fazer a requisição
// - método invalidarCache(endpoint) para limpar manualmente
// - opção { cache: false } para forçar requisição fresca
const api = new ClienteHTTP("https://jsonplaceholder.typicode.com", {
cacheTTL: 30000, // 30 segundos
});
// Primeira chamada — vai para a rede
const u1 = await api.get("/users/1");
// Segunda chamada — retorna do cache instantaneamente
const u2 = await api.get("/users/1");
// Forçar requisição nova
const u3 = await api.get("/users/1", { cache: false });
Ver solução — cache com TTL no ClienteHTTP, sem disparar a mesma requisição duas vezes
// Continuação do ClienteHTTP do artigo: o que segue são as adições.
class CacheComTTL {
constructor(ttlPadrao = 30000) {
this.ttlPadrao = ttlPadrao;
this.entradas = new Map();
}
// A chave inclui o método e o corpo: GET /users/1 e POST /users/1
// não são a mesma coisa, e dois POSTs com corpos diferentes também
// não. Cachear só pela URL é como servir a resposta errada de
// propósito.
static chave(endpoint, configuracao = {}) {
const metodo = (configuracao.method ?? "GET").toUpperCase();
const corpo = configuracao.body ? `:${configuracao.body}` : "";
return `${metodo} ${endpoint}${corpo}`;
}
obter(chave) {
const entrada = this.entradas.get(chave);
if (!entrada) return undefined;
if (Date.now() > entrada.expiraEm) {
// Expirou: remove agora em vez de deixar acumular. Sem isso o
// Map cresce para sempre — cache sem descarte é vazamento.
this.entradas.delete(chave);
return undefined;
}
entrada.acertos++;
return entrada.valor;
}
guardar(chave, valor, ttl = this.ttlPadrao) {
this.entradas.set(chave, {
valor,
expiraEm: Date.now() + ttl,
acertos: 0,
});
}
invalidar(endpoint) {
if (!endpoint) {
this.entradas.clear();
return;
}
// Invalida todos os métodos daquele endpoint de uma vez.
for (const chave of this.entradas.keys()) {
if (chave.includes(endpoint)) this.entradas.delete(chave);
}
}
get tamanho() {
return this.entradas.size;
}
}
class ClienteHTTPComCache extends ClienteHTTP {
constructor(baseURL = "", opcoes = {}) {
super(baseURL, opcoes);
this.cache = new CacheComTTL(opcoes.cacheTTL ?? 30000);
// Requisições em voo, para o problema do "cache stampede": ver
// adiante.
this.emVoo = new Map();
}
async get(endpoint, opcoes = {}) {
const { cache: usarCache = true, cacheTTL, ...resto } = opcoes;
const configuracao = { ...resto, method: "GET" };
const chave = CacheComTTL.chave(endpoint, configuracao);
if (usarCache) {
const guardado = this.cache.obter(chave);
if (guardado !== undefined) return guardado;
// Se a MESMA requisição já está em andamento, espera a que
// existe em vez de abrir outra. Sem isto, dez componentes que
// pedem /users/1 ao mesmo tempo no primeiro carregamento fazem
// dez requisições — o cache só ajudaria a partir da décima
// primeira.
if (this.emVoo.has(chave)) return this.emVoo.get(chave);
}
const promessa = this.requisitar(endpoint, configuracao)
.then((dados) => {
if (usarCache) this.cache.guardar(chave, dados, cacheTTL);
return dados;
})
.finally(() => {
this.emVoo.delete(chave);
});
if (usarCache) this.emVoo.set(chave, promessa);
return promessa;
}
// Escrita invalida a leitura correspondente: sem isso o POST cria o
// recurso e o GET seguinte continua devolvendo a lista antiga.
async post(endpoint, corpo, opcoes = {}) {
const resposta = await this.requisitar(endpoint, {
...opcoes,
method: "POST",
body: JSON.stringify(corpo),
});
this.invalidarCache(endpoint);
return resposta;
}
async put(endpoint, corpo, opcoes = {}) {
const resposta = await this.requisitar(endpoint, {
...opcoes,
method: "PUT",
body: JSON.stringify(corpo),
});
this.invalidarCache(endpoint);
return resposta;
}
async delete(endpoint, opcoes = {}) {
const resposta = await this.requisitar(endpoint, { ...opcoes, method: "DELETE" });
this.invalidarCache(endpoint);
return resposta;
}
invalidarCache(endpoint) {
this.cache.invalidar(endpoint);
}
}
// ---------------------------------------------------------------
// Usando
// ---------------------------------------------------------------
const api = new ClienteHTTPComCache("https://jsonplaceholder.typicode.com", {
cacheTTL: 30000,
});
const u1 = await api.get("/users/1"); // rede
const u2 = await api.get("/users/1"); // cache, instantâneo
const u3 = await api.get("/users/1", { cache: false }); // rede de novo
console.log(u1.name === u2.name); // true
// Dez pedidos simultâneos, uma requisição só:
api.invalidarCache("/users/2");
const dez = await Promise.all(
Array.from({ length: 10 }, () => api.get("/users/2"))
);
console.log(`${dez.length} respostas · cache com ${api.cache.tamanho} entrada(s)`);
// TTL curto para ver a expiração acontecer:
await api.get("/users/3", { cacheTTL: 50 });
await new Promise((r) => setTimeout(r, 80));
console.log(api.cache.obter(CacheComTTL.chave("/users/3", { method: "GET" })));
// undefined — expirou e foi removida
// ---------------------------------------------------------------
// O detalhe que morde: cachear o erro
// ---------------------------------------------------------------
// O `.then` acima só guarda em caso de sucesso — de propósito. Se o
// cache fosse preenchido no `finally`, um 500 momentâneo ficaria
// grudado por 30 segundos e a aplicação continuaria quebrada mesmo
// depois de o servidor voltar.
//
// O contrário também vale: se você QUISER cachear 404 (para não
// martelar a API perguntando por algo que não existe), faça isso
// explicitamente e com TTL bem menor que o do sucesso.
Cache sem descarte é vazamento de memória, e cache sem deduplicação não ajuda no pior momento — o primeiro carregamento, quando dez componentes pedem a mesma coisa ao mesmo tempo e o cache ainda está vazio. Guardar a promessa em voo, e não só o resultado, resolve os dois casos.
Rede, HTTP, parse e regra de negócio são falhas de natureza diferente e pedem respostas diferentes: queda de conexão e 5xx merecem nova tentativa, 4xx não — repetir um CPF inválido vai falhar exatamente igual —, e erro de negócio quase sempre é mensagem de tela, não linha de log. Tratar as quatro como "deu erro" produz aquele aplicativo que insiste no que nunca vai funcionar e desiste do que daria certo na segunda tentativa.
Fontes e Referências
- MDN Web Docs — HTTP Status Codes: https://developer.mozilla.org/pt-BR/docs/Web/HTTP/Status
- MDN Web Docs — AbortController: https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- JavaScript.info — Fetch: Error Handling: https://javascript.info/fetch-api
- Martin Fowler — Circuit Breaker Pattern: https://martinfowler.com/bliki/CircuitBreaker.html
- Google — Exponential Backoff: https://cloud.google.com/storage/docs/retry-strategy
- Node.js Design Patterns — Mario Casciaro (Packt Publishing)
Exercícios
Exercício 1
Este era o teste do cliente HTTP antes da correção. Um fetch falha por falta de internet. O erro vira ErroRede?
if (erro instanceof TypeError && !erro instanceof ErroHTTP) {
throw new ErroRede(erro.message);
}
Ver resposta
✓ Resposta: Não vira — e nenhum erro jamais viraria, porque a condição é sempre falsa. O ! é unário e tem precedência maior que a do instanceof, então !erro instanceof ErroHTTP é lido como (!erro) instanceof ErroHTTP. O !erro é avaliado primeiro e produz um booleano — false, já que um objeto de erro é truthy — e false instanceof QualquerCoisa é sempre false, porque valores primitivos nunca são instância de nada. O && encerra ali e o bloco nunca executa. O resultado prático é que o TypeError: Failed to fetch escapa cru para quem chamou, e a interface mostra ao usuário uma mensagem técnica em inglês em vez de "verifique sua conexão". A correção é parentizar: !(erro instanceof ErroHTTP). Vale notar que o bug é silencioso em ambos os sentidos — não há erro de sintaxe, não há aviso, e em desenvolvimento, onde a rede nunca cai, o caminho simplesmente não é exercitado.
Exercício 2
Classifique: em quais destes casos o await fetch(...) rejeita, e em quais ele resolve normalmente?
// A — o servidor devolve 500 Internal Server Error
// B — o Wi-Fi caiu no meio da requisição
// C — o servidor devolve 200 com corpo `{"erro":"saldo insuficiente"}`
// D — o servidor devolve 404 com uma página HTML
// E — o servidor não enviou os cabeçalhos de CORS
Ver resposta
✓ Resposta: Rejeitam apenas B e E, os dois casos em que a comunicação não se completou — e os dois com o mesmo e vago TypeError: Failed to fetch. A e D resolvem com response.ok === false: houve resposta, ela só não foi de sucesso. C resolve com ok === true, e é a categoria mais fácil de esquecer — o erro de negócio, que vem com status 200 porque, do ponto de vista do protocolo, tudo correu bem; quem precisa detectá-lo é o seu código, olhando o corpo. Essas três categorias pedem tratamentos diferentes: rede e 5xx são transitórios e merecem nova tentativa; 4xx são permanentes e repetir só desperdiça requisição; erro de negócio quase nunca é para o log, e sim para a tela, com a mensagem que o servidor mandou. Um cliente HTTP que trata os três como "deu erro" acaba tentando de novo um cadastro com CPF inválido.
Exercício 3
O comRetry do artigo está configurado com tentativas: 4 e delayBase: 500. A operação falha sempre com 503. Quanto tempo de espera se acumula, e por que existe o jitter?
const delay = delayBase * Math.pow(fatorMultiplicador, tentativa - 1); // fator 2
const jitter = Math.random() * 200;
const espera = Math.round(delay + jitter);
Ver resposta
✓ Resposta: Acumula cerca de 3,5 segundos: 500 ms após a primeira falha, 1000 após a segunda e 2000 após a terceira — a quarta é a última e não espera, porque não haverá nova tentativa. Mais o jitter, que soma até 200 ms aleatórios a cada espera. O crescimento exponencial existe porque um serviço que acabou de falhar precisa de folga: bater de novo em 500 ms atrapalha justamente a recuperação. E o jitter resolve um problema que só aparece em escala — se mil clientes falharem no mesmo instante, porque o servidor caiu, todos recuam pelo mesmo intervalo exato e voltam juntos, formando uma onda que derruba o serviço de novo assim que ele levanta. É o efeito conhecido como thundering herd, e alguns milissegundos de aleatoriedade bastam para espalhar as tentativas. Repare ainda na lista errosRetentaveis: ela inclui 408, 429 e os 5xx, e deixa de fora 400, 401, 403 e 404 — repetir um pedido malformado ou não autorizado vai falhar exatamente igual.
Exercício 4
Este era o toast do artigo. O botão "Fazer login" aparece na tela, mas não faz nada ao ser clicado. Por quê?
const acao = { label: "Fazer login", fn: () => window.location.href = "/login" };
toast.innerHTML = `
<span>${mensagem}</span>
<button onclick="${acao.fn}">${acao.label}</button>
`;
Ver resposta
✓ Resposta: Porque interpolar uma função dentro de uma string a converte no texto do próprio código-fonte. O atributo gerado fica onclick="() => window.location.href = '/login'", e o navegador, ao clicar, avalia esse conteúdo como uma expressão: ele cria a função e descarta o resultado, sem nunca chamá-la. Faltaria um par de parênteses no fim para invocá-la, e ainda assim seria frágil — basta a função conter aspas duplas para o atributo terminar antes da hora e o HTML se despedaçar. Há um segundo problema, mais grave, no ${mensagem}: essa mensagem pode vir do corpo de erro devolvido pelo servidor, e portanto é conteúdo externo entrando por innerHTML. A forma correta resolve tudo de uma vez: criar o botão com createElement, escrever o rótulo com textContent e ligar o comportamento com addEventListener("click", acao.fn), passando a função, não uma representação dela em texto.
Exercício 5
O interceptador de erro espera 5 segundos ao receber um 429 e depois devolve o erro. O usuário chega a ver a requisição ser repetida?
api.adicionarInterceptadorErro(async (erro) => {
if (erro.eMuitasRequisicoes) {
console.warn("[API] Rate limit atingido. Aguardando 5s...");
await new Promise(r => setTimeout(r, 5000));
}
return erro;
});
Ver resposta
✓ Resposta: Não. A requisição nunca é repetida — o interceptador apenas observa o erro e o devolve para ser propagado. Tudo o que os 5 segundos conseguem é atrasar em 5 segundos a mensagem de falha que o usuário vai receber de qualquer jeito, o que é pior do que não esperar. Para de fato retentar, o interceptador precisaria ter acesso à configuração original e reexecutar a requisição, devolvendo a nova promise em vez do erro — responsabilidade que neste desenho pertence ao comRetry, que envolve a chamada por fora. É um caso instrutivo de comentário que descreve a intenção enquanto o código faz outra coisa, e do tipo que passa em revisão porque a leitura em diagonal confirma o que o comentário promete. Vale acrescentar o que faltava mesmo: numa resposta 429 o servidor costuma mandar o cabeçalho Retry-After dizendo quantos segundos esperar — quando ele existe, chutar cinco segundos é ignorar a única informação confiável disponível.