trilha

Fundamentos de Programação/03 - Sistemas Reais/Semana 10 - Rede e HTTP7 min

Status Codes

Perguntas-guia
  • O que cada família (2xx, 3xx, 4xx, 5xx) comunica sobre de quem é a culpa?
  • 401 vs 403: qual é "não sei quem você é" e qual é "sei e você não pode"?
  • 400 vs 422: quando o corpo é inválido e quando a requisição é malformada?
  • Por que devolver 200 com {"error": ...} no corpo quebra clientes e caches?

Conceito

O modelo mental mais útil: a família diz de quem é a culpa.

Família Significa Culpa
2xx deu certo
3xx procure em outro lugar
4xx você mandou algo errado do cliente
5xx eu falhei processando do servidor

Essa divisão não é estética: monitoramento, alarme e lógica de retry se apoiam nela. 4xx em massa é um cliente quebrado; 5xx em massa acorda alguém de madrugada. Trocar um pelo outro faz o alarme apontar para o time errado.

401 vs 403 — a distinção mais errada de todas:

Significa O cliente deve
401 Unauthorized "não sei quem você é" — credencial ausente, inválida ou expirada autenticar (ou renovar o token)
403 Forbidden "sei quem você é, e você não pode" nada — tentar de novo não ajuda

O nome do 401 é historicamente infeliz: ele significa unauthenticated. A regra prática: se autenticar de novo pode resolver, é 401. Se não pode, é 403.

400 vs 422:

Quando
400 Bad Request não consegui entender — JSON malformado, parâmetro do tipo errado
422 Unprocessable entendi perfeitamente e é inválido — CPF com dígito errado, data no passado

O 400 é sobre sintaxe; o 422 é sobre semântica. Um cliente que recebe 400 tem um bug; um que recebe 422 recebeu dado ruim do usuário. São ações diferentes.

Por que 200 com {"error": ...} no corpo quebra tudo

É a decisão que parece conveniente e destrói um sinal legível por máquina:

  • caches guardam o erro como se fosse resposta válida
  • lógica de retry não repete (200 é sucesso)
  • monitoramento mostra 100% de sucesso enquanto tudo falha
  • balanceadores não removem a instância doente
  • todo cliente é obrigado a parsear o corpo para saber se deu certo

Você tinha um sinal de um byte que qualquer intermediário entende, e trocou por um contrato que só o seu cliente conhece.

Os outros que valem conhecer:

Código Uso
201 Created + Location criou; o header aponta para o recurso
202 Accepted aceitei, vou processar depois (assíncrono)
204 No Content deu certo, não há corpo (típico de DELETE)
304 Not Modified seu cache está válido; não mando o corpo
409 Conflict conflito de estado (duplicata, versão desatualizada)
410 Gone existia e não existe mais — permanente, ao contrário do 404
429 Too Many Requests + Retry-After você excedeu o limite; volte em N segundos
503 Service Unavailable + Retry-After indisponível temporariamente

429 e 503 sem Retry-After transferem para o cliente a decisão de quando voltar — e ele vai escolher errado.

Em Go

As constantes existem e devem ser usadas: http.StatusNotFound em vez de 404. Erro de digitação em número passa pelo compilador; nome errado não.

A armadilha nº 1 de net/http: escrever o corpo envia 200
fmt.Fprint(w, "erro")                     // <- isto JÁ ENVIOU 200
w.WriteHeader(http.StatusBadRequest)      // ignorado + aviso no log

A primeira escrita no ResponseWriter envia a linha de status e os headers. Depois disso, WriteHeader é ignorado e o Go registra http: superfluous response.WriteHeader call.

A ordem obrigatória é sempre: headers → WriteHeader → corpo.

E o mesmo vale para w.Header().Set(...): depois da primeira escrita, não tem efeito (6. Headers HTTP).

http.Error(w, msg, código) faz o certo na ordem certa: define Content-Type: text/plain, chama WriteHeader e escreve a mensagem. Para API JSON você precisa do seu próprio, porque http.Error manda texto puro.

