Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

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

Métodos HTTP e status code: 200, 201, 400, 404 e 500

Qual verbo usar em cada rota e qual status devolver em cada resposta, com tabela de decisão e a saída real de curl -i em cada caso de uma API Express.

Rodolfo Mori14 min de leitura

O método HTTP diz o que você quer fazer com um recurso. O status code diz o que aconteceu com o pedido. Errar um dos dois não quebra a sua API na hora — quebra o cliente dela, semanas depois, de um jeito difícil de rastrear.

Todos os exemplos desta lição rodam contra a mesma API: o acervo da Biblioteca Vila Nova, com livros e empréstimos. Ela é um Express sobre a base que você montou em criar um servidor HTTP no Node e nas regras de recurso e contrato de o que é uma API REST.

js
import express from 'express';

const app = express();
app.use(express.json());

const livros = [
  { id: 1, titulo: 'Dom Casmurro', autor: 'Machado de Assis', isbn: '9788535910663', ano: 1899, emprestado: false },
  { id: 2, titulo: 'Vidas Secas', autor: 'Graciliano Ramos', isbn: '9788503012263', ano: 1938, emprestado: true },
];

let proximoId = 3;
const acharLivro = (id) => livros.find((l) => l.id === Number(id));

const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => console.log(`Biblioteca Vila Nova ouvindo na porta ${PORT}`));

Método e status ocupam lados diferentes do contrato HTTP. Em palavras simples, o método declara a intenção da requisição; o status resume o resultado que o servidor produziu para aquela intenção.

O pedido na ida e o comprovante na volta

Numa biblioteca, a ficha “quero renovar este empréstimo” acompanha o pedido na ida. Na volta, um comprovante informa “renovado”, “livro não encontrado” ou “pedido sem autorização”. O método HTTP é a ficha de intenção; o status code é o comprovante do resultado. Um não substitui o outro, e ambos orientam o próximo passo do cliente.

Antes de abrir a implementação, classifique cada cenário com duas respostas: qual método representa a intenção e qual família de status descreve o resultado. Depois envie a requisição e confira método, status e corpo. Se o cliente precisa adivinhar pelo texto do JSON, o contrato ainda não está claro.

Cada verbo é uma promessa que você faz a quem consome a API. Duas colunas dessa tabela valem mais que o resto: seguro quer dizer que a requisição não muda nada no servidor, e idempotente quer dizer que repetir o mesmo pedido dez vezes deixa o servidor no mesmo estado que uma vez só.

verbo intenção manda corpo seguro idempotente
GET ler um recurso ou uma coleção não sim sim
POST criar um recurso novo na coleção sim não não
PUT substituir o recurso inteiro sim não sim
PATCH alterar alguns campos do recurso sim não não garantido
DELETE remover o recurso não não sim

No Express, cada verbo tem um método com o mesmo nome. As duas rotas de leitura da biblioteca ficam assim:

js
app.get('/livros', (req, res) => {
  res.status(200).json(livros);
});

app.get('/livros/:id', (req, res) => {
  const livro = acharLivro(req.params.id);
  if (!livro) {
    return res.status(404).json({ erro: 'Livro não encontrado', id: Number(req.params.id) });
  }
  res.status(200).json(livro);
});

Chamando as duas — e uma terceira vez com um id que não existe:

bash
curl -s -w '\n-- %{http_code}\n' http://localhost:5599/livros
curl -s -w '\n-- %{http_code}\n' http://localhost:5599/livros/1
curl -s -w '\n-- %{http_code}\n' http://localhost:5599/livros/99
[{"id":1,"titulo":"Dom Casmurro","autor":"Machado de Assis","isbn":"9788535910663","ano":1899,"emprestado":false},{"id":2,"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012263","ano":1938,"emprestado":true}] -- 200 {"id":1,"titulo":"Dom Casmurro","autor":"Machado de Assis","isbn":"9788535910663","ano":1899,"emprestado":false} -- 200 {"erro":"Livro não encontrado","id":99} -- 404

