Fetch API: consumindo dados da internet

[102] Fetch API: consumindo dados da internet

Aqui os dados deixam de ser simulados com setTimeout e passam a vir da internet. O artigo cobre o GET com fetch, o objeto Response, os verbos POST, PUT, PATCH e DELETE, o cancelamento com AbortController — e a pegadinha central: um 404 não faz o fetch rejeitar, então response.ok precisa ser checado sempre.
Javascript

24 min de leitura

Até agora simulamos requisições com setTimeout. A partir deste artigo, vamos buscar dados reais da internet. A Fetch API é a forma moderna e nativa do JavaScript para fazer requisições HTTP — substituindo o antigo XMLHttpRequest com uma interface muito mais limpa baseada em Promises.

Com Fetch você vai conseguir consumir APIs públicas, enviar dados para servidores, fazer login, carregar imagens, e muito mais. É uma das habilidades mais importantes do desenvolvimento web moderno.

O básico — uma requisição GET

// fetch() retorna uma Promise
fetch("https://jsonplaceholder.typicode.com/users/1")
  .then(response => response.json()) // converte a resposta para JSON
  .then(usuario => console.log(usuario))
  .catch(erro => console.error("Erro:", erro));

// Com async/await — muito mais limpo
async function buscarUsuario() {
  const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
  const usuario = await response.json();
  console.log(usuario);
}

buscarUsuario();

O fetch retorna uma Promise com um objeto Response. Esse objeto não é diretamente os dados — você precisa chamar .json() para converter o corpo da resposta.

O objeto Response

O Response tem várias propriedades e métodos importantes:

async function inspecionarResponse() {
  const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");

  // Status HTTP
  console.log(response.status);     // 200
  console.log(response.statusText); // "OK"
  console.log(response.ok);         // true (200-299), false para erros

  // Headers
  console.log(response.headers.get("content-type")); // "application/json; charset=utf-8"

  // URL final (após redirects)
  console.log(response.url);

  // Métodos para ler o corpo — só pode chamar UM por resposta
  const json = await response.json();     // parse JSON
  // ou
  const texto = await response.text();   // texto puro
  // ou
  const blob = await response.blob();    // arquivo binário (imagens, PDFs)
  // ou
  const buffer = await response.arrayBuffer(); // dados binários brutos
}

O erro mais comum com Fetch

Fetch não rejeita a Promise em erros HTTP (404, 500, etc.). Ele só rejeita em falhas de rede. Você precisa verificar response.ok manualmente:

async function buscarComVerificacao(url) {
  const response = await fetch(url);

  // ❌ Sem verificação, um 404 passa em silêncio e o erro só aparece
  //    disfarçado no parse, longe da causa:
  //    const dados = await response.json();
  //    (e o corpo só pode ser lido UMA vez — a segunda lança
  //     "body stream already read")

  // ✅ Com verificação correta
  if (!response.ok) {
    throw new Error(`Erro HTTP: ${response.status} — ${response.statusText}`);
  }

  return await response.json();
}

// Testando
async function main() {
  try {
    // Rota que não existe — retorna 404
    const dados = await buscarComVerificacao(
      "https://jsonplaceholder.typicode.com/users/99999"
    );
    console.log(dados);
  } catch (erro) {
    console.error(erro.message); // Erro HTTP: 404 — Not Found
  }
}

Criando um wrapper robusto para Fetch

Uma função auxiliar que você vai querer ter em todos os seus projetos:

async function requisitar(url, opcoes = {}) {
  try {
    const response = await fetch(url, opcoes);

    // Verifica erros HTTP
    if (!response.ok) {
      const erro = new Error(`Erro ${response.status}: ${response.statusText}`);
      erro.status = response.status;
      erro.url = url;
      throw erro;
    }

    // Se não há conteúdo (204 No Content), retorna null
    if (response.status === 204) return null;

    // Detecta o tipo e faz o parse correto
    const contentType = response.headers.get("content-type") || "";
    if (contentType.includes("application/json")) {
      return await response.json();
    }

    return await response.text();

  } catch (erro) {
    // Falha de rede (sem internet, CORS, etc.)
    if (erro.name === "TypeError") {
      throw new Error("Falha de rede. Verifique sua conexão.");
    }
    throw erro;
  }
}

// Uso limpo
const usuario = await requisitar("https://jsonplaceholder.typicode.com/users/1");
console.log(usuario.name);

Fazendo requisições POST, PUT, DELETE

O segundo argumento do fetch é um objeto de opções que configura o método, headers e corpo:

const BASE_URL = "https://jsonplaceholder.typicode.com";

// POST — criar um recurso
async function criarPost(dados) {
  const response = await fetch(`${BASE_URL}/posts`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(dados),
  });

  if (!response.ok) throw new Error(`Erro ${response.status}`);
  return await response.json();
}

// PUT — substituir um recurso completo
async function atualizarPost(id, dados) {
  const response = await fetch(`${BASE_URL}/posts/${id}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(dados),
  });

  if (!response.ok) throw new Error(`Erro ${response.status}`);
  return await response.json();
}

// PATCH — atualização parcial
async function atualizarTitulo(id, titulo) {
  const response = await fetch(`${BASE_URL}/posts/${id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title: titulo }),
  });

  if (!response.ok) throw new Error(`Erro ${response.status}`);
  return await response.json();
}

// DELETE — remover um recurso
async function deletarPost(id) {
  const response = await fetch(`${BASE_URL}/posts/${id}`, {
    method: "DELETE",
  });

  if (!response.ok) throw new Error(`Erro ${response.status}`);
  return true;
}

// Testando
async function main() {
  // Criar
  const novoPost = await criarPost({
    title: "Meu artigo sobre JavaScript",
    body: "JavaScript é incrível...",
    userId: 1,
  });
  console.log("Criado:", novoPost);

  // Atualizar título
  const atualizado = await atualizarTitulo(1, "Novo título");
  console.log("Atualizado:", atualizado.title);

  // Deletar
  await deletarPost(1);
  console.log("Post deletado.");
}

main();

Headers e autenticação

// Token JWT — o padrão mais comum de autenticação em APIs
async function buscarPerfilAutenticado(token) {
  const response = await fetch("https://api.exemplo.com/perfil", {
    headers: {
      "Authorization": `Bearer ${token}`,
      "Content-Type": "application/json",
      "Accept": "application/json",
    },
  });

  if (response.status === 401) {
    throw new Error("Token expirado. Faça login novamente.");
  }

  if (!response.ok) throw new Error(`Erro ${response.status}`);
  return await response.json();
}

// API Key — outro padrão comum
async function buscarClimaComApiKey(cidade) {
  const API_KEY = "sua_chave_aqui";
  const url = `https://api.openweathermap.org/data/2.5/weather?q=${cidade}&appid=${API_KEY}&lang=pt_br&units=metric`;

  const response = await fetch(url);
  if (!response.ok) throw new Error(`Cidade "${cidade}" não encontrada.`);
  return await response.json();
}

AbortController — cancelando requisições

Às vezes você precisa cancelar uma requisição em andamento — quando o usuário navega para outra página ou digita algo novo na busca:

let controlador = null;