Um formato de erro, documentado, para toda a API — é um requisito do Semana 11-12 - Projeto Integrador:

type ErroAPI struct {
	Codigo  string `json:"codigo"`         // legível por máquina: "cpf_invalido"
	Mensagem string `json:"mensagem"`      // legível por humano
	Campo   string `json:"campo,omitempty"`
}

func responderErro(w http.ResponseWriter, status int, e ErroAPI) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)               // ANTES do corpo
	json.NewEncoder(w).Encode(e)
}

Mapear erro de domínio para status num lugar só, e não em cada handler:

func status(err error) int {
	switch {
	case errors.Is(err, ErrNaoEncontrado):  return http.StatusNotFound
	case errors.Is(err, ErrJaExiste):       return http.StatusConflict
	case errors.Is(err, ErrInvalido):       return http.StatusUnprocessableEntity
	case errors.Is(err, ErrSemPermissao):   return http.StatusForbidden
	default:                                return http.StatusInternalServerError
	}
}

Isso mantém o domínio livre de HTTP (3. Dependency Inversion Principle) e o mapeamento auditável.

E sql.ErrNoRows merece atenção especial: ele significa "não encontrado", que é 404 — não 500. Tratá-lo como erro genérico é a causa mais comum de 500 onde deveria haver 404 (2. Agregações).

Respostas às perguntas-guia

1. O que cada família (2xx, 3xx, 4xx, 5xx) comunica sobre de quem é a culpa?

2xx sucesso, 3xx redirecionamento, 4xx culpa do cliente, 5xx culpa do servidor. Monitoramento e retry dependem dessa divisão.

2. 401 vs 403: qual é "não sei quem você é" e qual é "sei e você não pode"?

401 = não autenticado (autenticar pode resolver). 403 = autenticado e sem permissão (não resolve). O nome do 401 é enganoso.

3. 400 vs 422: quando o corpo é inválido e quando a requisição é malformada?

400 = não consegui parsear (sintaxe). 422 = parseei e o conteúdo é inválido (semântica).

Em Go: erro de json.Decoder → 400. Erro da sua validação de domínio → 422.

4. Por que devolver 200 com {"error": ...} no corpo quebra clientes e caches?

Porque destrói o sinal que intermediários entendem: cache guarda o erro, retry não repete, monitoramento mostra sucesso, e todo cliente precisa parsear o corpo para saber o resultado.

Trade-offs

Do conceito:

  • Usar códigos precisos compra comportamento correto de intermediários e cobra disciplina.
  • 404 em vez de 403 para recurso alheio compra segurança (não revela existência) e cobra diagnóstico confuso para o usuário legítimo.
  • 422 é preciso e não é padrão HTTP puro (vem do WebDAV) — alguns clientes o tratam como desconhecido.

Em Go:

  • http.Error compra corretude de ordem e cobra o text/plain.
  • Mapeamento centralizado de erro→status compra consistência e cobra um lugar a mais para manter.
  • A ordem obrigatória headers→status→corpo é uma pegadinha real, e o aviso de superfluous WriteHeader é a única pista.

Exemplo prático

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

var (
	ErrNaoEncontrado = errors.New("não encontrado")
	ErrJaExiste      = errors.New("já existe")
	ErrInvalido      = errors.New("dado inválido")
	ErrSemPermissao  = errors.New("sem permissão")
)

type ErroAPI struct {
	Codigo   string `json:"codigo"`
	Mensagem string `json:"mensagem"`
	Campo    string `json:"campo,omitempty"`
}

func statusDe(err error) int {
	switch {
	case errors.Is(err, ErrNaoEncontrado): return http.StatusNotFound
	case errors.Is(err, ErrJaExiste):      return http.StatusConflict
	case errors.Is(err, ErrInvalido):      return http.StatusUnprocessableEntity
	case errors.Is(err, ErrSemPermissao):  return http.StatusForbidden
	default:                               return http.StatusInternalServerError
	}
}