O -w '%{http_code}' é o jeito mais curto de ver o status sem poluir a tela com todos os headers. Guarde esse comando: ele volta várias vezes aqui.

bash
curl -i -s -X OPTIONS http://localhost:5599/livros/2
HTTP/1.1 200 OK X-Powered-By: Express Allow: DELETE, GET, HEAD, PATCH, PUT Content-Length: 29 Content-Type: text/plain X-Content-Type-Options: nosniff Date: Sat, 22 Aug 2026 22:49:18 GMT Connection: keep-alive Keep-Alive: timeout=5

DELETE, GET, HEAD, PATCH, PUT

Idempotência: a propriedade que decide se dá para tentar de novo

Isso não é teoria de prova. Quando a internet do celular cai no meio da requisição, o app não sabe se o servidor recebeu ou não. Se o verbo é idempotente, ele reenvia sem medo. Se não é, ele precisa perguntar antes.

O PUT é idempotente: mandar o mesmo livro inteiro duas vezes dá o mesmo resultado.

bash
LIVRO='{"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012263","ano":1938}'
curl -s -w '\n-- %{http_code}\n' -X PUT http://localhost:5599/livros/2 -H 'Content-Type: application/json' -d "$LIVRO"
curl -s -w '\n-- %{http_code}\n' -X PUT http://localhost:5599/livros/2 -H 'Content-Type: application/json' -d "$LIVRO"
{"id":2,"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012263","ano":1938,"emprestado":true} -- 200 {"id":2,"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012263","ano":1938,"emprestado":true} -- 200

Já o POST cria um recurso novo a cada chamada. Repetir um POST de empréstimo significaria emprestar o mesmo livro duas vezes — e é justamente para impedir isso que existe o status 409, que aparece daqui a pouco.

PUT ou PATCH: a decisão que trava todo mundo

A regra é literal. PUT substitui o recurso pela representação que você mandou; PATCH altera só os campos que vieram. Quem usa PUT mandando metade do objeto acaba apagando a outra metade sem perceber.

Por isso, na biblioteca, o PUT exige o livro inteiro e recusa o resto:

js
app.put('/livros/:id', (req, res) => {
  const livro = acharLivro(req.params.id);
  if (!livro) return res.status(404).json({ erro: 'Livro não encontrado' });

  const { titulo, autor, isbn, ano } = req.body ?? {};
  if (!titulo || !autor || !isbn || !ano) {
    return res.status(400).json({ erro: 'PUT exige a representação inteira do livro' });
  }

  Object.assign(livro, { titulo, autor, isbn, ano });
  res.status(200).json(livro);
});

app.patch('/livros/:id', (req, res) => {
  const livro = acharLivro(req.params.id);
  if (!livro) return res.status(404).json({ erro: 'Livro não encontrado' });

  Object.assign(livro, req.body ?? {});
  res.status(200).json(livro);
});

O mesmo corpo de um campo só, nos dois verbos:

bash
curl -s -w '\n-- %{http_code}\n' -X PUT   http://localhost:5599/livros/2 -H 'Content-Type: application/json' -d '{"ano":1939}'
curl -s -w '\n-- %{http_code}\n' -X PATCH http://localhost:5599/livros/2 -H 'Content-Type: application/json' -d '{"ano":1939}'
{"erro":"PUT exige a representação inteira do livro"} -- 400 {"id":2,"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012263","ano":1939,"emprestado":true} -- 200

Na dúvida: se a tela é um formulário de edição que carrega tudo e salva tudo, PUT. Se é um botão que muda uma coisa só — arquivar, renovar, marcar como lido — PATCH.

O PATCH ingênuo aceita campo que você nunca quis expor

Olhe de novo para o Object.assign(livro, req.body ?? {}) ali em cima: ele copia qualquer chave que chegou. Quem chama a rota é quem decide o que escrever no seu objeto — inclusive id e emprestado, que não aparecem em formulário de edição nenhum.