async function buscarComCancelamento(termo) {
  // Cancela a requisição anterior se ainda estiver em andamento
  if (controlador) {
    controlador.abort();
  }

  controlador = new AbortController();

  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/posts?q=${termo}`,
      { signal: controlador.signal }
    );

    if (!response.ok) throw new Error(`Erro ${response.status}`);
    return await response.json();

  } catch (erro) {
    if (erro.name === "AbortError") {
      console.log("Requisição cancelada.");
      return null;
    }
    throw erro;
  }
}

// Uso com busca ao vivo
const input = document.querySelector("#busca");
input.addEventListener("input", async (e) => {
  const resultado = await buscarComCancelamento(e.target.value);
  if (resultado) exibirResultados(resultado);
});

Exemplo completo — app de busca de usuários do GitHub

Vamos construir uma aplicação real que consome a API pública do GitHub:

<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <title>GitHub User Search</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    :root {
      --bg: #0d1117;
      --surface: #161b22;
      --surface2: #21262d;
      --border: #30363d;
      --accent: #58a6ff;
      --text: #c9d1d9;
      --muted: #8b949e;
      --green: #3fb950;
    }

    body {
      font-family: -apple-system, 'Segoe UI', sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
      padding: 2rem 1rem;
    }

    .container {
      max-width: 600px;
      margin: 0 auto;
    }

    h1 {
      text-align: center;
      margin-bottom: 2rem;
      font-size: 1.5rem;
      color: var(--text);
    }

    .busca {
      position: relative;
      margin-bottom: 1.5rem;
    }

    .busca input {
      width: 100%;
      padding: .75rem 1rem .75rem 2.75rem;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 8px;
      color: var(--text);
      font-size: 1rem;
      transition: border-color .2s;
    }

    .busca input:focus {
      outline: none;
      border-color: var(--accent);
    }

    .busca-icone {
      position: absolute;
      left: .9rem;
      top: 50%;
      transform: translateY(-50%);
      color: var(--muted);
    }

    .loading {
      text-align: center;
      color: var(--muted);
      padding: 2rem;
      display: none;
    }

    .loading.visivel { display: block; }

    .erro {
      background: rgba(248, 81, 73, .1);
      border: 1px solid rgba(248, 81, 73, .4);
      border-radius: 8px;
      padding: 1rem;
      color: #f85149;
      display: none;
      margin-bottom: 1rem;
    }

    .erro.visivel { display: block; }

    /* Card do usuário */
    .card-usuario {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 1.5rem;
      margin-bottom: 1.5rem;
      display: none;
      animation: aparecer .3s ease;
    }

    .card-usuario.visivel { display: block; }

    @keyframes aparecer {
      from { opacity: 0; transform: translateY(-8px); }
      to   { opacity: 1; transform: translateY(0); }
    }

    .usuario-topo {
      display: flex;
      gap: 1rem;
      align-items: center;
      margin-bottom: 1rem;
    }

    .avatar {
      width: 72px;
      height: 72px;
      border-radius: 50%;
      border: 2px solid var(--border);
    }

    .usuario-info h2 { font-size: 1.1rem; }

    .usuario-info a {
      color: var(--accent);
      text-decoration: none;
      font-size: .9rem;
    }

    .usuario-info a:hover { text-decoration: underline; }

    .bio {
      color: var(--muted);
      font-size: .9rem;
      line-height: 1.5;
      margin-bottom: 1rem;
    }

    .stats {
      display: flex;
      gap: 1.5rem;
      margin-bottom: 1rem;
    }

    .stat { text-align: center; }

    .stat-valor {
      display: block;
      font-size: 1.1rem;
      font-weight: 700;
      color: var(--text);
    }

    .stat-label {
      font-size: .75rem;
      color: var(--muted);
    }

    .tags {
      display: flex;
      flex-wrap: wrap;
      gap: .5rem;
      margin-bottom: 1rem;
    }

    .tag {
      background: var(--surface2);
      border: 1px solid var(--border);
      border-radius: 999px;
      padding: .2rem .75rem;
      font-size: .8rem;
      color: var(--muted);
    }

    /* Repositórios */
    .repos-titulo {
      font-size: .9rem;
      color: var(--muted);
      text-transform: uppercase;
      letter-spacing: .05em;
      margin-bottom: .75rem;
    }

    .repo {
      background: var(--surface2);
      border: 1px solid var(--border);
      border-radius: 8px;
      padding: .85rem 1rem;
      margin-bottom: .5rem;
      animation: aparecer .2s ease;
    }

    .repo-nome {
      color: var(--accent);
      font-weight: 600;
      font-size: .95rem;
      text-decoration: none;
    }

    .repo-nome:hover { text-decoration: underline; }

    .repo-desc {
      color: var(--muted);
      font-size: .85rem;
      margin: .3rem 0;
      line-height: 1.4;
    }

    .repo-meta {
      display: flex;
      gap: 1rem;
      font-size: .8rem;
      color: var(--muted);
      margin-top: .4rem;
    }

    .repo-lang { color: var(--green); }
    .repo-stars::before { content: "⭐ "; }
    .repo-forks::before { content: "🍴 "; }
  </style>
</head>
<body>
<div class="container">
  <h1>🐙 GitHub User Search</h1>

  <div class="busca">
    <span class="busca-icone">🔍</span>
    <input type="text" id="input-busca" placeholder="Digite um usuário do GitHub...">
  </div>

  <div class="erro" id="erro"></div>
  <div class="loading" id="loading">⏳ Buscando...</div>

  <div class="card-usuario" id="card-usuario">
    <div class="usuario-topo">
      <img class="avatar" id="avatar" src="" alt="Avatar">
      <div class="usuario-info">
        <h2 id="nome-completo"></h2>
        <a id="link-perfil" href="" target="_blank" rel="noopener"></a>
      </div>
    </div>
    <p class="bio" id="bio"></p>
    <div class="stats">
      <div class="stat">
        <span class="stat-valor" id="stat-repos"></span>
        <span class="stat-label">Repos</span>
      </div>
      <div class="stat">
        <span class="stat-valor" id="stat-seguidores"></span>
        <span class="stat-label">Seguidores</span>
      </div>
      <div class="stat">
        <span class="stat-valor" id="stat-seguindo"></span>
        <span class="stat-label">Seguindo</span>
      </div>
    </div>
    <div class="tags" id="tags"></div>
    <p class="repos-titulo">Repositórios populares</p>
    <div id="lista-repos"></div>
  </div>
</div>

<script>
  // ── Referências ──────────────────────────────────
  const inputBusca = document.querySelector("#input-busca");
  const cardUsuario = document.querySelector("#card-usuario");
  const erroEl = document.querySelector("#erro");
  const loadingEl = document.querySelector("#loading");

  // ── Utilitários de UI ────────────────────────────
  function mostrarLoading() {
    loadingEl.classList.add("visivel");
    cardUsuario.classList.remove("visivel");
    erroEl.classList.remove("visivel");
  }

  function mostrarErro(mensagem) {
    loadingEl.classList.remove("visivel");
    erroEl.textContent = mensagem;
    erroEl.classList.add("visivel");
    cardUsuario.classList.remove("visivel");
  }

  function mostrarCard() {
    loadingEl.classList.remove("visivel");
    erroEl.classList.remove("visivel");
    cardUsuario.classList.add("visivel");
  }

  // ── API do GitHub ────────────────────────────────
  async function buscarUsuarioGitHub(login) {
    const response = await fetch(`https://api.github.com/users/${login}`, {
      headers: { "Accept": "application/vnd.github.v3+json" },
    });

    if (response.status === 404) throw new Error(`Usuário "${login}" não encontrado.`);
    if (response.status === 403) throw new Error("Limite de requisições atingido. Aguarde um momento.");
    if (!response.ok) throw new Error(`Erro ${response.status} ao buscar usuário.`);

    return await response.json();
  }

  async function buscarReposGitHub(login) {
    // Este endpoint NÃO aceita sort=stars — os valores válidos são
    // created, updated, pushed e full_name. Pedimos até 100 e ordenamos
    // por estrelas aqui mesmo.
    const response = await fetch(
      `https://api.github.com/users/${login}/repos?sort=updated&per_page=100`,
      { headers: { "Accept": "application/vnd.github.v3+json" } }
    );

    if (!response.ok) return [];

    const repos = await response.json();
    return repos
      .sort((a, b) => b.stargazers_count - a.stargazers_count)
      .slice(0, 5);
  }

  // ── Renderização ─────────────────────────────────
  function renderizarUsuario(usuario, repos) {
    // Dados básicos
    document.querySelector("#avatar").src = usuario.avatar_url;
    document.querySelector("#avatar").alt = usuario.login;
    document.querySelector("#nome-completo").textContent = usuario.name || usuario.login;

    const linkPerfil = document.querySelector("#link-perfil");
    linkPerfil.textContent = `@${usuario.login}`;
    linkPerfil.href = usuario.html_url;

    document.querySelector("#bio").textContent = usuario.bio || "Sem bio disponível.";

    // Stats
    document.querySelector("#stat-repos").textContent =
      usuario.public_repos.toLocaleString("pt-BR");
    document.querySelector("#stat-seguidores").textContent =
      usuario.followers.toLocaleString("pt-BR");
    document.querySelector("#stat-seguindo").textContent =
      usuario.following.toLocaleString("pt-BR");

    // Tags
    const tagsEl = document.querySelector("#tags");
    tagsEl.innerHTML = "";
    const infos = [
      usuario.location && `📍 ${usuario.location}`,
      usuario.company && `🏢 ${usuario.company}`,
      usuario.blog && `🔗 Blog`,
      usuario.twitter_username && `🐦 @${usuario.twitter_username}`,
    ].filter(Boolean);

    infos.forEach(info => {
      const tag = document.createElement("span");
      tag.classList.add("tag");
      tag.textContent = info;
      tagsEl.appendChild(tag);
    });

    // Repositórios
    const listaRepos = document.querySelector("#lista-repos");
    listaRepos.innerHTML = "";

    if (repos.length === 0) {
      listaRepos.innerHTML = '<p style="color: var(--muted); font-size: .9rem;">Nenhum repositório público.</p>';
      return;
    }

    // O nome e a descrição do repositório vêm da API, e quem os escolhe
    // é o dono do repositório — ou seja, são dado de terceiro. Montar
    // isso com innerHTML seria XSS; cada texto entra por textContent.
    repos.forEach(repo => {
      const div = document.createElement("div");
      div.classList.add("repo");

      const link = document.createElement("a");
      link.classList.add("repo-nome");
      link.href = repo.html_url;
      link.target = "_blank";
      link.rel = "noopener noreferrer";
      link.textContent = repo.name;
      div.appendChild(link);

      if (repo.description) {
        const desc = document.createElement("p");
        desc.classList.add("repo-desc");
        desc.textContent = repo.description;
        div.appendChild(desc);
      }

      const meta = document.createElement("div");
      meta.classList.add("repo-meta");

      if (repo.language) {
        const lang = document.createElement("span");
        lang.classList.add("repo-lang");
        lang.textContent = repo.language;
        meta.appendChild(lang);
      }

      const stars = document.createElement("span");
      stars.classList.add("repo-stars");
      stars.textContent = repo.stargazers_count.toLocaleString("pt-BR");

      const forks = document.createElement("span");
      forks.classList.add("repo-forks");
      forks.textContent = repo.forks_count.toLocaleString("pt-BR");

      meta.append(stars, forks);
      div.appendChild(meta);
      listaRepos.appendChild(div);
    });
  }

  // ── Busca principal ──────────────────────────────
  let controlador = null;

  async function buscar(login) {
    if (!login.trim()) {
      cardUsuario.classList.remove("visivel");
      erroEl.classList.remove("visivel");
      return;
    }

    if (controlador) controlador.abort();
    controlador = new AbortController();

    mostrarLoading();

    try {
      // Busca usuário e repos em paralelo
      const [usuario, repos] = await Promise.all([
        buscarUsuarioGitHub(login),
        buscarReposGitHub(login),
      ]);

      renderizarUsuario(usuario, repos);
      mostrarCard();

    } catch (erro) {
      if (erro.name === "AbortError") return;
      mostrarErro(erro.message);
    }
  }

  // ── Debounce para não buscar a cada tecla ────────
  function debounce(fn, delay) {
    let timer;
    return (...args) => {
      clearTimeout(timer);
      timer = setTimeout(() => fn(...args), delay);
    };
  }

  const buscarDebounced = debounce(buscar, 600);

  inputBusca.addEventListener("input", (e) => {
    buscarDebounced(e.target.value.trim());
  });

  inputBusca.addEventListener("keydown", (e) => {
    if (e.key === "Enter") {
      buscar(inputBusca.value.trim());
    }
  });

  // Busca um usuário famoso para demonstração
  inputBusca.value = "torvalds";
  buscar("torvalds");
