Blog

Servidores HTTP en Go con net/http

La biblioteca estándar de Go trae un servidor HTTP listo para producción. Aprende handlers, enrutamiento con patrones de método y ruta en ServeMux, las reglas de ResponseWriter, qué pasa con cada petición y por qué http.Server necesita timeouts.

Go no necesita un framework web para servir HTTP. El paquete net/http de la biblioteca estándar tiene un servidor de verdad, un router y un cliente, y hay grandes servicios en producción que corren directamente sobre él.

Este post cubre los handlers, el enrutamiento con http.ServeMux, las reglas para escribir una respuesta, qué le pasa a una petición de principio a fin, y por qué conviene que construyas tú mismo un http.Server en lugar de llamar a http.ListenAndServe. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.

Un handler es un solo método

En Go, un handler es cualquier valor con un método ServeHTTP. El paquete net/http lo define como una interfaz:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

Es la misma idea de la parte sobre interfaces: una interfaz pequeña que se satisface sin declararlo. El servidor llama a ServeHTTP una vez por cada petición. El *http.Request contiene lo que envió el cliente. El http.ResponseWriter es donde escribes la respuesta.

Aquí hay un handler hecho a partir de un struct. Para correr un servidor real dentro de un ejemplo verificado, usamos httptest.NewServer. Arranca tu handler en un puerto libre al azar de tu propia computadora, te da su dirección en srv.URL y lo apaga cuando llamas a Close. Del lado del cliente está http.Get, que envía una petición real por una conexión real.

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

type greeter struct {
	greeting string
}

func (g greeter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "%s, you asked for %s\n", g.greeting, r.URL.Path)
}

func main() {
	var h http.Handler = greeter{greeting: "Hello"}

	srv := httptest.NewServer(h)
	defer srv.Close()

	res, err := http.Get(srv.URL + "/tasks")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res.Status)
	fmt.Println(res.Header.Get("Content-Type"))
	fmt.Print(string(body))
}

Imprime:

200 OK
text/plain; charset=utf-8
Hello, you asked for /tasks

greeter nunca dice que implementa http.Handler. Tiene el método, así que la asignación compila. fmt.Fprintf funciona sobre w porque un ResponseWriter también es un io.Writer, la interfaz que conociste con io.Reader e io.Writer.

El handler nunca fijó un código de estado ni un Content-Type, y aun así el cliente recibió los dos. Cuando tú no eliges, el servidor envía 200 y adivina el tipo de contenido a partir de los primeros bytes que escribes. Más abajo vas a ver exactamente cuándo pasa eso.

http.HandlerFunc convierte una función en un handler

La mayoría de los handlers no necesitan un struct, así que net/http tiene un adaptador. http.HandlerFunc es un tipo función con un método ServeHTTP que simplemente llama a la función:

type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }

Convertir una función común a ese tipo le da el método, así que se vuelve un http.Handler. Este programa también usa la segunda herramienta de httptest. httptest.NewRecorder es un ResponseWriter que guarda lo que escribió el handler, así que puedes llamar a un handler directamente, sin nada de red:

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func health(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "ok")
}

func main() {
	h := http.HandlerFunc(health)
	fmt.Printf("%T\n", h)

	req := httptest.NewRequest("GET", "/health", nil)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	fmt.Println(rec.Code)
	fmt.Print(rec.Body.String())
}

Imprime:

http.HandlerFunc
200
ok

http.HandlerFunc(health) es una conversión de tipo, no una llamada. Nada corre hasta que se llama a ServeHTTP. httptest.NewServer prueba el viaje completo a través de una conexión. httptest.NewRecorder prueba solo el handler, lo que es más rápido. La parte sobre probar y publicar la API profundiza mucho más en los dos.

Enrutamiento con http.ServeMux

Un servidor real tiene más de un handler, así que algo tiene que elegir cuál recibe cada petición. En net/http eso es http.ServeMux, un router que a su vez es un handler. Registras patrones en él, y su propio ServeHTTP elige el handler correcto y lo llama.