bash
curl -s -w '\n-- %{http_code}\n' -X PATCH http://localhost:5599/livros/2 \
  -H 'Content-Type: application/json' \
  -d '{"emprestado":false,"id":99}'
{"id":99,"titulo":"Vidas Secas","autor":"Graciliano Ramos","isbn":"9788503012263","ano":1939,"emprestado":false} -- 200

O livro trocou de identidade e voltou sozinho para a estante, e a API respondeu 200 como se estivesse tudo certo. “Alterar alguns campos” só vira promessa cumprível quando a rota diz quais campos:

js
const CAMPOS_EDITAVEIS = ['titulo', 'autor', 'isbn', 'ano'];

app.patch('/livros/:id', (req, res) => {
  const livro = acharLivro(req.params.id);
  if (!livro) return res.status(404).json({ erro: 'Livro não encontrado' });

  const campos = Object.keys(req.body ?? {});
  const recusados = campos.filter((c) => !CAMPOS_EDITAVEIS.includes(c));

  if (campos.length === 0) {
    return res.status(400).json({ erro: 'PATCH precisa de pelo menos um campo' });
  }

  if (recusados.length > 0) {
    return res.status(400).json({
      erro: 'PATCH aceita só os campos editáveis do livro',
      editaveis: CAMPOS_EDITAVEIS,
      recusados,
    });
  }

  Object.assign(livro, req.body);
  res.status(200).json(livro);
});

O mesmo pedido, agora com a lista no lugar:

bash
curl -s -w '\n-- %{http_code}\n' -X PATCH http://localhost:5599/livros/2 \
  -H 'Content-Type: application/json' \
  -d '{"emprestado":false,"id":99}'
{"erro":"PATCH aceita só os campos editáveis do livro","editaveis":["titulo","autor","isbn","ano"],"recusados":["emprestado","id"]} -- 400

Repare que a resposta é 400 e não 403: o pedido não é proibido, ele está malformado para esta rota. E o campo emprestado volta a mudar só por onde o empréstimo passa — que é a rota do 409, algumas seções abaixo.

As cinco famílias de status, numa tabela só

O primeiro dígito já conta a história inteira. Decorar esses cinco significados resolve 90% das dúvidas antes de você abrir a documentação.

faixa significado quem errou exemplos do dia a dia
1xx “recebi, continue” ninguém 100 Continue, 101 Switching Protocols
2xx deu certo ninguém 200, 201, 204
3xx mudou de endereço — ou não mudou nada ninguém 301, 302, 304 Not Modified
4xx o cliente mandou algo errado quem chamou 400, 401, 403, 404, 409, 422
5xx o servidor falhou você 500, 502, 503, 504

A fronteira que importa é entre 4xx e 5xx: ela decide quem é acordado de madrugada. Um 4xx é comportamento esperado da API. Um 5xx é bug seu, e precisa aparecer no alerta.

201 não é 200: o Location diz onde o recurso nasceu

Criação bem-sucedida devolve 201 Created, não 200. E o 201 fica muito melhor com o header Location apontando para o endereço do recurso novo:

js
app.post('/livros', (req, res) => {
  const { titulo, autor, isbn, ano } = req.body ?? {};
  // ...validações que vêm nas próximas seções...

  const livro = { id: proximoId++, titulo, autor, isbn, ano, emprestado: false };
  livros.push(livro);
  res.status(201).location(`/livros/${livro.id}`).json(livro);
});
bash
curl -i -s -X POST http://localhost:5599/livros \
  -H 'Content-Type: application/json' \
  -d '{"titulo":"Grande Sertao: Veredas","autor":"Guimaraes Rosa","isbn":"9788520925171","ano":1956}'
HTTP/1.1 201 Created X-Powered-By: Express Location: /livros/3 Content-Type: application/json; charset=utf-8 Content-Length: 120 ETag: W/"78-v5lna20HfsTKjKFYpck5mW8IGR4" Date: Sat, 22 Aug 2026 22:49:18 GMT Connection: keep-alive Keep-Alive: timeout=5