</script>
</body>
</html>

Boas práticas com Fetch

// ✅ 1. Sempre verifique response.ok
if (!response.ok) throw new Error(`Erro ${response.status}`);

// ✅ 2. Sempre use try/catch com async/await
try {
  const dados = await fetch(url).then(r => r.json());
} catch (erro) {
  tratarErro(erro);
}

// ✅ 3. Nunca exponha API keys no frontend
// Use variáveis de ambiente e proxies de backend

// ✅ 4. Use AbortController em buscas ao vivo
// para cancelar requisições desatualizadas

// ✅ 5. Mostre feedback de loading ao usuário
// sempre que uma requisição estiver em andamento

// ✅ 6. Implemente retry para falhas temporárias
// especialmente em requisições críticas

// ✅ 7. Cache respostas quando possível
const cache = new Map();

async function buscarComCache(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }
  const dados = await fetch(url).then(r => r.json());
  cache.set(url, dados);
  return dados;
}

Tarefa para você

Use a API pública do PokeAPI (https://pokeapi.co/api/v2/) para construir:

// 1. Função buscarPokemon(nome) que retorna:
//    { nome, id, tipos, altura, peso, habilidades, sprite }

// 2. Função buscarTipoPokemon(tipo) que retorna
//    os primeiros 10 pokémons daquele tipo

// 3. Crie uma interface HTML simples com:
//    - Campo de busca por nome
//    - Exibição do sprite (imagem)
//    - Listagem de tipos com cores diferentes para cada tipo
//    - Botão "Pokémon aleatório" que busca um ID entre 1 e 898

// Dica: a URL base é https://pokeapi.co/api/v2/pokemon/{nome-ou-id}
// Não precisa de API key — é totalmente pública e gratuita
Ver solução — as duas funções da PokeAPI e a interface, com cache e cancelamento
const BASE = "https://pokeapi.co/api/v2";

// ---------------------------------------------------------------
// O envelope do fetch — feito uma vez, usado por todas as chamadas
// ---------------------------------------------------------------
// `fetch` só rejeita quando a REDE falha. Um 404 é uma resposta
// perfeitamente bem-sucedida do ponto de vista dele, e cai no `then`
// como se estivesse tudo bem. Checar `response.ok` não é opcional.
async function buscarJSON(url, { sinal } = {}) {
  const resposta = await fetch(url, { signal: sinal });

  if (!resposta.ok) {
    throw new Error(
      resposta.status === 404
        ? "Não encontrado."
        : `Erro ${resposta.status} ao consultar a API.`
    );
  }

  return resposta.json();
}

// ---------------------------------------------------------------
// 1 — buscarPokemon
// ---------------------------------------------------------------
const cache = new Map(); // a PokeAPI pede explicitamente que se use cache

async function buscarPokemon(nome, opcoes = {}) {
  const chave = String(nome).trim().toLowerCase();

  if (cache.has(chave)) return cache.get(chave);

  const dados = await buscarJSON(`${BASE}/pokemon/${chave}`, opcoes);

  const pokemon = {
    nome: dados.name,
    id: dados.id,
    tipos: dados.types.map((t) => t.type.name),
    // A API dá decímetros e hectogramas. Entregar 7 e 69 como "altura"
    // e "peso" seria repassar o problema para quem consome.
    altura: dados.height / 10,   // metros
    peso: dados.weight / 10,     // quilos
    habilidades: dados.abilities.map((a) => a.ability.name),
    sprite:
      dados.sprites.other?.["official-artwork"]?.front_default ??
      dados.sprites.front_default,
  };

  cache.set(chave, pokemon);
  return pokemon;
}

// ---------------------------------------------------------------
// 2 — buscarTipoPokemon
// ---------------------------------------------------------------
async function buscarTipoPokemon(tipo, quantidade = 10) {
  const dados = await buscarJSON(`${BASE}/type/${String(tipo).toLowerCase()}`);

  const primeiros = dados.pokemon.slice(0, quantidade);

  // Promise.all porque as 10 buscas são independentes: em série
  // seriam 10 idas e voltas enfileiradas, uma espera de segundos.
  return Promise.all(primeiros.map((p) => buscarPokemon(p.pokemon.name)));
}

// ---------------------------------------------------------------
// 3 — a interface
// ---------------------------------------------------------------
// ---- index.html
// <form id="busca">
//   <input type="search" id="entrada" placeholder="pikachu" required>
//   <button type="submit">Buscar</button>
//   <button type="button" id="btn-aleatorio">Pokémon aleatório</button>
// </form>
// <div id="resultado" role="status"></div>

// ---- app.js
const CORES = {
  fire: "#f08030", water: "#6890f0", grass: "#78c850", electric: "#f8d030",
  psychic: "#f85888", ice: "#98d8d8", dragon: "#7038f8", dark: "#705848",
  fairy: "#ee99ac", normal: "#a8a878", fighting: "#c03028", flying: "#a890f0",
  poison: "#a040a0", ground: "#e0c068", rock: "#b8a038", bug: "#a8b820",
  ghost: "#705898", steel: "#b8b8d0",
};

const formulario = document.querySelector("#busca");
const entrada = document.querySelector("#entrada");
const btnAleatorio = document.querySelector("#btn-aleatorio");
const resultado = document.querySelector("#resultado");

// Guarda a busca em andamento para poder cancelá-la: quem digita
// rápido dispara várias, e sem cancelamento a resposta da PRIMEIRA
// pode chegar depois da última e sobrescrever a tela com dado velho.
let buscaAtual = null;

function renderizar(pokemon) {
  resultado.innerHTML = "";

  const titulo = document.createElement("h2");
  titulo.textContent = `#${String(pokemon.id).padStart(3, "0")} ${pokemon.nome}`;

  const imagem = document.createElement("img");
  imagem.src = pokemon.sprite;
  imagem.alt = pokemon.nome;
  imagem.width = 200;
  imagem.loading = "lazy";

  const tipos = document.createElement("div");
  tipos.classList.add("tipos");

  for (const tipo of pokemon.tipos) {
    const etiqueta = document.createElement("span");
    etiqueta.classList.add("tipo");
    etiqueta.textContent = tipo;
    // Cor por tipo: aqui o style inline se justifica, porque a cor é
    // dado vindo da API, não decisão de layout.
    etiqueta.style.backgroundColor = CORES[tipo] ?? "#68a090";
    tipos.appendChild(etiqueta);
  }

  const ficha = document.createElement("dl");
  ficha.innerHTML = `
    <dt>Altura</dt><dd>${pokemon.altura.toFixed(1)} m</dd>
    <dt>Peso</dt><dd>${pokemon.peso.toFixed(1)} kg</dd>
  `;

  const habilidades = document.createElement("p");
  habilidades.textContent = `Habilidades: ${pokemon.habilidades.join(", ")}`;

  resultado.append(titulo, imagem, tipos, ficha, habilidades);
}

async function mostrar(nomeOuId) {
  // Cancela a busca anterior, se ainda estiver em voo.
  buscaAtual?.abort();
  buscaAtual = new AbortController();

  resultado.textContent = "Carregando...";

  try {
    const pokemon = await buscarPokemon(nomeOuId, { sinal: buscaAtual.signal });
    renderizar(pokemon);
  } catch (erro) {
    // Cancelamento não é falha: é o comportamento pedido. Mostrar
    // "erro" aqui confundiria o usuário que só digitou outra letra.
    if (erro.name === "AbortError") return;

    resultado.textContent = `❌ ${erro.message}`;
  }
}

formulario.addEventListener("submit", (evento) => {
  evento.preventDefault();
  mostrar(entrada.value);
});

btnAleatorio.addEventListener("click", () => {
  const id = Math.floor(Math.random() * 898) + 1;
  entrada.value = "";
  mostrar(id);
});

// ---------------------------------------------------------------
// O detalhe que morde: fetch não rejeita em 404
// ---------------------------------------------------------------
// Sem a checagem de `response.ok`, buscar "pikachuu" seguiria adiante
// e o `.json()` estouraria com um erro de sintaxe sobre o corpo do
// 404 — mensagem que não tem nada a ver com o problema real, e que
// manda o leitor caçar bug no lugar errado.
//
//   fetch(url).then(r => r.json())  // ❌ 404 vira "Unexpected token <"
//
// Só falha de rede (DNS, offline, CORS) faz o fetch rejeitar.

fetch só rejeita quando a rede falha: 404 e 500 chegam como resposta normal, e o erro só aparece disfarçado no .json(). Cheque response.ok em toda chamada — e, em campo de busca, cancele a requisição anterior com AbortController, senão a resposta atrasada sobrescreve a recente.

Se sobrar uma única coisa deste artigo, que seja esta: o fetch só rejeita quando a rede falha. Um 404, um 500 e um 403 chegam como resposta bem-sucedida, e quem separa um caso do outro é o response.ok. O restante — verbos, cabeçalhos, corpo em JSON, AbortController — é mecânica que se consulta na documentação quando precisa; essa distinção é a que vira defeito em produção quando fica de fora.

Fontes e Referências

Exercícios

Exercício 1

O usuário não existe e a API responde 404. O await fetch lança alguma coisa? O que sai em A, B e C?

const r = await fetch("https://api.github.com/users/usuario-que-nao-existe-12345");

console.log(r.ok);      // A
console.log(r.status);  // B

const dados = await r.json();
console.log(dados.message); // C
Ver resposta

✓ Resposta: Não lança nada. A é false, B é 404 e C imprime "Not Found". Para o fetch, receber um 404 é sucesso: a requisição saiu, o servidor respondeu, a comunicação funcionou — o que veio dentro é problema seu. Ele só rejeita quando a rede falha. E aqui está o que torna o descuido perigoso: a API do GitHub devolve um JSON também no 404, então o .json() funciona, o programa segue adiante e passa a tratar um objeto de erro como se fosse um usuário — a tela mostra campos vazios e ninguém sabe por quê. Em APIs que respondem HTML no 404, o sintoma é outro e igualmente enganoso: o .json() estoura com SyntaxError: Unexpected token '<', uma mensagem que manda o leitor caçar bug de parse quando o problema é a URL. Daí a regra sem exceção: if (!response.ok) throw ... antes de tocar no corpo.

Exercício 2

O que acontece na terceira linha?

const r = await fetch(url);

const texto = await r.text();
const json = await r.json();
Ver resposta

✓ Resposta: Lança TypeError: Failed to execute 'json' on 'Response': body stream already read. O corpo de uma resposta é um fluxo, não um texto guardado na memória: ele pode ser consumido uma única vez, e depois disso a propriedade r.bodyUsed passa a valer true. Isso vale para todos os leitores — text(), json(), blob(), arrayBuffer(): escolha um. Quando é mesmo necessário ler duas vezes, existem duas saídas. A primeira é const copia = r.clone(), feito antes de qualquer leitura, o que dá dois fluxos independentes — com o custo de o navegador precisar segurar o corpo inteiro na memória enquanto o mais lento não termina. A segunda, e melhor na prática, é ler o texto uma vez e fazer o parse você mesmo: const texto = await r.text() seguido de JSON.parse(texto). Assim, quando o parse falha, você ainda tem o texto bruto para colocar na mensagem de erro — coisa que o .json() sozinho não permite.

Exercício 3

Três POSTs. Dois estão errados. Quais, e por quê?

// A
fetch(url, { method: "POST", body: JSON.stringify({ nome: "Ana" }) });

// B
fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ nome: "Ana" }),
});