Desde Go 1.22, un patrón puede nombrar un método HTTP además de una ruta, y una ruta puede tener wildcards entre llaves. Dentro del handler, r.PathValue devuelve lo que capturó un wildcard:

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "task %s\n", r.PathValue("id"))
	})
	mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "deleted task %s\n", r.PathValue("id"))
	})

	srv := httptest.NewServer(mux)
	defer srv.Close()

	send := func(method, path string) {
		req, _ := http.NewRequest(method, srv.URL+path, nil)
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			fmt.Println("error:", err)
			return
		}
		defer res.Body.Close()
		body, _ := io.ReadAll(res.Body)
		fmt.Printf("%-6s %-12s %d %q", method, path, res.StatusCode, body)
		if allow := res.Header.Get("Allow"); allow != "" {
			fmt.Printf(" Allow: %s", allow)
		}
		fmt.Println()
	}

	send("GET", "/tasks/42")
	send("DELETE", "/tasks/7")
	send("PUT", "/tasks/42")
	send("GET", "/tasks")
	send("GET", "/tasks/a%2Fb")
	send("GET", "/users/1")
}

Imprime:

GET    /tasks/42    200 "task 42\n"
DELETE /tasks/7     200 "deleted task 7\n"
PUT    /tasks/42    405 "Method Not Allowed\n" Allow: DELETE, GET, HEAD
GET    /tasks       404 "404 page not found\n"
GET    /tasks/a%2Fb 200 "task a/b\n"
GET    /users/1     404 "404 page not found\n"

Lee las líneas una por una:

  • GET /tasks/42 y DELETE /tasks/7 llegan cada uno a su propio handler, y {id} capturó el número.
  • PUT /tasks/42 encontró una ruta que existe, pero ningún patrón permite PUT. ServeMux respondió 405 Method Not Allowed por su cuenta, con un header Allow que lista los métodos que sí funcionarían. HEAD está en la lista porque un patrón GET también coincide con las peticiones HEAD.
  • GET /tasks es un 404, no un 405. {id} tiene que coincidir con un segmento de la ruta, y no hay ninguno, así que ningún patrón coincide con la ruta.
  • /tasks/a%2Fb nos sorprendió. %2F es una barra escapada, así que el wildcard ve un solo segmento. Pero PathValue lo devuelve sin escapar, como a/b. Trata un valor de ruta como cualquier otra entrada que viene de un cliente, y valídalo antes de usarlo.
  • Una ruta que ningún patrón conoce recibe 404 con el cuerpo 404 page not found.

En un patrón, el método va antes de la ruta, separado por un espacio, y un patrón sin método coincide con todos los métodos. Antes de Go 1.22 nada de esto existía. La gente revisaba r.Method a mano o recurría a un router de terceros. Para la mayoría de las APIs, hoy no lo necesitas.

Explicado como si tuvieras diez años

Imagina la sala de clasificación de una oficina de correos. Hay una pared de casilleros, y cada uno tiene una dirección escrita encima: “Calle Principal 12”, “Calle Principal, cualquier casa”, “Cualquier lugar de la ciudad”. Llega una carta, y el clasificador lee su dirección y la deja en un casillero. Un cartero toma todo lo que hay en ese casillero y lo reparte.

Si una dirección cabe en más de un casillero, el clasificador elige el más exacto. Una carta para Calle Principal 12 va al casillero “Calle Principal 12”, aunque “Calle Principal, cualquier casa” también la aceptaría. Si ningún casillero sirve, la carta se devuelve con un sello de “dirección desconocida”.

La versión precisa

ServeMux guarda un conjunto de patrones, cada uno junto a un handler. Para cada petición busca todos los patrones que coinciden con el método y la ruta. Si coinciden varios, gana el más específico. Un patrón es más específico que otro si coincide con un subconjunto estricto de las peticiones con las que coincide el otro. El orden en que los registraste no importa.

Si nada coincide con la ruta, ServeMux llama a su handler interno de no encontrado, que escribe 404. Si la ruta coincide pero el método no, escribe 405 con un header Allow.

Dónde falla la analogía: una oficina de correos clasifica solo por dirección. ServeMux también lee el método, que es como clasificar por dirección y por si el sobre dice “entregar”, “recoger” o “cancelar”. Un wildcard además hace más que un casillero: copia parte de la dirección, como 42, y se la pasa al cartero. Y una oficina de correos se las arregla como puede cuando dos casilleros son igual de exactos. ServeMux se niega a arrancar, como muestra la siguiente sección.

Wildcards: {name...} y {$}