{“id”:3,“titulo”:“Grande Sertao: Veredas”,“autor”:“Guimaraes Rosa”,“isbn”:“9788520925171”,“ano”:1956,“emprestado”:false}

O front-end lê o Location e já sabe para onde navegar. Sem ele, resta ao cliente adivinhar como você monta a URL do item.

204 é sucesso mudo — e ele engole o seu JSON sem avisar

204 No Content quer dizer “deu certo, e não tenho nada para te devolver”. É o status certo para o DELETE da biblioteca:

js
app.delete('/livros/:id', (req, res) => {
  const i = livros.findIndex((l) => l.id === Number(req.params.id));
  if (i === -1) return res.status(404).json({ erro: 'Livro não encontrado' });

  livros.splice(i, 1);
  res.status(204).end();
});
bash
curl -i -s -X DELETE http://localhost:5599/livros/3
HTTP/1.1 204 No Content X-Powered-By: Express Date: Sat, 22 Aug 2026 22:49:18 GMT Connection: keep-alive Keep-Alive: timeout=5

Repare no que não está aí: nem Content-Type, nem Content-Length. Uma resposta 204 não tem corpo nenhum, e por isso o Node nem escreve o cabeçalho de tamanho. Muita gente espera ver Content-Length: 0 — não aparece.

E aqui está a armadilha. Se você escrever res.status(204).json(...), o Express aceita sem reclamar e o corpo simplesmente some:

js
app.delete('/livros/:id', (req, res) => {
  res.status(204).json({ mensagem: 'Livro removido do acervo' });
});
HTTP/1.1 204 No Content X-Powered-By: Express ETag: W/"27-8VbAZx3/C71qHLRm0eZyniC8CmI" Date: Sat, 22 Aug 2026 22:49:18 GMT Connection: keep-alive Keep-Alive: timeout=5

O ETag prova que o Express chegou a calcular o JSON — e a resposta saiu vazia mesmo assim. Sem erro, sem aviso, sem log. Se você precisa devolver alguma mensagem, o status é 200, não 204.

400, 404 e 409: três jeitos diferentes de o cliente errar

400 Bad Request é “o pedido está malformado”: falta campo obrigatório, o tipo está errado, o JSON não abre. 404 Not Found é “esse recurso não existe”. 409 Conflict é o mais esquecido dos três: o pedido está correto, mas briga com o estado atual do servidor.

js
if (!titulo || !isbn) {
  return res.status(400).json({
    erro: 'Campos obrigatórios ausentes',
    faltando: ['titulo', 'isbn'].filter((c) => !req.body?.[c]),
  });
}

if (livros.some((l) => l.isbn === isbn)) {
  return res.status(409).json({ erro: 'Já existe um livro com esse ISBN no acervo', isbn });
}
bash
curl -s -w '\n-- %{http_code}\n' -X POST http://localhost:5599/livros -H 'Content-Type: application/json' -d '{"autor":"Clarice Lispector"}'
curl -s -w '\n-- %{http_code}\n' -X POST http://localhost:5599/livros -H 'Content-Type: application/json' -d '{"titulo":"Dom Casmurro","autor":"Machado de Assis","isbn":"9788535910663","ano":1899}'
{"erro":"Campos obrigatórios ausentes","faltando":["titulo","isbn"]} -- 400 {"erro":"Já existe um livro com esse ISBN no acervo","isbn":"9788535910663"} -- 409

O segundo pedido está perfeito: JSON válido, todos os campos, tipos certos. Devolver 400 ali seria mentira. O problema é o acervo, não o pedido — e é exatamente isso que o 409 comunica.

O caso mais bonito de 409 é o empréstimo. O livro existe, o leitor existe, o pedido é impecável — só que o livro já saiu com outra pessoa:

js
app.post('/emprestimos', (req, res) => {
  const livro = acharLivro(req.body?.livroId);
  if (!livro) return res.status(404).json({ erro: 'Livro não encontrado' });

  if (livro.emprestado) {
    return res.status(409).json({ erro: 'Este livro já está emprestado', livroId: livro.id });
  }

  livro.emprestado = true;
  res.status(201).location(`/emprestimos/${livro.id}`).json({ livroId: livro.id, leitor: req.body.leitor });
});
bash
curl -s -w '\n-- %{http_code}\n' -X POST http://localhost:5599/emprestimos -H 'Content-Type: application/json' -d '{"livroId":1,"leitor":"Marina"}'
curl -s -w '\n-- %{http_code}\n' -X POST http://localhost:5599/emprestimos -H 'Content-Type: application/json' -d '{"livroId":1,"leitor":"Joao"}'
{"livroId":1,"leitor":"Marina"} -- 201 {"erro":"Este livro já está emprestado","livroId":1} -- 409

Mesmo endereço, mesmo formato, dois resultados diferentes — porque o estado do acervo mudou entre uma chamada e outra. É a assinatura do 409.

401 é “não sei quem você é”; 403 é “sei, e não pode”

Os dois são recusa, e trocar um pelo outro atrapalha quem está integrando. 401 Unauthorized significa que faltou (ou falhou) a autenticação — e a resposta precisa trazer o header WWW-Authenticate dizendo qual esquema usar. 403 Forbidden significa que a identidade chegou, foi aceita, e mesmo assim não tem permissão. Mandar credencial melhor não resolve um 403.

js
app.get('/relatorios', (req, res) => {
  const token = req.get('Authorization');

  if (!token) {
    return res.status(401).set('WWW-Authenticate', 'Bearer').json({
      erro: 'Envie o token no header Authorization',
    });
  }

  if (token !== 'Bearer bibliotecaria') {
    return res.status(403).json({ erro: 'Só a bibliotecária vê o relatório do acervo' });
  }

  res.status(200).json({ total: livros.length, emprestados: livros.filter((l) => l.emprestado).length });
});
bash
curl -i -s http://localhost:5599/relatorios
HTTP/1.1 401 Unauthorized X-Powered-By: Express WWW-Authenticate: Bearer Content-Type: application/json; charset=utf-8 Content-Length: 48 ETag: W/"30-8Kdokl7JyGlPTvQuiA75HW7LI5k" Date: Sat, 22 Aug 2026 22:49:18 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“Envie o token no header Authorization”}

Com um token de leitor comum, a resposta muda de faixa dentro do mesmo 4xx:

bash
curl -s -w '\n-- %{http_code}\n' http://localhost:5599/relatorios -H 'Authorization: Bearer leitor'
{"erro":"Só a bibliotecária vê o relatório do acervo"} -- 403

422 existe mesmo? Quando ele ganha do 400

422 Unprocessable Content é para o pedido sintaticamente perfeito que não passa numa regra de negócio. O JSON abriu, os campos estão lá, os tipos batem — e mesmo assim o conteúdo não faz sentido. Um livro publicado em 2049, por exemplo:

js
if (ano && ano > new Date().getFullYear()) {
  return res.status(422).json({
    erro: 'O ano de publicação não pode estar no futuro',
    campo: 'ano',
    recebido: ano,
  });
}
bash
curl -s -w '\n-- %{http_code}\n' -X POST http://localhost:5599/livros \
  -H 'Content-Type: application/json' \
  -d '{"titulo":"O Livro do Futuro","autor":"Ninguem","isbn":"9781234567897","ano":2049}'
{"erro":"O ano de publicação não pode estar no futuro","campo":"ano","recebido":2049} -- 422

Vale a pena? A minha resposta: use 422 se a sua API tem validação de formulário e o front precisa marcar campo a campo; use 400 para todo o resto. Um cliente que trata 400 genérico não quebra ao receber 422, mas um 422 bem usado deixa claro que o problema é o valor, não o formato — e essa distinção poupa muita conversa. Se você for validar com uma biblioteca de schema, o assunto continua em validar a entrada da API com Zod.