// C
fetch(url, {
  method: "POST",
  headers: { "Content-Type": "multipart/form-data" },
  body: new FormData(formulario),
});
Ver resposta

✓ Resposta: Só o B está certo. Em A falta o cabeçalho: o corpo é uma string, e o navegador então declara Content-Type: text/plain;charset=UTF-8. O servidor lê o cabeçalho antes de olhar o conteúdo, decide que não é JSON e devolve 400 ou um corpo vazio — enquanto no DevTools o payload aparece perfeitamente formado, o que torna o diagnóstico irritante. Em C o erro é o oposto: o cabeçalho está a mais. Com FormData, o navegador precisa gerar sozinho um Content-Type que inclui um separador aleatório, algo como multipart/form-data; boundary=----WebKitFormBoundary7MA4YW. Ao escrever o cabeçalho à mão você apaga esse separador, e o servidor recebe um corpo que não consegue dividir em campos — resultado: formulário vazio do outro lado. A regra é curta: com FormData, nunca defina Content-Type; com JSON, sempre.

Exercício 4

A busca ao vivo não tem AbortController. O usuário digita a e, logo depois, ab. A resposta de a demora 900 ms; a de ab, 200 ms. O que fica na tela?

input.addEventListener("input", async (e) => {
  const dados = await buscar(e.target.value);
  exibirResultados(dados);
});
Ver resposta