func responderErro(w http.ResponseWriter, status int, e ErroAPI) {
	w.Header().Set("Content-Type", "application/json") // 1. headers
	w.WriteHeader(status)                              // 2. status
	json.NewEncoder(w).Encode(e)                       // 3. corpo
}

func handler(w http.ResponseWriter, r *http.Request) {
	token := r.Header.Get("Authorization")
	if token == "" {
		w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
		responderErro(w, http.StatusUnauthorized,
			ErroAPI{"nao_autenticado", "credencial ausente", ""})
		return // 401: autenticar PODE resolver
	}
	if token != "Bearer admin" {
		responderErro(w, http.StatusForbidden,
			ErroAPI{"sem_permissao", "seu papel não permite esta operação", ""})
		return // 403: autenticar de novo NÃO resolve
	}

	var corpo struct {
		CPF   string `json:"cpf"`
		Idade int    `json:"idade"`
	}
	if err := json.NewDecoder(r.Body).Decode(&corpo); err != nil {
		responderErro(w, http.StatusBadRequest,
			ErroAPI{"json_invalido", "não consegui interpretar o corpo", ""})
		return // 400: SINTAXE
	}
	if len(corpo.CPF) != 11 {
		responderErro(w, http.StatusUnprocessableEntity,
			ErroAPI{"cpf_invalido", "cpf precisa de 11 dígitos", "cpf"})
		return // 422: entendi, e é inválido
	}
	if corpo.Idade < 18 {
		responderErro(w, http.StatusUnprocessableEntity,
			ErroAPI{"menor_de_idade", "idade mínima 18", "idade"})
		return
	}
	w.Header().Set("Location", "/clientes/9")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]any{"id": 9})
}

// A ARMADILHA: escrever o corpo antes do status
func handlerErrado(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "algo deu errado")        // JÁ ENVIOU 200
	w.WriteHeader(http.StatusBadRequest)     // ignorado + log de superfluous
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /clientes", handler)
	mux.HandleFunc("GET /errado", handlerErrado)
	srv := httptest.NewServer(mux)
	defer srv.Close()

	casos := []struct{ nome, metodo, path, corpo, auth string }{
		{"sem credencial", "POST", "/clientes", `{}`, ""},
		{"credencial insuficiente", "POST", "/clientes", `{}`, "Bearer user"},
		{"json malformado", "POST", "/clientes", `{cpf`, "Bearer admin"},
		{"cpf inválido", "POST", "/clientes", `{"cpf":"123","idade":30}`, "Bearer admin"},
		{"menor de idade", "POST", "/clientes", `{"cpf":"12345678901","idade":15}`, "Bearer admin"},
		{"tudo certo", "POST", "/clientes", `{"cpf":"12345678901","idade":30}`, "Bearer admin"},
	}
	for _, c := range casos {
		req, _ := http.NewRequest(c.metodo, srv.URL+c.path, strings.NewReader(c.corpo))
		if c.auth != "" { req.Header.Set("Authorization", c.auth) }
		resp, _ := http.DefaultClient.Do(req)
		b := make([]byte, 200); n, _ := resp.Body.Read(b); resp.Body.Close()
		fmt.Printf("%-26s -> %d %-8s %s\n", c.nome, resp.StatusCode,
			resp.Header.Get("Location"), strings.TrimSpace(string(b[:n])))
	}

	fmt.Println("\n--- a armadilha: corpo antes do status ---")
	r, _ := http.Get(srv.URL + "/errado")
	b := make([]byte, 60); n, _ := r.Body.Read(b); r.Body.Close()
	fmt.Printf("esperava 400, veio %d (%q)\n", r.StatusCode, strings.TrimSpace(string(b[:n])))
}

A última linha é a demonstração: o handler pediu 400 e o cliente recebeu 200, porque o corpo foi escrito antes.

Relacionado


Parte de Semana 10 - Rede e HTTP · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas