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
- MDN Web Docs — Fetch API: https://developer.mozilla.org/pt-BR/docs/Web/API/Fetch_API
- MDN Web Docs — Using Fetch: https://developer.mozilla.org/pt-BR/docs/Web/API/Fetch_API/Using_Fetch
- MDN Web Docs — Response: https://developer.mozilla.org/pt-BR/docs/Web/API/Response
- MDN Web Docs — AbortController: https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- JavaScript.info — Fetch: https://javascript.info/fetch
- JavaScript.info — Fetch: Abort: https://javascript.info/fetch-abort
- GitHub REST API Docs: https://docs.github.com/en/rest
- PokeAPI Docs: https://pokeapi.co/docs/v2
- web.dev — Introduction to fetch: https://web.dev/introduction-to-fetch
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.