✓ Resposta: Fica na tela o resultado de a — o termo antigo. A resposta de ab chega primeiro, é exibida, e 700 ms depois a resposta atrasada da busca anterior sobrescreve tudo. O usuário vê o resultado certo aparecer e ser substituído pelo errado, e como isso depende da variação da rede, o defeito é intermitente: some no ambiente local e reaparece em produção. É importante notar que o debounce não resolve isso — ele reduz a quantidade de requisições disparadas, mas nada impede que uma delas volte fora de ordem. As duas soluções corretas são cancelar a anterior com AbortController, ou guardar um identificador da requisição mais recente e descartar qualquer resposta que não seja a dele. Com o cancelamento vem uma sutileza: o catch passa a receber um erro de nome AbortError, e ele precisa ser tratado como caso normal e não como falha, senão cada tecla digitada acende uma mensagem de erro na tela.

Exercício 5

Este catch disparou. Quais situações diferentes produzem exatamente essa mesma mensagem — e por que o navegador não diz qual foi?

try {
  const r = await fetch("https://outro-dominio.com/api/dados");
} catch (erro) {
  console.log(erro.name, erro.message); // TypeError  Failed to fetch
}
Ver resposta

✓ Resposta: Pelo menos quatro: não há conexão de rede; o domínio não resolve no DNS; o servidor não enviou os cabeçalhos de CORS que autorizam a sua origem; ou a página está em https e a URL é http, o que o navegador bloqueia como conteúdo misto. Todas chegam ao JavaScript como o mesmo TypeError: Failed to fetch, e a falta de detalhe é proposital: se o script pudesse distinguir "recusado por CORS" de "host inexistente", uma página maliciosa conseguiria mapear a rede interna de quem a visita só medindo respostas. O motivo real aparece no console e na aba Network do DevTools, que não estão sujeitos a essa restrição. Vale ainda desfazer um mal-entendido comum sobre CORS: quem bloqueia é o navegador, não o servidor. A requisição pode ter chegado e sido processada inteira do outro lado — um POST pode ter criado o registro —, e o que o navegador recusa é entregar a resposta ao seu código. Por isso CORS não é mecanismo de segurança do servidor, e um erro de CORS não significa que nada aconteceu.

Comentários

Mais em Javascript

Async/Await: escrevendo código assíncrono de forma limpa
Async/Await: escrevendo código assíncrono de forma limpa

A cadeia de .then resolveu a pirâmide mas espalhou os valores por escopos…

Promises: resolvendo o Callback Hell
Promises: resolvendo o Callback Hell

Uma Promise é o recibo de uma operação que ainda não terminou: você guarda o…

WebSockets e Comunicação em Tempo Real
WebSockets e Comunicação em Tempo Real

HTTP é uma conversa em que só o cliente pode falar primeiro. O WebSocket abre…