500 e 503: o erro é seu, e o cliente não precisa saber por quê

500 é a resposta honesta para “quebrou aqui dentro e eu não previ”. A rota abaixo tem um bug de propósito — o livro não tem a propriedade capa:

js
app.get('/livros/:id/capa', (req, res) => {
  const livro = acharLivro(req.params.id);
  res.status(200).json({ capa: livro.capa.url });
});

app.use((err, req, res, next) => {
  const status = err.status ?? err.statusCode ?? 500;

  if (status >= 500) {
    console.error('[erro 500]', err.stack);
    return res.status(500).json({ erro: 'Erro interno na biblioteca', id: 'log-7f21' });
  }

  res.status(status).json({ erro: err.message });
});

Do lado de fora, o cliente recebe uma mensagem genérica com um identificador:

bash
curl -s -w '\n-- %{http_code}\n' http://localhost:5599/livros/2/capa
{"erro":"Erro interno na biblioteca","id":"log-7f21"} -- 500

Do lado de dentro, o log guarda o que interessa:

[erro 500] TypeError: Cannot read properties of undefined (reading 'url') at file:///private/tmp/biblioteca/biblioteca.js:110:43 at Layer.handleRequest (/private/tmp/biblioteca/node_modules/router/lib/layer.js:152:17) at next (/private/tmp/biblioteca/node_modules/router/lib/route.js:157:13)

Essa separação é regra, não estilo: stack trace na resposta HTTP entrega caminho de arquivo, versão de biblioteca e estrutura interna para qualquer pessoa na internet. O handler centralizado tem mais detalhes em tratamento de erro no Express.

O 503 Service Unavailable é diferente do 500: ele é planejado. Serve para deploy, manutenção e sobrecarga — e aceita o header Retry-After dizendo em quantos segundos vale a pena tentar de novo.

js
let emManutencao = true;

app.get('/busca', (req, res) => {
  if (emManutencao) {
    return res.status(503).set('Retry-After', '120').json({ erro: 'Índice de busca em reconstrução' });
  }
  res.status(200).json([]);
});
bash
curl -i -s http://localhost:5599/busca
HTTP/1.1 503 Service Unavailable X-Powered-By: Express Retry-After: 120 Content-Type: application/json; charset=utf-8 Content-Length: 45 ETag: W/"2d-o6K+1S5bvi+rl1ZzEaugFEqIfVE" Date: Sat, 22 Aug 2026 22:49:18 GMT Connection: keep-alive Keep-Alive: timeout=5

{“erro”:“Índice de busca em reconstrução”}

O erro mais comum: JSON quebrado saindo como 500

Este é o defeito que quase toda API iniciante tem, e ele nasce de um handler de erro de três linhas que parece inofensivo:

js
app.use((err, req, res, next) => {
  console.error('[erro]', err.message);
  res.status(500).json({ erro: 'Erro interno na biblioteca', id: 'log-7f21' });
});

Agora mande um JSON com vírgula sobrando — erro do cliente, não seu:

bash
curl -s -w '\n-- %{http_code}\n' -X POST http://localhost:5599/livros \
  -H 'Content-Type: application/json' \
  -d '{"titulo":"Capitaes da Areia",}'
{"erro":"Erro interno na biblioteca","id":"log-7f21"} -- 500

Sua API acabou de assumir a culpa por um erro de digitação de outra pessoa. E o estrago é maior do que parece: o monitoramento vai acusar taxa de erro 5xx, alguém vai ser acordado, e o cliente vai reenviar o mesmo JSON quebrado porque 5xx é o tipo de erro que se costuma tentar de novo.

A causa é simples. O express.json() lança um erro que já vem com o status certoerr.status vale 400 —, e o handler acima joga esse número fora. Basta respeitá-lo:

js
app.use((err, req, res, next) => {
  const status = err.status ?? err.statusCode ?? 500;

  if (status >= 500) {
    console.error('[erro 500]', err.stack);
    return res.status(500).json({ erro: 'Erro interno na biblioteca', id: 'log-7f21' });
  }

  res.status(status).json({ erro: err.message });
});

