Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

Fetch API: como consumir uma API com JavaScript

fetch não rejeita em erro HTTP, o corpo é uma segunda Promise e só pode ser lido uma vez. GET, POST, CORS e AbortController com respostas reais do Node.

Rodolfo Mori6 min de leitura

fetch faz uma requisição HTTP e devolve uma Promise com a resposta. Ele é nativo no navegador e no Node desde a versão 18 — nada para instalar.

Guarde estas duas frases antes de escrever a primeira linha. O fetch não rejeita quando o servidor responde com erro: 404 e 500 chegam normalmente, com response.ok valendo false. E o corpo da resposta é uma segunda Promise: response.json() também precisa de await.

Os exemplos abaixo rodam contra uma API local da loja, servida em http://localhost:4599 por este arquivo:

js
import { createServer } from 'node:http';

const produtos = [
  { id: 1, nome: 'Teclado mecânico', preco: 289.9, estoque: 12 },
  { id: 2, nome: 'Mouse sem fio', preco: 149.9, estoque: 3 },
  { id: 3, nome: 'Cadeira gamer', preco: 1199, estoque: 0 },
];

const espera = (ms) => new Promise((r) => setTimeout(r, ms));

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost:4599');
  const json = (status, corpo) => {
    res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
    res.end(JSON.stringify(corpo));
  };

  // ?ms=400 atrasa a resposta — é assim que os exemplos de tempo são medidos
  await espera(Number(url.searchParams.get('ms') ?? 0));

  if (url.pathname === '/produtos') return json(200, produtos);

  const um = url.pathname.match(/^\/produtos\/(\d+)$/);
  if (um) {
    const p = produtos.find((x) => x.id === Number(um[1]));
    return p ? json(200, p) : json(404, { erro: 'produto não encontrado' });
  }

  if (url.pathname === '/estoque') {
    const p = produtos.find((x) => x.id === Number(url.searchParams.get('id')));
    return json(200, { id: p?.id ?? null, disponivel: (p?.estoque ?? 0) > 0 });
  }

  if (url.pathname === '/frete') {
    return json(200, { cep: url.searchParams.get('cep'), prazo: 4, valor: 24.9 });
  }

  if (url.pathname === '/pedidos' && req.method === 'POST') {
    let corpo = '';
    for await (const parte of req) corpo += parte;
    const pedido = JSON.parse(corpo);
    return json(201, { id: 8241, cliente: pedido.cliente, total: pedido.total, status: 'aguardando pagamento' });
  }

  json(404, { erro: 'rota não encontrada' });
}).listen(4599);

O objeto Response é a resposta HTTP, não o JSON já pronto. Em palavras simples, primeiro chegam status e cabeçalhos; depois você escolhe como consumir o corpo. É por isso que uma chamada comum tem dois await.

Dois envelopes: a resposta primeiro, o corpo depois

Pense numa compra que chega com um envelope de protocolo preso à caixa. No protocolo estão o status da entrega e as informações do envio; o produto está dentro da caixa e ainda precisa ser aberto. O Response é o protocolo. A chamada response.json() abre e interpreta o corpo.

Antes de executar a primeira chamada, escreva os valores esperados para response.status, response.ok e o JSON. Depois confira os três. Repita a previsão para uma URL inexistente: a saída mostra por que erro HTTP e falha de rede são casos técnicos diferentes.

Duas esperas: uma pela resposta, outra pelo corpo.

js
const resposta = await fetch('http://localhost:4599/produtos');

console.log('status:', resposta.status, resposta.statusText);
console.log('ok:', resposta.ok);

const produtos = await resposta.json();
console.log(produtos);
status: 200 OK ok: true [ { id: 1, nome: 'Teclado mecânico', preco: 289.9, estoque: 12 }, { id: 2, nome: 'Mouse sem fio', preco: 149.9, estoque: 3 }, { id: 3, nome: 'Cadeira gamer', preco: 1199, estoque: 0 } ]