Un wildcard simple como {id} coincide con exactamente un segmento de la ruta. Dos formas especiales cubren los demás casos. {path...} al final de un patrón coincide con todos los segmentos restantes, barras incluidas. {$} coincide solo con el final de la ruta, y así es como registras la raíz sin atrapar todo lo demás:

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "home page")
	})
	mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "file %q\n", r.PathValue("path"))
	})

	for _, path := range []string{"/", "/about", "/files/notes/2026/todo.txt", "/files/"} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
		fmt.Printf("%-27s %d %s", path, rec.Code, rec.Body.String())
	}
}

Imprime:

/                           200 home page
/about                      404 404 page not found
/files/notes/2026/todo.txt  200 file "notes/2026/todo.txt"
/files/                     200 file ""

Sin {$}, el patrón GET / terminaría en barra, y un patrón que termina en barra coincide con todas las rutas que están debajo. /about recibiría la página de inicio en lugar de un 404. El wildcard {path...} también puede no coincidir con nada, y por eso /files/ llegó al handler con un string vacío.

Gana el patrón más específico

Cuando una petición coincide con varios patrones, ServeMux no toma el primero que se registró. Toma el que coincide con la menor cantidad posible de peticiones. Este programa registra cuatro patrones que se solapan, a propósito en el orden “equivocado”:

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func reply(text string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, text)
	}
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/tasks/{id}", reply(`"/tasks/{id}"`))
	mux.HandleFunc("GET /tasks/{id}", reply(`"GET /tasks/{id}"`))
	mux.HandleFunc("GET /tasks/new", reply(`"GET /tasks/new"`))
	mux.HandleFunc("/", reply(`"/"`))

	for _, t := range []struct{ method, path string }{
		{"GET", "/tasks/new"},
		{"GET", "/tasks/42"},
		{"PUT", "/tasks/42"},
		{"GET", "/tasks/42/notes"},
	} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest(t.method, t.path, nil))
		fmt.Printf("%-4s %-16s -> %s", t.method, t.path, rec.Body.String())
	}
}

Imprime:

GET  /tasks/new       -> "GET /tasks/new"
GET  /tasks/42        -> "GET /tasks/{id}"
PUT  /tasks/42        -> "/tasks/{id}"
GET  /tasks/42/notes  -> "/"

Un segmento literal, new, le gana a un wildcard, {id}. Un patrón con método le gana a la misma ruta sin método, así que GET va al handler específico de ese método y PUT cae en el que acepta cualquier método. / termina en barra, así que coincide con todas las rutas, y /tasks/42/notes termina ahí porque no hay nada más que sirva. Por eso tampoco hay un 405 en este programa: el patrón que atrapa todo siempre coincide.

A veces ninguno de dos patrones es más específico. GET /{kind}/42 y /tasks/{id} coinciden los dos con /tasks/42, pero cada uno también coincide con rutas con las que el otro no. Registrar los dos hace que HandleFunc entre en panic al arrancar. El mensaje nombra los dos patrones y la línea donde se registró cada uno, y luego explica:

GET /{kind}/42 and /tasks/{id} both match some paths, like "/tasks/42".
But neither is more specific than the other.

Un panic al arrancar es del tipo útil. Encuentras la ambigüedad la primera vez que corres el servidor, no cuando llega una petición con mala suerte.

Escribir una respuesta: header, código de estado, cuerpo

Una respuesta HTTP sale en un orden fijo: la línea de estado, luego los headers, luego el cuerpo. ResponseWriter te obliga a seguir ese orden, porque una vez que una parte salió, ya no se puede cambiar. Tres llamadas corresponden a las tres partes:

  • w.Header() devuelve el map de headers. Cámbialo primero.
  • w.WriteHeader(code) envía la línea de estado y los headers.
  • w.Write envía bytes del cuerpo. Si todavía no se llamó a WriteHeader, el primer Write llama a WriteHeader(200) por ti.

Este es un handler que lo hace en el orden correcto:

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func create(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Location", "/tasks/43")
	w.WriteHeader(http.StatusCreated)
	fmt.Fprintln(w, "created task 43")
}

func main() {
	rec := httptest.NewRecorder()
	create(rec, httptest.NewRequest("POST", "/tasks", nil))

	res := rec.Result()
	fmt.Println(res.Status)
	fmt.Println("Location:", res.Header.Get("Location"))
	fmt.Print(rec.Body.String())
}

Imprime:

201 Created
Location: /tasks/43
created task 43