O mesmo curl, com o handler corrigido:

{"erro":"Expected double-quoted property name in JSON at position 30 (line 1 column 31)"} -- 400

Mesma requisição, mesma exceção, outra faixa de status. A diferença toda é uma linha que lê err.status antes de decidir.

A tabela de decisão das sete rotas da biblioteca

Este mapa organiza as decisões de um CRUD. Cada linha foi conferida contra a API rodando — os status da coluna da direita saíram do terminal, não de uma estimativa.

rota sucesso erro do cliente quando acontece
GET /livros 200 coleção vazia também é 200, com []
GET /livros/:id 200 404 id que não está no acervo
POST /livros 201 + Location 400 · 409 · 422 campo faltando · ISBN repetido · ano no futuro
PUT /livros/:id 200 400 · 404 representação incompleta · id inexistente
PATCH /livros/:id 200 400 · 404 campo fora da lista editável · id inexistente
DELETE /livros/:id 204 404 id já removido
POST /emprestimos 201 + Location 404 · 409 livro inexistente · livro já emprestado

Três decisões dessa tabela são minhas, e valem ser ditas em voz alta: coleção vazia é 200 e não 404 (a coleção existe, só está vazia); DELETE repetido devolve 404 e não 204 (a segunda chamada realmente não encontrou nada, e essa informação ajuda quem depura); e POST duplicado devolve 409 em vez de recriar em silêncio — é o 409 que compensa o POST não ser idempotente.

Provando a tabela inteira num script só

Não confie na tabela: rode. Este script tem uma chamada para cada célula dela — as sete rotas, com os sucessos e os erros de cliente de cada uma — e imprime só o status. Dá para colar num npm run smoke e rodar depois de todo deploy.

bash
#!/bin/bash
API=http://localhost:5599
COMPLETO='{"titulo":"Dom Casmurro","autor":"Machado de Assis","isbn":"9788535910663","ano":1899}'
MACUNAIMA='{"titulo":"Macunaima","autor":"Mario de Andrade","isbn":"9788526017504","ano":1928}'

confere() {
  local metodo=$1 rota=$2 corpo=$3
  local codigo
  if [ -n "$corpo" ]; then
    codigo=$(curl -s -o /dev/null -w '%{http_code}' -X "$metodo" "$API$rota" \
      -H 'Content-Type: application/json' -d "$corpo")
  else
    codigo=$(curl -s -o /dev/null -w '%{http_code}' -X "$metodo" "$API$rota")
  fi
  printf '%-6s %-16s -> %s\n' "$metodo" "$rota" "$codigo"
}

confere GET    /livros
confere GET    /livros/1
confere GET    /livros/404
confere POST   /livros       "$MACUNAIMA"
confere POST   /livros       "$MACUNAIMA"
confere POST   /livros       '{"autor":"Mario de Andrade"}'
confere POST   /livros       '{"titulo":"Anuario","autor":"IBGE","isbn":"9781234567897","ano":2049}'
confere PUT    /livros/1     "$COMPLETO"
confere PUT    /livros/1     "$COMPLETO"
confere PUT    /livros/1     '{"ano":1900}'
confere PUT    /livros/404   "$COMPLETO"
confere PATCH  /livros/1     '{"ano":1900}'
confere PATCH  /livros/1     '{"emprestado":false}'
confere PATCH  /livros/404   '{"ano":1900}'
confere POST   /emprestimos  '{"livroId":1,"leitor":"Marina"}'
confere POST   /emprestimos  '{"livroId":1,"leitor":"Joao"}'
confere POST   /emprestimos  '{"livroId":404,"leitor":"Joao"}'
confere DELETE /livros/1
confere DELETE /livros/1
GET /livros -> 200 GET /livros/1 -> 200 GET /livros/404 -> 404 POST /livros -> 201 POST /livros -> 409 POST /livros -> 400 POST /livros -> 422 PUT /livros/1 -> 200 PUT /livros/1 -> 200 PUT /livros/1 -> 400 PUT /livros/404 -> 404 PATCH /livros/1 -> 200 PATCH /livros/1 -> 400 PATCH /livros/404 -> 404 POST /emprestimos -> 201 POST /emprestimos -> 409 POST /emprestimos -> 404 DELETE /livros/1 -> 204 DELETE /livros/1 -> 404