O objeto Response chega assim que os cabeçalhos chegam — o corpo ainda está vindo pela rede. Por isso json() é outra Promise, e não uma propriedade pronta. Se esse encadeamento de esperas ainda estiver novo, vale a lição de async e await.

fetch não rejeita em erro HTTP

Aqui está o comportamento que mais pega gente. Peço um produto que não existe:

js
const resposta = await fetch('http://localhost:4599/produtos/99');

console.log('caiu no catch?', 'não — o fetch resolveu normalmente');
console.log('status:', resposta.status);
console.log('ok:', resposta.ok);

const corpo = await resposta.json();
console.log('corpo:', corpo);
console.log('preço do produto:', corpo.preco);
caiu no catch? não — o fetch resolveu normalmente status: 404 ok: false corpo: { erro: 'produto não encontrado' } preço do produto: undefined

Nenhum erro. O programa segue em frente com um objeto que não é o esperado, e o undefined viaja pela aplicação até estourar longe daqui — normalmente como Cannot read properties of undefined.

A verificação de ok é obrigatória, e vale escrevê-la uma vez só, numa função:

js
async function buscarProduto(id) {
  const resposta = await fetch(`http://localhost:4599/produtos/${id}`);
  if (!resposta.ok) {
    throw new Error(`produto ${id}: a API respondeu ${resposta.status}`);
  }
  return resposta.json();
}

try {
  console.log(await buscarProduto(2));
  console.log(await buscarProduto(99));
} catch (e) {
  console.log('erro tratado:', e.message);
}
{ id: 2, nome: 'Mouse sem fio', preco: 149.9, estoque: 3 } erro tratado: produto 99: a API respondeu 404

O corpo só pode ser lido uma vez

Response é um fluxo. Depois de consumido, acabou:

js
const resposta = await fetch('http://localhost:4599/produtos');

const texto = await resposta.text();
console.log('texto tem', texto.length, 'caracteres');

const produtos = await resposta.json();
console.log(produtos);
texto tem 178 caracteres node:internal/deps/undici/undici:6979 return Promise.reject(new TypeError("Body is unusable: Body has already been read")); ^ TypeError: Body is unusable: Body has already been read at consumeBody (node:internal/deps/undici/undici:6979:31) at _Response.json (node:internal/deps/undici/undici:6930:18) at file:///private/tmp/loja-async/fetch-body-duas-vezes.mjs:6:33 at process.processTicksAndRejections (node:internal/process/task_queues:104:5) Node.js v24.16.0

Isso aparece na vida real quando você tenta logar a resposta bruta antes de converter para JSON. O contorno: leia como texto uma vez e use JSON.parse, ou chame resposta.clone() antes de consumir a primeira cópia.

POST: método, cabeçalhos e corpo

O segundo argumento do fetch é o objeto de opções. Para enviar JSON são três coisas: method, o cabeçalho Content-Type e o corpo passado por JSON.stringify.

js
const resposta = await fetch('http://localhost:4599/pedidos', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: 'Bearer token-da-loja',
  },
  body: JSON.stringify({
    cliente: 'Ana Souza',
    itens: [{ id: 1, quantidade: 1 }],
    total: 289.9,
  }),
});

console.log('status:', resposta.status);
console.log(await resposta.json());
status: 201 { id: 8241, cliente: 'Ana Souza', total: 289.9, status: 'aguardando pagamento' }

O erro de digitação mais caro desta seção é passar o objeto direto em body: sem JSON.stringify, o corpo vira a string [object Object] e o servidor devolve um 400 que parece culpa dele.

Quando o fetch realmente rejeita

Ele rejeita quando a requisição não acontece: servidor fora do ar, DNS que não resolve, conexão recusada, CORS, requisição abortada. Apontando para uma porta onde não há ninguém:

js
const resposta = await fetch('http://localhost:4600/produtos');
console.log(resposta.status);
node:internal/modules/run_main:107 triggerUncaughtException( ^ [TypeError: fetch failed] { [cause]: AggregateError [ECONNREFUSED]: at internalConnectMultiple (node:net:1193:18) at afterConnectMultiple (node:net:1783:7) { code: 'ECONNREFUSED', [errors]: [ Error: connect ECONNREFUSED ::1:4600 at createConnectionError (node:net:1746:14) at afterConnectMultiple (node:net:1776:16) { errno: -61, code: 'ECONNREFUSED', syscall: 'connect', address: '::1', port: 4600 }, Error: connect ECONNREFUSED 127.0.0.1:4600 at createConnectionError (node:net:1746:14) at afterConnectMultiple (node:net:1776:16) { errno: -61, code: 'ECONNREFUSED', syscall: 'connect', address: '127.0.0.1', port: 4600 } ] } } Node.js v24.16.0

TypeError: fetch failed é uma mensagem que não diz nada sozinha. O diagnóstico está em cause, e é ele que você deve imprimir:

js
try {
  const resposta = await fetch('http://localhost:4600/produtos');
  console.log(resposta.status);
} catch (e) {
  console.log('nome:', e.name);
  console.log('mensagem:', e.message);
  console.log('causa:', e.cause?.code, e.cause?.message);
}
nome: TypeError mensagem: fetch failed causa: ECONNREFUSED

No navegador, a mesma família de falhas aparece no console como TypeError: Failed to fetch — e a causa não vem no objeto de erro: vem numa segunda linha, escrita pelo próprio navegador.

CORS: o bloqueio que não é do seu código

Quando a página está em uma origem e a API em outra, o navegador só entrega a resposta ao seu JavaScript se o servidor autorizar por cabeçalho. Vamos olhar o que a nossa API manda:

js
const resposta = await fetch('http://localhost:4599/produtos');

console.log('content-type:', resposta.headers.get('content-type'));
console.log('access-control-allow-origin:', resposta.headers.get('access-control-allow-origin'));
console.log('todos os cabeçalhos:', [...resposta.headers.keys()]);
content-type: application/json; charset=utf-8 access-control-allow-origin: null todos os cabeçalhos: [ 'connection', 'content-type', 'date', 'keep-alive', 'transfer-encoding' ]

O Access-Control-Allow-Origin não existe nessa resposta. No Node isso não faz diferença nenhuma — a saída acima é de uma execução real, e os produtos vieram. O Node não implementa a política de mesma origem: quem implementa é o navegador.

Com essa mesma API e uma página servida em outra origem, o desfecho é outro. A requisição sai, o servidor responde, e o navegador descarta a resposta antes de o seu then ver qualquer coisa. Você recebe duas mensagens, e elas são diferentes de tudo que o Node imprime — o texto abaixo é do console do navegador, não da execução acima. Primeiro o aviso do próprio navegador:

Access to fetch at ‘http://localhost:4599/produtos’ from origin ‘http://localhost:3000’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.

E só então a Promise rejeita, com um erro que não diz absolutamente nada sobre a causa: TypeError: Failed to fetch. Se você tratar só esse TypeError, vai procurar bug na sua requisição por horas — a informação útil está na linha de cima, que o catch não recebe.

Três consequências práticas:

  • O bloqueio é do navegador, decidido pelos cabeçalhos do servidor. Nenhuma opção do lado do cliente resolve — mode: 'no-cors' só devolve uma resposta opaca, ilegível.
  • Se a API é sua, adicione o cabeçalho. Se é de terceiros, chame por um backend seu, que não passa pela regra do navegador.
  • Requisições com Content-Type: application/json ou cabeçalho Authorization disparam um OPTIONS de verificação antes do POST. Ver dois pedidos na aba Network é o esperado.

AbortController: desistir no tempo certo

Sem cancelamento, uma API travada trava a sua tela junto. O AbortController resolve:

js
const controlador = new AbortController();
const relogio = setTimeout(() => controlador.abort(), 150);

try {
  const resposta = await fetch('http://localhost:4599/frete?cep=01310100&ms=800', {
    signal: controlador.signal,
  });
  console.log(await resposta.json());
} catch (e) {
  if (e.name === 'AbortError') {
    console.log('desistimos do frete:', e.message);
  } else {
    throw e;
  }
} finally {
  clearTimeout(relogio);
}
desistimos do frete: This operation was aborted

O mesmo controlador serve para cancelar quando o usuário digita a próxima letra na busca ou sai da tela. O clearTimeout no finally é o cuidado que a lição de timers cobra: timer criado é timer que precisa morrer.

Um wrapper que cobre quase tudo

Juntando as quatro decisões — verificar ok, ler o corpo do erro, aplicar tempo-limite e devolver JSON já convertido:

js
async function pedirJson(url, opcoes = {}) {
  const { tempoLimite = 5000, ...resto } = opcoes;

  const resposta = await fetch(url, {
    ...resto,
    signal: AbortSignal.timeout(tempoLimite),
  });

  if (!resposta.ok) {
    const corpo = await resposta.text();
    throw new Error(`${resposta.status} ${resposta.statusText} em ${url} — ${corpo}`);
  }

  return resposta.json();
}

console.log(await pedirJson('http://localhost:4599/produtos/1'));

try {
  await pedirJson('http://localhost:4599/produtos/99');
} catch (e) {
  console.log('erro tratado:', e.message);
}

try {
  await pedirJson('http://localhost:4599/frete?cep=01310100&ms=900', { tempoLimite: 200 });
} catch (e) {
  console.log('nome:', e.name, '| mensagem:', e.message);
}
{ id: 1, nome: 'Teclado mecânico', preco: 289.9, estoque: 12 } erro tratado: 404 Not Found em http://localhost:4599/produtos/99 — {"erro":"produto não encontrado"} nome: TimeoutError | mensagem: The operation was aborted due to timeout

AbortSignal.timeout(ms) evita o par controlador/setTimeout escrito à mão, e o erro vem com o nome TimeoutError, distinguível de um cancelamento feito pelo usuário.

Vinte linhas que você escreve uma vez por projeto e não repete mais. Quando a tela precisa de várias dessas chamadas ao mesmo tempo, o próximo passo é Promise.all, allSettled e race — sem isso, cada await novo soma tempo de espera na cara do usuário.

Prefere aprender em vídeo?

Tem aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • fetch
  • api
  • http
  • cors
  • abortcontroller

Perguntas frequentes

Por que meu catch não pega o erro 404 do fetch?
Porque para o fetch um 404 é uma resposta bem-sucedida: o servidor respondeu. A Promise só rejeita quando a requisição não acontece — rede fora, DNS errado, CORS, requisição abortada. Erro de status se verifica com response.ok.
Preciso instalar axios ou node-fetch?
Não. O fetch é nativo no navegador desde 2017 e no Node desde a versão 18. Bibliotecas ainda entregam conveniências como interceptadores e retry automático, mas nada disso é requisito para consumir uma API.
Como resolvo um erro de CORS no front-end?
No front-end, não se resolve. O bloqueio é do navegador, com base nos cabeçalhos que o servidor manda. Ou o servidor passa a enviar Access-Control-Allow-Origin, ou você chama a API pelo seu próprio backend.
Preciso de try/catch e de response.ok, os dois?
Sim, porque eles cobrem falhas diferentes. O try/catch pega a requisição que não aconteceu; o response.ok pega a que aconteceu e voltou com erro. Só um dos dois deixa metade dos casos passando batido.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Usando Fetch — developer.mozilla.org
  2. MDN — CORS — developer.mozilla.org

Continue por aqui