Usa rec.Result() para leer lo que vería un cliente. rec.Header() es el map vivo del handler, y todavía muestra headers que se fijaron demasiado tarde para enviarse.

Ahora el orden equivocado. Este handler escribe primero el cuerpo y después intenta fijar un header y un 404. Para ver la queja del servidor, el programa construye el servidor de prueba con httptest.NewUnstartedServer, apunta su ErrorLog a stdout sin marca de tiempo, y solo entonces lo arranca:

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
)

func lateHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "task 42")
	w.Header().Set("X-Task-Id", "42")
	w.WriteHeader(http.StatusNotFound)
}

func main() {
	srv := httptest.NewUnstartedServer(http.HandlerFunc(lateHandler))
	// Send the server's error log to stdout, with no timestamp, so we can see it.
	srv.Config.ErrorLog = log.New(os.Stdout, "server log: ", 0)
	srv.Start()
	defer srv.Close()

	res, err := http.Get(srv.URL)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println("status:", res.StatusCode)
	fmt.Printf("X-Task-Id: %q\n", res.Header.Get("X-Task-Id"))
	fmt.Print("body: ", string(body))
}

Imprime:

server log: http: superfluous response.WriteHeader call from main.lateHandler (main.go:15)
status: 200
X-Task-Id: ""
body: task 42

El cliente recibió un 200, no un 404, y ningún header X-Task-Id. El primer Fprintln fijó el código de estado en 200 y congeló los headers. El Header().Set tardío cambió un map que ya nadie lee, y lo hizo en silencio. El WriteHeader tardío no hizo nada, salvo dejar una línea “superfluous” en el log del servidor con la función y el número de línea. Si ves esa línea en un log real, busca un camino de error que escribe después de que empezó el cuerpo.

La vida de una petición

Una petición pasa por varias manos entre el cliente y tu handler, y cada una hace un solo trabajo. Mira cómo pasa una petición:

cliente servidor patrones ServeMux GET /tasks GET /tasks/{id} POST /tasks GET /tasks/42 200 task 42 goroutine handler de GET /tasks/{id} id := r.PathValue("id") // "42" w.Header().Set("Content-Type", "text/plain") w.WriteHeader(200) fmt.Fprintln(w, "task", id) un cliente va a enviar GET /tasks/42 el servidor acepta la conexión y arranca una goroutine para la petición ServeMux compara sus patrones y elige el más específico: GET /tasks/{id} el handler corre y lee r.PathValue("id"), que vale "42" fija un header, luego WriteHeader(200) y escribe el cuerpo la respuesta vuelve al cliente, y la goroutine termina

Una petición de principio a fin. El servidor le da a la conexión su propia goroutine, ServeMux elige el patrón más específico que coincide, y el handler lee el valor de la ruta y después escribe el header, el código de estado y el cuerpo, en ese orden, antes de que la respuesta vuelva.

Estos son los pasos en palabras, por si la animación no se reproduce:

  1. Un cliente envía GET /tasks/42.
  2. El servidor acepta la conexión y arranca una goroutine nueva para atenderla.
  3. ServeMux compara la petición con sus patrones, GET /tasks, GET /tasks/{id} y POST /tasks, y elige GET /tasks/{id}.
  4. Corre el handler de ese patrón y llama a r.PathValue("id"), que devuelve "42".
  5. El handler fija un header, llama a WriteHeader(200) y escribe el cuerpo.
  6. La respuesta vuelve al cliente, y la goroutine ya no tiene nada más que hacer.

El paso 2 importa más de lo que parece. El servidor ejecuta Accept en un bucle, y por cada conexión nueva arranca una goroutine, como en la parte sobre goroutines. Las peticiones que llegan por esa conexión se turnan en su goroutine, y con HTTP/2 cada petición recibe una goroutine propia. De cualquier forma, cien clientes significan cien goroutines corriendo tus handlers al mismo tiempo, y tú nunca escribiste go.

Este programa demuestra que dos handlers de verdad corren a la vez. /wait se bloquea hasta que se cierra un canal, y solo /release lo cierra:

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
)

func get(url string) string {
	res, err := http.Get(url)
	if err != nil {
		return "error: " + err.Error()
	}
	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)
	return string(body)
}

