Prueba una API REST en Go con httptest, un store falso, verificaciones sobre los logs, el detector de carreras y fuzzing. Después despliégala con timeouts en el servidor, un apagado ordenado y un solo binario estático.
Una API que pasa unas cuantas peticiones manuales con curl no está terminada. Necesitas pruebas que revisen cada código de estado y cada camino de error, y un servidor que aguante clientes lentos, se detenga sin perder peticiones y se distribuya como algo que puedes copiar a una máquina y ejecutar.
Este post hace las dos cosas con la API de lista de tareas que construimos en la parte sobre APIs REST con JSON. Agrega archivos de prueba reales a ese módulo, y luego cubre los timeouts, el apagado ordenado y la compilación de un solo binario. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.
La API que vamos a probar
La API de tareas es un módulo de Go con un paquete task para el almacenamiento, un paquete api para HTTP y un comando tasksd que ejecuta el servidor. Sus cinco rutas responden con códigos de estado del 200 al 500, y cada error es un JSON con la misma forma. Este post agrega cuatro archivos de prueba al módulo:
19-tasks-api/
├── go.mod
├── cmd/
│ └── tasksd/
│ ├── main.go
│ └── main_test.go
└── internal/
├── task/
│ ├── task.go
│ └── memstore.go
└── api/
├── api.go
├── api_test.go
├── handlers.go
├── json.go
├── json_test.go
├── validate.go
├── middleware.go
└── middleware_test.go
Cada archivo de prueba usa el mismo nombre de paquete que el código que tiene al lado, así que las pruebas pueden llegar a nombres no exportados como decodeJSON. La parte sobre paquetes y pruebas cubrió go test, las tablas de pruebas y t.Helper, así que este post los usa sin volver a explicarlos.
httptest.NewRecorder o httptest.NewServer
El paquete net/http/httptest te da dos formas de probar un handler, y cada una prueba cosas distintas. httptest.NewRecorder devuelve un ResponseWriter que guarda el estado, los headers y el cuerpo. Tú mismo llamas a ServeHTTP, sin red. httptest.NewServer inicia un servidor real en un puerto de loopback libre, y le hablas con un cliente real.
La mayoría de las pruebas de la API usan el recorder, a través de dos helpers pequeños:
// newTestAPI returns the API over a store that already holds one task,
// "Buy milk", with ID 1. Its logs are thrown away.
func newTestAPI(t *testing.T) http.Handler {
t.Helper()
store := task.NewMemStore()
if _, err := store.Create(t.Context(), task.Task{Title: "Buy milk"}); err != nil {
t.Fatalf("seeding the store: %v", err)
}
return New(store, slog.New(slog.DiscardHandler))
}
// do sends one request straight to h, with no network, and returns
// the recorded response. A non-empty body is sent as JSON.
func do(h http.Handler, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
newTestAPI arma la API completa, con el middleware incluido, sobre un store que ya tiene una tarea. slog.DiscardHandler descarta los logs. t.Context() devuelve un contexto que se cancela justo antes de que se ejecuten las funciones de limpieza de la prueba, así que una llamada al store dentro de una prueba lo recibe, igual que la llamada al store de un handler recibe r.Context(). Los dos llegaron en Go 1.24.
Usa el recorder para probar lo que decide un handler: estado, headers y cuerpo. Es rápido, y un fallo apunta directo a tu código. Usa un servidor real cuando la red forma parte de la prueba: un cliente, el manejo de conexiones, los timeouts o el apagado.
Una tabla sobre cada endpoint
Una tabla de pruebas encaja bien con una API, porque cada fila es una petición y la respuesta que esperas. Esta cubre cada ruta, incluidos los errores:
func TestEndpoints(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantHeader map[string]string
wantBody string
}{
{"list", "GET", "/tasks", "", 200, nil,
`[{"id":1,"title":"Buy milk","done":false}]`},
{"get", "GET", "/tasks/1", "", 200, nil,
`{"id":1,"title":"Buy milk","done":false}`},
{"get missing", "GET", "/tasks/99", "", 404, nil,
`{"error":"task not found"}`},
{"get bad id", "GET", "/tasks/abc", "", 404, nil,
`{"error":"task not found"}`},
{"create", "POST", "/tasks", `{"title":"Walk the dog"}`, 201,
map[string]string{"Location": "/tasks/2"},
`{"id":2,"title":"Walk the dog","done":false}`},
{"create blank title", "POST", "/tasks", `{"title":" "}`, 422, nil,
`{"error":"validation failed","fields":{"title":"must not be empty"}}`},
{"create unknown field", "POST", "/tasks", `{"title":"a","id":7}`, 400, nil,
`{"error":"unknown field \"id\""}`},
{"create too big", "POST", "/tasks", `{"title":"` + strings.Repeat("a", maxBodyBytes) + `"}`, 413, nil,
`{"error":"request body must not be larger than 1048576 bytes"}`},
{"update", "PUT", "/tasks/1", `{"title":"Buy milk","done":true}`, 200, nil,
`{"id":1,"title":"Buy milk","done":true}`},
{"update missing", "PUT", "/tasks/99", `{"title":"x"}`, 404, nil,
`{"error":"task not found"}`},
{"delete", "DELETE", "/tasks/1", "", 204, nil, ``},
{"delete missing", "DELETE", "/tasks/99", "", 404, nil,
`{"error":"task not found"}`},
{"wrong method", "PATCH", "/tasks/1", "", 405,
map[string]string{"Allow": "DELETE, GET, HEAD, PUT"},
`{"error":"method not allowed"}`},
{"unknown path", "GET", "/users", "", 404, nil,
`{"error":"not found"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := do(newTestAPI(t), tt.method, tt.path, tt.body)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
for name, want := range tt.wantHeader {
if got := rec.Header().Get(name); got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
if got := strings.TrimSuffix(rec.Body.String(), "\n"); got != tt.wantBody {
t.Errorf("body = %s, want %s", got, tt.wantBody)
}
if rec.Code != http.StatusNoContent {
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
}
})
}
}
Cada fila recibe una API nueva, así que la fila delete no puede romper la fila get. Comparar el cuerpo completo también revisa la forma del error: fields aparece solo en el 422, y el 405 es JSON con un header Allow.
Con -v, cada fila aparece como un subtest con nombre. Quité las duraciones de las líneas --- PASS, porque cambian en cada ejecución:
$ go test -v -run TestEndpoints ./internal/api
=== RUN TestEndpoints
=== RUN TestEndpoints/list
=== RUN TestEndpoints/get
=== RUN TestEndpoints/get_missing
=== RUN TestEndpoints/get_bad_id
=== RUN TestEndpoints/create
=== RUN TestEndpoints/create_blank_title
=== RUN TestEndpoints/create_unknown_field
=== RUN TestEndpoints/create_too_big
=== RUN TestEndpoints/update
=== RUN TestEndpoints/update_missing
=== RUN TestEndpoints/delete
=== RUN TestEndpoints/delete_missing
=== RUN TestEndpoints/wrong_method
=== RUN TestEndpoints/unknown_path
--- PASS: TestEndpoints
--- PASS: TestEndpoints/list
--- PASS: TestEndpoints/get
--- PASS: TestEndpoints/get_missing
--- PASS: TestEndpoints/get_bad_id
--- PASS: TestEndpoints/create
--- PASS: TestEndpoints/create_blank_title
--- PASS: TestEndpoints/create_unknown_field
--- PASS: TestEndpoints/create_too_big
--- PASS: TestEndpoints/update
--- PASS: TestEndpoints/update_missing
--- PASS: TestEndpoints/delete
--- PASS: TestEndpoints/delete_missing
--- PASS: TestEndpoints/wrong_method
--- PASS: TestEndpoints/unknown_path
PASS
Después viene una línea final ok, con una duración que varía. El caso 415 está en una prueba corta aparte, porque necesita otro Content-Type.
Un servidor real, con t.Cleanup y t.Context
Una prueba del paquete de la API corre sobre una conexión real, para comprobar que un cliente puede seguir el header Location después de crear una tarea. Dos helpers la preparan:
// startServer runs h on a real loopback port until the test ends.
func startServer(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv
}
// send makes a real HTTP request and returns the response with its body read.
func send(t *testing.T, method, url, body string) (*http.Response, string) {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body))
if err != nil {
t.Fatalf("building request: %v", err)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("reading body: %v", err)
}
return resp, strings.TrimSuffix(string(b), "\n")
}
startServer no puede usar defer srv.Close(), porque eso cerraría el servidor apenas retorne el helper. t.Cleanup(srv.Close) lo ejecuta cuando termina la prueba. send arma cada petición con t.Context(), así que una petición que siga esperando cuando termina la prueba se cancela.
func TestCreateThenFollowLocation(t *testing.T) {
srv := startServer(t, New(task.NewMemStore(), slog.New(slog.DiscardHandler)))
if _, body := send(t, "GET", srv.URL+"/tasks", ""); body != "[]" {
t.Fatalf("empty list = %s, want []", body)
}
resp, _ := send(t, "POST", srv.URL+"/tasks", `{"title":"Buy milk"}`)
loc := resp.Header.Get("Location")
if resp.StatusCode != http.StatusCreated || loc == "" {
t.Fatalf("create: status %d, Location %q", resp.StatusCode, loc)
}
resp, body := send(t, "GET", srv.URL+loc, "")
if resp.StatusCode != http.StatusOK || body != `{"id":1,"title":"Buy milk","done":false}` {
t.Errorf("GET %s = %d %s", loc, resp.StatusCode, body)
}
}
La primera petición comprueba que una lista vacía sea [] y no null. El resto lee la URL de la tarea nueva desde la respuesta, como lo haría un cliente.
Un store falso para el camino del 500
El camino del 500 es el más difícil de probar, porque MemStore nunca falla. Pero Store es una interfaz, así que una prueba puede pasarle a New cualquier cosa que tenga los cinco métodos. Este store falso falla de la forma que elija la prueba:
// fakeStore is a Store whose every method calls fail. The test decides
// what fail does: return an error, or panic.
type fakeStore struct {
fail func() error
}
func (f fakeStore) List(context.Context) ([]task.Task, error) { return nil, f.fail() }
func (f fakeStore) Get(context.Context, int64) (task.Task, error) { return task.Task{}, f.fail() }
func (f fakeStore) Create(context.Context, task.Task) (task.Task, error) {
return task.Task{}, f.fail()
}
func (f fakeStore) Update(context.Context, task.Task) (task.Task, error) {
return task.Task{}, f.fail()
}
func (f fakeStore) Delete(context.Context, int64) error { return f.fail() }
Una prueba hace que fail devuelva un error, y otra hace que entre en panic. Los handlers no pueden distinguirlo de un store real, y para eso sirve poner el almacenamiento detrás de una interfaz.
Un 500 tiene dos mitades que importan. El cliente no debe enterarse de nada sobre la causa, y el log debe registrarla completa. Para revisar el log, la prueba le da a la API un logger que escribe en un bytes.Buffer:
// newTestLogger returns a logger that writes JSON lines into buf. It drops
// the attributes that change on every run: time, duration and stack.
func newTestLogger(buf *bytes.Buffer) *slog.Logger {
opts := &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case slog.TimeKey, "duration", "stack":
return slog.Attr{}
}
return a
},
}
return slog.New(slog.NewJSONHandler(buf, opts))
}
La hora, la duración de la petición y el stack trace de un panic cambian en cada ejecución, así que ReplaceAttr los descarta, y lo que queda se puede comparar de forma exacta. Esta es la prueba, con su helper:
// assertLog fails the test unless buf holds exactly the want lines.
func assertLog(t *testing.T, buf *bytes.Buffer, want ...string) {
t.Helper()
got := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n")
if len(got) != len(want) {
t.Fatalf("got %d log lines, want %d:\n%s", len(got), len(want), buf)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("log line %d:\n got %s\nwant %s", i+1, got[i], want[i])
}
}
}
func TestStoreErrorIs500(t *testing.T) {
var logs bytes.Buffer
store := fakeStore{fail: func() error {
return errors.New("dial tcp db.internal:5432: connection refused")
}}
h := New(store, newTestLogger(&logs))
rec := do(h, "GET", "/tasks/1", "")
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
if body := rec.Body.String(); body != `{"error":"internal server error"}`+"\n" {
t.Errorf("body = %q leaks more than it should", body)
}
assertLog(t, &logs,
`{"level":"ERROR","msg":"internal error","err":"dial tcp db.internal:5432: connection refused","request_id":"req-1"}`,
`{"level":"INFO","msg":"request","method":"GET","path":"/tasks/1","status":500,"request_id":"req-1"}`,
)
}
El error nombra un host y un puerto de base de datos, algo que a un atacante le gustaría ver. El cuerpo es solo {"error":"internal server error"}. El log tiene el error real y luego la línea de la petición con estado 500, y las dos llevan req-1, que conecta el reporte de bug de un usuario con la causa.
Probar la recuperación de panics y los IDs de petición
La recuperación de panics también se prueba con fakeStore, esta vez con un fail que entra en panic:
func TestPanicIs500(t *testing.T) {
var logs bytes.Buffer
store := fakeStore{fail: func() error { panic("store exploded") }}
h := New(store, newTestLogger(&logs))
rec := do(h, "DELETE", "/tasks/1", "")
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
if body := rec.Body.String(); strings.Contains(body, "exploded") {
t.Errorf("body = %q leaks the panic value", body)
}
if id := rec.Header().Get("X-Request-Id"); id != "req-1" {
t.Errorf("X-Request-Id = %q, want req-1", id)
}
assertLog(t, &logs,
`{"level":"ERROR","msg":"panic","value":"store exploded","request_id":"req-1"}`,
`{"level":"INFO","msg":"request","method":"DELETE","path":"/tasks/1","status":500,"request_id":"req-1"}`,
)
}
El panic empieza dentro del store y atraviesa la cadena real de middleware que arma New. Si alguien reordena New para que logRequests quede dentro de recoverPanics, la línea de la petición desaparece del log y esta prueba falla.
Un panic después de enviar el estado es otra historia:
func TestPanicAfterWriteHeader(t *testing.T) {
var logs bytes.Buffer
s := &server{logger: newTestLogger(&logs)}
h := s.recoverPanics(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "[")
panic("encoder broke halfway")
}))
rec := do(h, "GET", "/tasks", "")
// The 200 is already on its way, so the recovery can't turn it into a 500.
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
if want := `[{"error":"internal server error"}` + "\n"; rec.Body.String() != want {
t.Errorf("body = %q, want %q", rec.Body.String(), want)
}
}
Esta prueba llama a recoverPanics directamente, con un handler que envía un 200 y un byte, y luego entra en panic. La recuperación igual llama a writeError, pero WriteHeader(500) llega tarde, así que el estado sigue siendo 200. El JSON del error se agrega a lo que ya se había enviado, y el cliente recibe [{"error":"internal server error"}, que no es JSON válido. Por eso writeJSON serializa el cuerpo completo antes de escribir un estado. La prueba deja fijo este comportamiento, para que nadie crea que la recuperación lo cubre.
TestRequestIDs, en el mismo archivo, envía tres peticiones y comprueba que reciben req-1, req-2 y req-3, y luego comprueba que un segundo New vuelve a empezar en req-1.
El detector de carreras sobre peticiones concurrentes
El detector de carreras vigila la memoria mientras corren las pruebas y reporta cuando dos goroutines tocan la misma variable sin un lock. Solo ve código que de verdad se ejecuta al mismo tiempo, así que la prueba tiene que provocarlo:
func TestConcurrentCreates(t *testing.T) {
h := New(task.NewMemStore(), slog.New(slog.DiscardHandler))
const n = 50
var wg sync.WaitGroup
for range n {
wg.Go(func() {
rec := do(h, "POST", "/tasks", `{"title":"Buy milk"}`)
if rec.Code != http.StatusCreated {
t.Errorf("status = %d, want 201", rec.Code)
}
})
}
wg.Wait()
var tasks []task.Task
rec := do(h, "GET", "/tasks", "")
if err := json.NewDecoder(rec.Body).Decode(&tasks); err != nil {
t.Fatalf("decoding list: %v", err)
}
if len(tasks) != n {
t.Fatalf("got %d tasks, want %d", len(tasks), n)
}
for i, tk := range tasks {
if tk.ID != int64(i+1) {
t.Errorf("tasks[%d].ID = %d, want %d", i, tk.ID, i+1)
}
}
}
Cincuenta goroutines envían POST /tasks a un mismo handler a la vez, con sync.WaitGroup.Go, de Go 1.25. Luego la prueba comprueba que haya exactamente cincuenta tareas con IDs del 1 al 50. Es seguro llamar a t.Errorf desde esas goroutines, pero t.Fatalf tiene que ejecutarse en la goroutine de la propia prueba.
Para ver que la prueba hace su trabajo, borré las dos líneas que bloquean el mutex en MemStore.Create y la ejecuté con -race. Estas son las líneas del reporte que no cambian entre ejecuciones, con la duración quitada de la línea --- FAIL:
$ go test -race -run TestConcurrentCreates ./internal/api
==================
WARNING: DATA RACE
...
example.com/tasks/internal/task.(*MemStore).Create()
...
--- FAIL: TestConcurrentCreates
testing.go:1712: race detected during execution of test
FAIL
El detector de carreras lo marcó en 20 ejecuciones de 20. Sin -race, 19 ejecuciones se cayeron con fatal error: concurrent map writes y una pasó. Esa única ejecución que pasó es la razón para correr go test -race ./... antes de cada release: una carrera que hoy no hace caer el programa sigue siendo un bug.
Fuzzing del decodificador
Una prueba de fuzzing le pasa a una función miles de entradas generadas y comprueba una propiedad que tiene que cumplirse para todas. decodeJSON lee bytes que mandan desconocidos, así que es el objetivo natural:
// FuzzDecodeJSON feeds decodeJSON random bodies. Whatever arrives, it must
// not panic, and every error must be a *requestError with a 4xx status.
func FuzzDecodeJSON(f *testing.F) {
f.Add(`{"title":"Buy milk","done":true}`)
f.Add(`{"title":`)
f.Add(`["Buy milk"]`)
f.Add(`{"title":"a"}{"title":"b"}`)
f.Fuzz(func(t *testing.T, body string) {
req := httptest.NewRequest("POST", "/tasks", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
var in taskInput
err := decodeJSON(httptest.NewRecorder(), req, &in)
if err == nil {
return
}
var re *requestError
if !errors.As(err, &re) {
t.Fatalf("decodeJSON(%q) returned %T, want *requestError", body, err)
}
if re.status != http.StatusBadRequest && re.status != http.StatusRequestEntityTooLarge {
t.Fatalf("decodeJSON(%q) status = %d, want 400 or 413", body, re.status)
}
})
}
Sea cual sea el cuerpo, decodeJSON no debe entrar en panic, y cualquier error debe ser un *requestError con un 400 o un 413. Las llamadas a f.Add son semillas que el fuzzer va mutando. Un go test normal ejecuta solo las semillas. Para generar entradas nuevas, pasa -fuzz con un límite de tiempo:
$ go test -fuzz=FuzzDecodeJSON -fuzztime=10s ./internal/api
fuzz: elapsed: 0s, gathering baseline coverage: 0/4 completed
...
PASS
Las líneas de progreso intermedias dependen de tu máquina. Una entrada que falle se guardaría en testdata/fuzz/FuzzDecodeJSON/, y desde ese momento un go test normal la ejecutaría como semilla. -fuzz acepta un solo paquete a la vez, así que rechaza ./....
De qué protege cada timeout del servidor
Un http.Server sin timeouts espera a un cliente lento para siempre, y cada cliente que espera ocupa una conexión y una goroutine. El servidor de cmd/tasksd configura cuatro:
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
}
En pocas palabras, cada uno limita un tipo distinto de espera:
ReadHeaderTimeoutfrena a un cliente que envía los headers de su petición muy despacio, o que nunca los termina. Ese es el ataque Slowloris: abrir miles de conexiones, mandar unos pocos bytes por cada una de vez en cuando, y el servidor se queda sin conexiones sin haber visto nunca una petición completa.ReadTimeoutlimita la lectura de la petición entera, cuerpo incluido, así que un cliente tampoco puede mandar un cuerpo gota a gota para siempre.WriteTimeoutlimita cuánto puede tardar la respuesta, contando desde el final de los headers de la petición. Frena a un cliente que lee la respuesta demasiado despacio, y pone un tope a la petición completa.IdleTimeoutcierra una conexión keep-alive que quedó sin usar entre peticiones.
Este programa envía medio header de petición y luego espera, una vez contra un servidor sin ReadHeaderTimeout y otra contra uno con 50ms. El cliente espera 2 segundos, un margen amplio:
package main
import (
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"time"
)
// slowClient connects, sends half a request header, and then waits up to
// 2 seconds for the server to do anything.
func slowClient(timeout time.Duration) {
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
})
srv := httptest.NewUnstartedServer(h)
srv.Config.ReadHeaderTimeout = timeout
srv.Start()
defer srv.Close()
conn, err := net.Dial("tcp", srv.Listener.Addr().String())
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: example.com\r\n") // no blank line: the header never ends
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, err = conn.Read(make([]byte, 1))
switch {
case errors.Is(err, os.ErrDeadlineExceeded):
fmt.Printf("ReadHeaderTimeout %v: after 2s the server is still holding the connection open\n", timeout)
default:
fmt.Printf("ReadHeaderTimeout %v: the server hung up: %v\n", timeout, err)
}
}
func main() {
slowClient(0)
slowClient(50 * time.Millisecond)
}
Imprime:
ReadHeaderTimeout 0s: after 2s the server is still holding the connection open
ReadHeaderTimeout 50ms: the server hung up: EOF
Con el timeout, el servidor cerró la conexión sin enviar nada, ni siquiera un 408, y recuperó su conexión.
WriteTimeout me sorprendió. Esperaba que frenara a un handler lento, y no lo hace:
package main
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
func main() {
handlerDone := make(chan string, 1)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond) // slow work, 10 times the WriteTimeout
_, err := fmt.Fprintln(w, "report ready")
handlerDone <- fmt.Sprintf("handler: finished, write error = %v", err)
})
srv := httptest.NewUnstartedServer(h)
srv.Config.WriteTimeout = 50 * time.Millisecond
srv.Start()
defer srv.Close()
_, err := http.Get(srv.URL)
fmt.Println("client got EOF:", errors.Is(err, io.EOF))
fmt.Println(<-handlerDone)
}
Imprime:
client got EOF: true
handler: finished, write error = <nil>
El handler corrió sus 500ms completos, y su escritura no reportó ningún error, porque los bytes solo fueron al buffer del servidor. El timeout actuó cuando el servidor intentó enviarlos, y el cliente recibió una conexión cerrada en lugar de una respuesta. WriteTimeout protege la conexión, no el tiempo de tu handler. Para abandonar un trabajo lento, pásale r.Context(), o envuelve el handler en http.TimeoutHandler, que responde 503 cuando se acaba el tiempo.
Apagado ordenado
Detener un servidor matando el proceso corta cada petición a la mitad. Un apagado ordenado (graceful shutdown) deja de aceptar conexiones nuevas, deja que terminen las peticiones que ya están corriendo, y solo entonces sale. Esta es la segunda mitad de serve en cmd/tasksd:
errc := make(chan error, 1)
go func() {
logger.Info("listening", "addr", ln.Addr().String())
errc <- srv.Serve(ln)
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
}
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
La secuencia tiene cinco pasos:
srv.Serve(ln)se bloquea hasta que el servidor se detiene, así que corre en una goroutine.errctiene lugar para un valor, así que esa goroutine siempre puede enviar su resultado y terminar.- El
selectespera lo que pase primero: queServefalle, o que se cancelectx, algo quesignal.NotifyContextenrunhace con Ctrl+C o conSIGTERM. srv.Shutdowncierra el listener, así que las conexiones nuevas se rechazan, cierra las conexiones inactivas y espera a que las activas terminen sus peticiones.- La espera recibe un contexto nuevo de 10 segundos a partir de
context.Background(), porquectxya está cancelado y terminaría la espera de inmediato. Si todavía hay peticiones corriendo al llegar el plazo,Shutdowndevuelvecontext.DeadlineExceeded. - Después de
Shutdown,Servedevuelvehttp.ErrServerClosed. Esa es la forma normal de detenerse, así queservedevuelve nil en ese caso.
Mantén esos 10 segundos por debajo de lo que tu plataforma espera entre SIGTERM y matar el proceso. Kubernetes espera 30 segundos por defecto, pero docker stop espera solo 10.
Explicado como si tuvieras diez años
Piensa en una tienda a la hora de cerrar. La dueña no saca a todos a empujones a la calle. Primero cierra la puerta con llave, para que nadie nuevo pueda entrar. A la gente que solo está mirando sin comprar le pide que se vaya. Los clientes que ya están parados frente a la caja pueden terminar de pagar.
Cuando paga el último cliente, apaga las luces y se va a su casa. Si alguien sigue contando monedas después de diez minutos, cierra de todos modos.
La versión precisa
Shutdown cierra cada listener registrado en el servidor, así que el sistema operativo rechaza las conexiones nuevas a ese puerto. Luego cierra las conexiones inactivas, es decir, las que están entre peticiones o las nuevas que llevan unos segundos sin empezar una petición. Después revisa cada tanto las conexiones que quedan, y retorna cuando todas terminaron su petición actual y quedaron inactivas, o cuando termina su contexto. No cancela los contextos de las peticiones ni interrumpe los handlers. Un handler que nunca retorna deja a Shutdown esperando hasta el plazo.
Dónde falla la analogía: una dueña de tienda puede decirle a un cliente “ya cerramos, apúrese, por favor”. Shutdown no les dice nada a los handlers que están corriendo. Si un handler hace un trabajo largo, tiene que enterarse del apagado de otra forma, por ejemplo con una función registrada con srv.RegisterOnShutdown. Además, cerrar la puerta no es amable en una red real: un cliente que intenta conectarse recibe “connection refused”, así que un balanceador de carga debería dejar de enviar tráfico antes de que empiece el apagado.
El apagado ordenado a lo largo del tiempo. Cuando se cancela ctx, el listener se cierra y las conexiones nuevas se rechazan, las conexiones inactivas se cierran de inmediato, y Shutdown espera hasta que la petición que ya estaba corriendo envía su respuesta.
Demostrar en una prueba que el apagado funciona
La secuencia de apagado no se puede comprobar ejecutando main y presionando Ctrl+C en una prueba, pero serve recibe un contexto, un listener y un handler, así que una prueba puede controlar cada paso. Escucha en 127.0.0.1:0, que le pide al sistema operativo cualquier puerto libre, y le pasa a serve un handler que se bloquea hasta que la prueba lo libera:
func TestServeShutsDownGracefully(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
started := make(chan struct{})
release := make(chan struct{})
slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(started)
<-release
io.WriteString(w, "finished")
})
ctx, cancel := context.WithCancel(t.Context())
served := make(chan error, 1)
go func() {
served <- serve(ctx, ln, slow, slog.New(slog.DiscardHandler))
}()
// 1. Start a request, and wait until the handler is running.
type result struct {
body string
err error
}
inflight := make(chan result, 1)
go func() {
resp, err := http.Get("http://" + addr + "/slow")
if err != nil {
inflight <- result{err: err}
return
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
inflight <- result{string(b), err}
}()
<-started
// 2. Ask the server to stop, as SIGTERM would.
cancel()
// 3. Wait until new connections are refused. Shutdown closes the
// listener first, but nothing tells us when, so poll with a deadline.
deadline := time.Now().Add(5 * time.Second)
for {
conn, err := net.Dial("tcp", addr)
if err != nil {
break
}
conn.Close()
if time.Now().After(deadline) {
t.Fatal("server still accepts connections 5s after cancel")
}
time.Sleep(10 * time.Millisecond)
}
// 4. The request in flight has not been cut off. Let it finish.
select {
case r := <-inflight:
t.Fatalf("request ended before it was released: %+v", r)
default:
}
close(release)
if r := <-inflight; r.err != nil || r.body != "finished" {
t.Errorf("in-flight request: body %q, err %v; want finished", r.body, r.err)
}
if err := <-served; err != nil {
t.Errorf("serve returned %v, want nil", err)
}
}
Son los canales los que fijan el orden, no los sleeps. El handler cierra started en cuanto se ejecuta, así que la prueba cancela solo cuando la petición de verdad está en curso, y el handler no puede terminar hasta que la prueba cierre release.
Un paso tiene que sondear, porque Shutdown no avisa cuando el listener ya está cerrado. La prueba intenta conectarse cada 10 milisegundos hasta que una conexión falla, con un plazo de 5 segundos, y cierra cada conexión que sí entra para que Shutdown no la espere.
Una prueba que no puede fallar no demuestra nada, así que cambié srv.Shutdown(shutdownCtx) por srv.Close(), que cierra todas las conexiones de golpe. La prueba falló: la petición en curso recibió EOF en lugar de finished.
La prueba pasó 20 veces seguidas con go test -race -count=20 ./.... No usa testing/synctest, estable desde Go 1.25: ese paquete simula el reloj, pero su documentación dice que evites la red real dentro de él.
Distribuir un solo binario
Un programa de Go se compila en un solo ejecutable que incluye el runtime y cada paquete que usa, así que distribuir la API significa copiar un archivo. Primero, dale una versión: main.go declara una variable que la compilación puede sobrescribir.
// version is set at build time with -ldflags "-X main.version=v1.2.3".
var version = "dev"
func main() {
addr := flag.String("addr", "localhost:8080", "address to listen on")
showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse()
if *showVersion {
fmt.Println("tasksd", version)
return
}
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
if err := run(*addr, logger); err != nil {
logger.Error("server stopped", "err", err)
os.Exit(1)
}
}
-X main.version=v1.0.0 fija ese string en tiempo de enlazado. Esta es la compilación para release:
$ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=v1.0.0" -o tasksd ./cmd/tasksd
$ ./tasksd -version
tasksd v1.0.0
Qué hace cada flag:
CGO_ENABLED=0desactiva cgo. Esto me sorprendió: ungo buildnormal del servidor quedó enlazado dinámicamente con la biblioteca de C, aunque el módulo no tiene código C. El paquetenetpuede usar la biblioteca de C para las búsquedas DNS, así que la enlaza siempre que cgo está disponible. Con cgo desactivado,filereportó un binario enlazado estáticamente, que corre en cualquier Linux de esa arquitectura, incluso en un contenedor vacío.-trimpathquita del binario las rutas de directorios de tu máquina, así que los stack traces muestranexample.com/tasks/internal/api/handlers.goen lugar de una ruta dentro de tu carpeta personal. También ayuda a que dos máquinas generen los mismos bytes a partir del mismo código.-ldflags="-s -w"quita la tabla de símbolos y la información de depuración DWARF. La compilación para release quedó cerca de un tercio más chica que ungo buildnormal. Los panics siguen mostrando nombres de archivo y números de línea, porque el runtime guarda sus propias tablas para eso, pero un depurador tiene menos con qué trabajar.
go version -m lee la configuración de compilación de cualquier binario de Go:
$ go version -m tasksd
...
path example.com/tasks/cmd/tasksd
mod example.com/tasks (devel)
build -buildmode=exe
build -compiler=gc
build -trimpath=true
build CGO_ENABLED=0
build GOARCH=amd64
build GOOS=linux
build GOAMD64=v1
Dejé fuera la primera línea, que nombra la versión exacta de Go. -ldflags no aparece en la lista: Go no lo registra con -trimpath, porque los flags del enlazador pueden contener rutas. Un programa puede leer la misma información con debug.ReadBuildInfo, pero para tu propio número de versión, -X es más simple.
Para compilar para otro sistema, define GOOS y GOARCH. No hace falta ningún toolchain extra:
$ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o tasksd-linux-arm64 ./cmd/tasksd
$ GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o tasksd.exe ./cmd/tasksd
go tool dist list imprime cada par soportado. Cuando le envié SIGTERM al binario de Linux con kill, registró shutting down en el log y salió con estado 0.
Un binario estático también permite una imagen de contenedor pequeña. Este Dockerfile es solo ilustrativo. No lo compilé ni lo ejecuté como parte de este post:
FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tasksd ./cmd/tasksd
FROM scratch
COPY --from=build /tasksd /tasksd
USER 65532:65532
ENTRYPOINT ["/tasksd", "-addr", ":8080"]
La imagen final no tiene nada más que el binario: ni shell, ni certificados de CA, ni datos de zonas horarias. Este servidor no necesita ninguno, pero un servicio que llama a APIs por HTTPS necesitaría certificados, o una imagen base distroless. -addr :8080 importa, porque localhost:8080 solo acepta conexiones desde dentro del contenedor. La forma exec de ENTRYPOINT hace que tasksd sea el proceso 1, así que docker stop le envía SIGTERM directamente.
Hacia dónde seguir
La API de tareas es un servicio pequeño y completo, y cada paso siguiente ya tiene un lugar en el diseño:
- Una base de datos. Escribe un
Storesobredatabase/sql, como el boceto deSQLStoreen la parte sobre APIs con JSON, y corre la misma tabla de endpoints contra él con una base de datos de prueba. - Autenticación. Un middleware que revisa un token y pone al usuario en el contexto de la petición, el mismo patrón que
withRequestID. Para clientes de navegador, mirahttp.CrossOriginProtection, agregado en Go 1.25, que rechaza peticiones cross-origin inseguras. - Una descripción OpenAPI de las rutas, los cuerpos y los códigos de estado, para que los clientes puedan generar código a partir de ella.
- Profiling.
net/http/pprofsirve perfiles de CPU y de memoria. Ponlo en un puerto aparte y privado, nunca en el mux público.
Qué recordar
- Prueba los handlers con
httptest.NewRecorderpara el estado, los headers y el cuerpo. Usahttptest.NewServercuando la red, un cliente o el propio servidor forman parte de la prueba. - Pon el almacenamiento detrás de una interfaz, y un store falso pequeño puede provocar cada camino de error, incluido un panic. Revisa también el log, con un handler de
slogque escriba en un buffer y unReplaceAttrque descarte lo que cambia. - Usa
t.Cleanupen los helpers en lugar dedefer, yt.Context()para todo lo que reciba un contexto. Corrego test -race ./...con pruebas que de verdad envíen peticiones al mismo tiempo. - Configura
ReadHeaderTimeoutcontra los clientes lentos.WriteTimeoutcierra la conexión, pero no detiene tu handler. - Apaga con
srv.Shutdowny un plazo, y tratahttp.ErrServerClosedcomo éxito. Pruébalo con un handler que se bloquee en un canal. - Distribuye con
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w", fija la versión con-Xy compila para otras plataformas conGOOSyGOARCH.
Un servidor no está terminado hasta que probaste cómo falla y cómo se detiene.