Tratamento de erros em requisições HTTP

[106] 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 Referências

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

Escopo, Hoisting e Closures
Escopo, Hoisting e Closures

Um laço com var e um setTimeout dentro imprime 4, 4, 4 — e explicar esse…

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…

Introdução ao Node.js: JavaScript fora do navegador
Introdução ao Node.js: JavaScript fora do navegador

Em 2009 o JavaScript saiu da aba do navegador. O Node roda o mesmo motor V8…