Dezenove chamadas, e nenhuma linha da tabela ficou por conta da memória. Repare nos dois pares do começo: o mesmo POST duas vezes dá 201 e depois 409; o mesmo PUT duas vezes dá 200 e 200. É a idempotência aparecendo no terminal.

No front, 404 não cai no catch

Uma última consequência que pega muita gente. O fetch só rejeita a promessa quando a rede falha. Status de erro chega como resposta normal, e você precisa olhar o ok ou o status na mão:

js
let caiuNoCatch = false;
let resposta;

try {
  resposta = await fetch('http://localhost:5599/livros/404');
} catch (erro) {
  caiuNoCatch = true;
}

console.log('caiu no catch?', caiuNoCatch);
console.log('status:', resposta.status);
console.log('ok:', resposta.ok);
console.log('corpo:', await resposta.json());
caiu no catch? false status: 404 ok: false corpo: { erro: 'Livro não encontrado', id: 404 }

O try/catch sozinho não protege ninguém de um 404. É por isso que o status importa tanto: ele é a única parte da resposta que o cliente consegue interpretar sem entender o seu JSON. Esse tratamento no navegador está detalhado em como consumir uma API com a Fetch API.

O que vem depois

Agora que cada rota devolve o status certo, o próximo passo é parar de montar tudo na mão: Express do zero mostra como subir o servidor, registrar as rotas e ler o corpo com muito menos código. O mapa completo até o deploy está no guia de Node.js e a ordem de estudo, na trilha de Node.

Prefere aprender em vídeo?

Tem uma aula sobre este assunto no nosso canal.

Ver todos os vídeos do canal
  • http
  • status code
  • metodos http
  • api
  • express

Perguntas frequentes

Posso devolver 200 em tudo e sinalizar o erro dentro do JSON?
Pode, mas você quebra todo mundo que depende do protocolo: o cache guarda a resposta de erro, o monitoramento marca a rota como saudável, a retentativa automática não dispara e o response.ok do fetch dá true. O status é a parte da resposta que as ferramentas leem sem conhecer a sua API.
Qual status devolvo quando o recurso não existe no DELETE?
As duas escolhas se defendem. 404 conta a verdade do momento e ajuda quem está depurando; 204 sempre torna o DELETE completamente idempotente do ponto de vista do cliente. Escolha uma, documente, e não misture as duas na mesma API.
Preciso do header Location no 201?
Não é obrigatório, mas é o que transforma o 201 numa resposta útil. Com o Location apontando para o recurso criado, o cliente sabe para onde redirecionar sem adivinhar como você monta a URL do item.
Quando uso 405 em vez de 404?
405 Method Not Allowed é para quando o caminho existe mas o verbo não. DELETE /livros numa API que só aceita GET e POST na coleção é 405, e a resposta precisa trazer o header Allow com os verbos aceitos. 404 é para o caminho que não existe de jeito nenhum.
502, 503 e 504 são a mesma coisa?
Não. 502 é o proxy recebendo lixo de quem está atrás dele, 503 é o serviço indisponível de propósito (deploy, manutenção, sobrecarga) e 504 é o proxy cansando de esperar. Só o 503 costuma sair da sua aplicação; os outros dois quase sempre vêm do Nginx ou do balanceador.

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 com Express 5.2.1, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — HTTP request methods — developer.mozilla.org
  2. MDN — HTTP response status codes — developer.mozilla.org
  3. RFC 9110 — HTTP Semantics — rfc-editor.org
  4. Express 5 — API Reference: res.status() — expressjs.com

Continue por aqui