Tratamento de erros em requisições HTTP

Tratamento de erros em requisições HTTP

Código que só funciona quando tudo dá certo não está pronto para produção. Aqui os erros são separados em quatro famílias — rede, HTTP, parse e regra de negócio —, cada uma com seu tratamento: classes de erro próprias, cliente com interceptadores, retry com backoff e jitter, e um circuit breaker.
Javascript

21 min de leitura

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 leituras recomendadas

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.

Comentários

Mais em Javascript

Arquitetura de Software: SOLID, Clean Architecture e DDD
Arquitetura de Software: SOLID, Clean Architecture e DDD

Se houver uma frase para levar daqui, é a regra das dependências: elas apontam…

Deploy: do código ao ar
Deploy: do código ao ar

Construir é metade do trabalho; a outra metade é colocar no ar. O artigo…

Mini Projeto: Calculadora no Console
Mini Projeto: Calculadora no Console

Chegamos ao fim do primeiro módulo, e o jeito de fechá-lo é construindo: uma…