func main() {
	release := make(chan struct{})

	mux := http.NewServeMux()
	mux.HandleFunc("GET /wait", func(w http.ResponseWriter, r *http.Request) {
		<-release // blocks until another request closes the channel
		fmt.Fprint(w, "wait: released")
	})
	mux.HandleFunc("GET /release", func(w http.ResponseWriter, r *http.Request) {
		close(release)
		fmt.Fprint(w, "release: done")
	})

	srv := httptest.NewServer(mux)
	defer srv.Close()

	waitResult := make(chan string)
	go func() { waitResult <- get(srv.URL + "/wait") }()

	fmt.Println(get(srv.URL + "/release"))
	fmt.Println(<-waitResult)
}

Imprime:

release: done
wait: released

Si el servidor atendiera una petición a la vez, /wait ocuparía al único trabajador para siempre, /release nunca correría y el programa se colgaría. Termina, así que los dos handlers estaban corriendo al mismo tiempo.

El estado compartido en un handler necesita un lock

Los handlers que corren al mismo tiempo y tocan la misma variable tienen una carrera de datos, exactamente como las goroutines de la parte sobre sync. Aquí la carrera es fácil de pasar por alto, porque nada en tu código arranca una goroutine. Este handler cuenta visitas sin lock, y llegan 50 peticiones juntas:

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sync"
)

type visits struct {
	count int
}

func (v *visits) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	v.count++ // no lock: every request runs in its own goroutine
	fmt.Fprint(w, v.count)
}

func main() {
	srv := httptest.NewServer(&visits{})
	defer srv.Close()

	var wg sync.WaitGroup
	for range 50 {
		wg.Go(func() {
			res, err := http.Get(srv.URL)
			if err != nil {
				fmt.Println("error:", err)
				return
			}
			io.Copy(io.Discard, res.Body)
			res.Body.Close()
		})
	}
	wg.Wait()
	fmt.Println("done")
}

Si lo ejecutas con go run -race ., imprime un reporte como este (las líneas ... reemplazan direcciones de memoria, rutas de archivos y números de goroutine que cambian en cada ejecución):

WARNING: DATA RACE
...
  main.(*visits).ServeHTTP()
...
  net/http.(*conn).serve()
...
done
exit status 66

Mira el stack en el reporte. Debajo de tu ServeHTTP está net/http.(*conn).serve(), la goroutine que el servidor arrancó para cada conexión. El detector de carreras encontró dos de ellas escribiendo count sin nada que las coordine.

La solución es la de la parte sobre sync: un mutex en el struct, y un receptor puntero para que todas las peticiones compartan el mismo:

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sync"
)

type visits struct {
	mu    sync.Mutex
	count int
}

func (v *visits) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	v.mu.Lock()
	v.count++
	n := v.count
	v.mu.Unlock()
	fmt.Fprint(w, n)
}

func main() {
	v := &visits{}
	srv := httptest.NewServer(v)
	defer srv.Close()

	var wg sync.WaitGroup
	for range 50 {
		wg.Go(func() {
			res, err := http.Get(srv.URL)
			if err != nil {
				fmt.Println("error:", err)
				return
			}
			io.Copy(io.Discard, res.Body)
			res.Body.Close()
		})
	}
	wg.Wait()

	v.mu.Lock()
	fmt.Println("visits:", v.count)
	v.mu.Unlock()
}

Imprime:

visits: 50

El handler copia la cuenta en n mientras tiene el lock, y lo libera antes de escribir la respuesta. Escribirle a un cliente lento puede tardar mucho, y nadie más debería esperar por eso. Todo lo que un handler lee o escribe fuera de su propia petición, como un map de tareas, una caché o un contador, necesita el mismo cuidado.

r.Context() termina cuando el cliente se va

Cada petición lleva un contexto, y el servidor lo cancela cuando el cliente se desconecta. Es el context de la parte sobre patrones de concurrencia, ya conectado para ti. Un handler que hace trabajo lento debería vigilar r.Context().Done() y parar, porque ya nadie está esperando la respuesta.

En este programa, el cliente se rinde en cuanto el handler empezó. El trabajo del handler tardaría 10 segundos, un margen amplio, así que el resultado siempre es el mismo:

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"
)

func main() {
	started := make(chan struct{})
	outcome := make(chan string)

	slow := func(w http.ResponseWriter, r *http.Request) {
		close(started)
		select {
		case <-time.After(10 * time.Second):
			fmt.Fprintln(w, "report ready")
			outcome <- "handler: finished the work"
		case <-r.Context().Done():
			outcome <- "handler: stopped early: " + r.Context().Err().Error()
		}
	}

	srv := httptest.NewServer(http.HandlerFunc(slow))
	defer srv.Close()

	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		<-started
		cancel() // the client gives up once the handler is running
	}()

	req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL, nil)
	_, err := http.DefaultClient.Do(req)
	fmt.Println("client: canceled:", errors.Is(err, context.Canceled))
	fmt.Println(<-outcome)
}

Imprime:

client: canceled: true
handler: stopped early: context canceled

Cancelar el contexto del cliente cerró la conexión. El servidor lo notó, canceló r.Context(), y el select del handler tomó el caso Done de inmediato en lugar de esperar 10 segundos. En un handler real le pasas r.Context() a la consulta a la base de datos o a la llamada saliente, y esas también se detienen. El contexto también termina cuando ServeHTTP retorna, así que no lo guardes para trabajo que deba sobrevivir a la petición.

Construye un http.Server, no llames a http.ListenAndServe

El primer servidor Go de todo tutorial es http.ListenAndServe(":8080", mux). Funciona, pero construye un http.Server con todos los campos en su valor cero, y para los timeouts, cero significa “esperar para siempre”.

Eso es un problema real en internet. Un cliente puede abrir una conexión y enviar los headers de su petición a un byte cada pocos segundos. Sin ReadHeaderTimeout, el servidor espera con paciencia, ocupando una goroutine y una conexión abierta. Con suficientes clientes así, el servidor se queda sin conexiones abiertas que pueda sostener, sin ver nunca una petición completa. El ataque es tan viejo que tiene nombre, Slowloris, y al atacante casi no le cuesta nada.

La solución es construir el servidor tú mismo y fijar los timeouts:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "task %s\n", r.PathValue("id"))
	})

	srv := &http.Server{
		Addr:              "localhost:8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
	log.Println("listening on", srv.Addr)
	log.Fatal(srv.ListenAndServe())
}

Nuestro verificador no ejecuta este, porque escucha en un puerto fijo hasta que lo detienes. Ejecútalo tú, y desde una segunda terminal:

curl localhost:8080/tasks/42

Eso imprime task 42. Esto es lo que limita cada timeout:

  • ReadHeaderTimeout: cuánto tiempo tiene un cliente para enviar los headers de la petición. Es el que detiene a Slowloris, y el que nunca debes omitir.
  • ReadTimeout: cuánto puede tardar leer la petición completa, cuerpo incluido.
  • WriteTimeout: cuánto tiempo tiene el servidor para escribir la respuesta después de leer los headers.
  • IdleTimeout: cuánto tiempo puede quedar sin uso una conexión keep-alive entre peticiones.

Los números de arriba son puntos de partida razonables, no reglas. Un servidor que acepta subidas grandes o transmite respuestas largas necesita otros. Además, ListenAndServe se bloquea hasta que el servidor falla, y entonces log.Fatal sale sin dejar que terminen las peticiones en curso. Elegir timeouts para producción y apagar el servidor de forma ordenada se cubren en la parte sobre probar y publicar la API.

Qué recordar

  • Un http.Handler es cualquier tipo con ServeHTTP(http.ResponseWriter, *http.Request). http.HandlerFunc convierte una función común en uno.
  • Desde Go 1.22, los patrones de ServeMux aceptan un método y wildcards: "GET /tasks/{id}", que se lee con r.PathValue("id"). {rest...} coincide con el resto de la ruta, y {$} coincide solo con el final.
  • Gana el patrón más específico, sin importar el orden de registro. Los patrones ambiguos entran en panic al registrarse. Una ruta conocida con el método equivocado recibe 405 y un header Allow, y una ruta desconocida recibe 404.
  • Fija los headers, luego llama a WriteHeader, luego escribe el cuerpo. El primer Write envía 200 y congela los headers, y todo lo que venga después se ignora.
  • Las peticiones corren en sus propias goroutines, así que el estado compartido en un handler necesita un mutex, y -race va a encontrar los lugares que no lo tienen.
  • r.Context() se cancela cuando el cliente se va. Pásalo al trabajo lento.
  • Construye un http.Server con al menos ReadHeaderTimeout fijado. http.ListenAndServe espera para siempre a los clientes lentos.

Tu handler corre en muchas goroutines a la vez, las hayas arrancado tú o no.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.