Go context.Context bien hecho: cancelación, timeouts y valores

El contexto de Go es control de flujo, no almacenamiento.

Índice

El context.Context de Go es lo suficientemente simple como para usarse mal, y ese es el problema.

La mayoría de los desarrolladores de Go aprenden rápidamente las reglas de superficie: pasar el context como primer argumento, comprobar ctx.Done(), usar context.WithTimeout y nunca pasar nil.

func DoSomething(ctx context.Context) error {
	// ...
}

Esas reglas son útiles, pero cubren la parte fácil. En servicios de producción, el context no es solo una convención de parámetros: es el plano de control para la vida útil de la solicitud.

Go context: cancellation propagates through the call chain

El context le indica al trabajo cuándo detenerse, cuánto tiempo le queda, qué ruta de cancelación se tomó y qué valores con alcance de solicitud deben viajar a través de las fronteras de la API. Usado bien, previene fugas de goroutines, evita trabajo desperdiciado, propaga límites de tiempo (deadlines) y hace que los servicios sean más fáciles de apagar. Usado mal, se convierte en un saco de dependencias ocultas, globales falsas, timeouts olvidados, temporizadores fugados y un comportamiento de cancelación confuso.

La versión ligeramente opinada es esta: usa el context para la cancelación, los límites de tiempo y los metadatos con alcance de solicitud, y no lo uses como un contenedor de dependencias.

Para qué sirve el context

El paquete context tiene tres funciones principales: la cancelación, los límites de tiempo y timeouts, y los valores con alcance de solicitud. Estas tres funciones cubren todo lo que está diseñado para hacer.

Un context debería responder a preguntas como:

¿Se ha cancelado esta solicitud?
¿Cuánto tiempo le queda a esta operación?
¿Qué ID de solicitud se debe adjuntar a los registros?
¿Qué usuario autenticado está asociado con esta solicitud?

Un context no debería responder a preguntas como:

¿Dónde está mi conexión a la base de datos?
¿Dónde está mi registrador de eventos (logger)?
¿Dónde está mi configuración?
¿Qué implementación de servicio debo usar?

Esas son dependencias: pásalas explícitamente a través de los parámetros de función (consulta Inyección de dependencias en Go para patrones sobre cómo hacer esto de manera limpia). El context es para la vida útil de la solicitud y los metadatos de la solicitud, no para el cableado de la aplicación.

La forma básica del context

La interfaz central es pequeña:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

Las partes importantes son:

  • Done() se cierra cuando el context se cancela o su límite de tiempo expira.
  • Err() explica por qué terminó el context.
  • Deadline() te dice si el context tiene un límite de tiempo.
  • Value() almacena datos con alcance de solicitud.

La mayoría del código no implementa esta interfaz. Recibe un context y lo pasa hacia abajo.

La primera regla: pasar el context explícitamente

Para funciones que realizan trabajo con alcance de solicitud o cancelable, pasa el context como primer parámetro. Esta es la convención estándar de Go y lo que cada librería y herramienta del ecosistema espera:

func GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Haz esto para funciones que pueden:

  • Llamar a una base de datos
  • Llamar a otro servicio
  • Esperar en una cola
  • Iniciar trabajo en segundo plano
  • Bloquearse en I/O
  • Usar un timeout
  • Necesitar valores con alcance de solicitud
  • Necesitar cancelación

No añadas el context a pequeñas funciones puras que no lo necesitan.

Esto está bien:

func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

No todas las funciones necesitan un context. Añadir el context en todas partes hace que el código sea ruidoso.

No almacenes el context en structs

Almacenar un context en un struct es uno de los errores más comunes en las bases de código de Go, y vale la pena señalarlo explícitamente. No hagas esto:

type UserService struct {
	ctx context.Context
	db  *sql.DB
}

Haz esto en su lugar:

type UserService struct {
	db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Un context pertenece a una solicitud, operación o tarea, mientras que un struct de servicio suele vivir mucho más tiempo que cualquier solicitud individual. Mezclar esas vidas útiles hace que la cancelación sea confusa y dificulta razonar sobre a qué operación pertenece un context.

Hay excepciones raras para tipos que representan genuinamente una vida útil de operación única, pero son lo suficientemente raras que la regla por defecto debería ser simple:

Pasa el context. No lo almacenes.

No pases un context nil

Nunca pases nil como context.

Malo:

err := svc.DoWork(nil)

Usa context.Background() cuando no hay un context existente:

err := svc.DoWork(context.Background())

En pruebas, usa el context de la prueba cuando sea posible:

func TestDoWork(t *testing.T) {
	err := svc.DoWork(t.Context())
	if err != nil {
		t.Fatal(err)
	}
}

Un context nil puede provocar un pánico cuando el código llama a métodos sobre él. Un context de fondo (background) es explícito y seguro.

Contexts de fondo, TODO y de solicitud

Hay tres puntos de partida comunes.

context.Background

Usa context.Background() en el nivel superior de un programa cuando no existe un context padre. Es el context raíz del cual se derivan todos los contexts hijos:

func main() {
	ctx := context.Background()
	_ = run(ctx)
}

o:

func TestSomething(t *testing.T) {
	ctx := context.Background()
	_ = ctx
}

context.TODO

Usa context.TODO() cuando sabes que se debe usar un context, pero aún no has decidido cuál.

ctx := context.TODO()

Esto es útil durante la migración, pero no debería convertirse en permanente si existe un context real.

Context de solicitud

En los servidores HTTP, usa el context de la solicitud:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	_ = ctx
}

El context de la solicitud se cancela cuando se cierra la conexión del cliente, se cancela la solicitud o el servidor termina de manejar la solicitud.

Para los servicios web, esto es generalmente el context que deberías pasar hacia el código de la aplicación.

Cancelación con context.WithCancel

Usa context.WithCancel cuando quieres detener el trabajo explícitamente.

ctx, cancel := context.WithCancel(parent)
defer cancel()

La función cancel devuelta cancela el context hijo y libera los recursos asociados con él. Llámala siempre cuando termines. Incluso si el context eventualmente expirará el tiempo, llamar a cancel temprano evita mantener recursos vivos durante más tiempo del necesario.

Ejemplo:

func RunWorker(parent context.Context) error {
	ctx, cancel := context.WithCancel(parent)
	defer cancel()

	done := make(chan error, 1)

	go func() {
		done <- doBackgroundWork(ctx)
	}()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case err := <-done:
		return err
	}
}

El patrón es simple:

  • Deriva un context hijo.
  • Añade defer cancel.
  • Pasa el context hijo al trabajo que debería detenerse juntos.
  • Observa ctx.Done().

Timeouts con context.WithTimeout

Usa context.WithTimeout cuando una operación tiene una duración máxima.

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

Ejemplo con un cliente HTTP:

func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

Esto convierte el timeout en parte de la operación, no en una configuración global oculta.

Siempre llama a cancel

Cuando llamas a WithCancel, WithTimeout o WithDeadline, siempre llama a la función de cancelación devuelta. Esto es importante para la corrección.

Bueno:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

Malo:

ctx, _ := context.WithTimeout(parent, 5*time.Second)

Olvidarse de llamar a cancel puede mantener los temporizadores y los contexts hijos vivos más tiempo del necesario.

Los canales de temporizador en sí también son parte del contrato: desde Go 1.27, los canales detrás de time.After y time.NewTimer siempre son sin búfer, y la antigua opción de exclusión asynctimerchan ya no compila con su valor anterior a 1.23. La Guía de actualización a Go 1.27 lista esa configuración junto con los otros valores eliminados de GODEBUG que hacen fallar la compilación.

Deadlines frente a timeouts

Un timeout es relativo:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

Un deadline es absoluto:

deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

La mayoría del código de aplicación usa timeouts. Los deadlines son útiles cuando una solicitud tiene una hora de finalización fija que debe compartirse entre múltiples operaciones. Por ejemplo, si una solicitud tiene 900 milisegundos restantes, no le des a cada llamada descendiente un timeout nuevo de 1 segundo; propaga el presupuesto restante en su lugar.

Presupuestos de timeout entre capas de servicio

Un error común es apilar timeouts ciegamente.

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	_ = service.DoWork(ctx)
}

func (s *Service) DoWork(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	return s.repo.Query(ctx)
}

Esto parece inofensivo, pero oculta el presupuesto real. La capa de servicio normalmente debería respetar el deadline del llamador en lugar de reiniciar el temporizador al mismo valor.

Un patrón mejor es:

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	if err := service.DoWork(ctx); err != nil {
		// handle error
		return
	}
}

Luego, dentro del servicio:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Query(ctx)
}

Añade un timeout hijo solo cuando una suboperación necesite un presupuesto más pequeño:

func (s *Service) DoWork(ctx context.Context) error {
	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	return s.repo.Query(queryCtx)
}

El modelo mental correcto es directo: la solicitud completa tiene un presupuesto exterior, las suboperaciones específicas pueden tener presupuestos más pequeños tallados de ese presupuesto, y ninguna capa extiende silenciosamente la solicitud más allá de lo que el llamador intentó.

Comprueba ctx.Err() para distinguir la cancelación del timeout

Cuando un context termina, ctx.Err() devuelve la razón.

Normalmente es una de estas:

context.Canceled
context.DeadlineExceeded

Ejemplo:

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return handle(result)
}

Esto permite a los llamadores distinguir la cancelación del timeout, y esa distinción importa en la práctica. Una solicitud cancelada a menudo significa que el cliente se desconectó, mientras que un error de deadline excedido generalmente significa que tu servicio fue demasiado lento. No deberían registrarse, reintentarse ni reportarse de la misma manera siempre.

Usa context.Cause para mejores razones de cancelación

Go moderno también soporta cancelación con conocimiento de causa (cause-aware).

Las funciones útiles incluyen:

  • context.WithCancelCause
  • context.WithTimeoutCause
  • context.WithDeadlineCause
  • context.Cause

El ctx.Err() simple te dice la razón amplia: cancelado o deadline excedido.

context.Cause(ctx) puede decirte la causa más específica.

Ejemplo:

var ErrShutdown = errors.New("server shutting down")

func Run(ctx context.Context) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	go func() {
		// Some shutdown signal arrived.
		cancel(ErrShutdown)
	}()

	<-ctx.Done()

	return context.Cause(ctx)
}

Usa la cancelación con conocimiento de causa cuando la razón importe para los llamadores, los registros o el comportamiento de limpieza, y evítala donde un ctx.Err() simple sea suficiente. El detalle extra solo vale la pena cuando el diagnóstico genuinamente lo requiere.

Ejemplo de servidor HTTP

Un manejador HTTP normal debería comenzar desde r.Context(). Para un recorrido completo sobre la estructuración de servicios HTTP en Go, consulta Construyendo APIs REST en Go. Gin, Echo y Fiber cada uno envuelven context.Context dentro de su propio tipo de context de solicitud, y las diferencias entre las pilas se comparan en Marcos de trabajo web en Go en 2026: net/http, chi, Gin, Echo, Fiber comparados.

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		id := r.PathValue("id")

		user, err := svc.GetUser(ctx, id)
		if err != nil {
			writeError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

El servicio debería aceptar y propagar el context:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

El repositorio debería usar métodos de base de datos conscientes del context:

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		return nil, err
	}

	return &user, nil
}

Lo importante es la cadena. Cada capa pasa el mismo context a la siguiente:

flowchart TD A[HTTP request context] --> B[Handler] B --> C[Service] C --> D[Repository] D --> E[Database query]

No rompas la cadena creando context.Background() en medio.

El error de context.Background(): rompiendo la cadena de cancelación

Este es un error común:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(context.Background(), id)
}

Esto descarta toda la información de cancelación y deadline del llamador. Si el cliente se desconecta, la consulta a la base de datos continúa ejecutándose. Si la solicitud expira el tiempo, el trabajo descendiente puede seguir en vuelo. Si el servidor se está apagando, este código lo ignora por completo. Reemplazar el context recibido con context.Background() dentro de la lógica de negocio casi siempre es incorrecto.

Usa el context que se te dio:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

Solo usa context.Background() en el borde donde no exista un context padre.

Ejemplo de cliente HTTP

Para las solicitudes HTTP salientes, adjunta el context a la solicitud.

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

No hagas esto:

req, err := http.NewRequest(http.MethodGet, endpoint, nil)

Eso crea una solicitud sin el context de la operación.

También evita depender solo de http.Client.Timeout. Puede ser útil como límite de seguridad, pero los contexts de solicitud te dan una mejor propagación a través de la cadena de llamadas.

Un patrón común es:

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

Usa esto cuando la llamada a la API descendiente tiene un presupuesto específico dentro de una solicitud más grande.

Ejemplo de base de datos

La mayoría de las APIs de base de datos de Go tienen métodos conscientes del context. Para una mirada más amplia de cómo las librerías de acceso a datos de Go manejan el context, incluyendo GORM, Ent, Bun y sqlc, consulta Comparando ORMs de Go para PostgreSQL.

Úsalos.

Bueno:

rows, err := db.QueryContext(ctx, query, args...)

Bueno:

err := db.QueryRowContext(ctx, query, id).Scan(&name)

Bueno:

result, err := db.ExecContext(ctx, query, args...)

Malo:

rows, err := db.Query(query, args...)

Las formas conscientes del context permiten que las operaciones de base de datos se detengan cuando la solicitud se cancela o expira el tiempo, lo cual es especialmente importante para consultas lentas, bases de datos sobrecargadas y APIs orientadas al usuario donde la latencia afecta directamente la experiencia del usuario.

Transacciones y context

Las transacciones necesitan un manejo cuidadoso del context.

Una transacción normalmente debería comenzar con el context de la operación:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

Luego, usa el mismo context para las operaciones de transacción:

if _, err := tx.ExecContext(ctx, query, args...); err != nil {
	return err
}

if err := tx.Commit(); err != nil {
	return err
}

Ten cuidado con los timeouts alrededor de las transacciones. Si el context se cancela antes de Commit, la transacción puede ser revertida (rollback). Eso puede ser lo que quieres, pero debería ser intencional.

Para transacciones largas, la mejor respuesta normalmente no es un timeout más largo. Es una transacción más corta que hace menos trabajo por unidad.

Trabajadores en segundo plano y context

Los trabajadores en segundo plano deberían recibir un context que represente su vida útil.

Ejemplo:

type Worker struct {
	logger *slog.Logger
}

func (w *Worker) Run(ctx context.Context) error {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()

		case <-ticker.C:
			if err := w.doOnce(ctx); err != nil {
				w.logger.Error("worker iteration failed", "err", err)
			}
		}
	}
}

Este trabajador se detiene limpiamente cuando el context se cancela, y su ticker se limpia adecuadamente mediante defer ticker.Stop(). En main, crearías un context raíz atado a las señales del SO:

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	worker := &Worker{logger: slog.Default()}

	if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("worker stopped", "err", err)
	}
}

Este es el context usado correctamente: describe la vida útil del trabajo del proceso, y cuando el SO envía una señal, todo el árbol de goroutines que comparten este context se detendrá juntos.

Prevenir fugas de goroutines con la cancelación de context

Una fuga de goroutines ocurre cuando una goroutine permanece bloqueada para siempre después de que ya no es útil.

El context ayuda a prevenir esto.

Malo:

func StartWorker() {
	go func() {
		for {
			doWork()
			time.Sleep(time.Second)
		}
	}()
}

Esta goroutine no tiene una ruta de apagado.

Mejor:

func StartWorker(ctx context.Context) {
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()

		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				doWork()
			}
		}
	}()
}

Cualquier goroutine que entre en un bucle debería casi siempre tener una ruta de cancelación.

Eso no significa que cada goroutine deba recibir el context directamente, pero el sistema debería tener una manera clara de detenerla.

context.AfterFunc

context.AfterFunc ejecuta una función después de que un context se cancela.

Puede ser útil para la limpieza, desbloquear operaciones o puentear APIs que no soportan nativamente el context.

Ejemplo:

func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
	stop := context.AfterFunc(ctx, func() {
		// Wake up or clean up if needed.
	})
	defer stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-ch:
		return nil
	}
}

Usa AfterFunc con cuidado. Inicia la lógica cuando ocurre la cancelación, lo que puede hacer que el flujo de control sea más difícil de seguir. Para la mayoría del código de aplicación, un select normal sobre ctx.Done() es más claro y fácil de razonar. AfterFunc es más valioso cuando necesitas adaptar la cancelación de un context a una API que no acepta context de antemano.

context.WithoutCancel

context.WithoutCancel crea un context que no se cancela cuando el padre se cancela.

Esto es útil, pero también es fácil de usar mal.

Caso de uso de ejemplo:

func Handler(audit *AuditLog) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// Handle request...
		_ = ctx

		auditCtx := context.WithoutCancel(ctx)

		go func() {
			ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
			defer cancel()

			_ = audit.Write(ctx, "request completed")
		}()
	}
}

La idea es que la escritura de auditoría puede necesitar continuar brevemente incluso después de que el context de la solicitud se cancela. Esto debería ser raro y deliberado. No uses WithoutCancel como una manera de evitar lidiar con la cancelación. Úsalo solo cuando el trabajo hijo genuinamente debe sobrevivir a la cancelación del padre, y siempre añade un nuevo timeout: un context que ignora la cancelación pero no lleva ningún deadline puede crear fácilmente fugas de goroutines en segundo plano.

Valores de context bien hechos

Los valores de context son para datos con alcance de solicitud que cruzan fronteras de API.

Buenos ejemplos:

  • ID de solicitud
  • ID de trazas (trace ID)
  • ID de usuario autenticado
  • ID de inquilino (tenant ID)
  • Localización
  • Principio de seguridad
  • Metadatos de correlación

Malos ejemplos:

  • Conexión a base de datos
  • Registrador de eventos como dependencia oculta
  • Banderas de funcionalidad para flujo de control ordinario
  • Parámetros de función opcionales
  • Configuración
  • Clientes de servicio

Una regla útil: si el valor es parte de la identidad de la solicitud o del contexto de observabilidad, puede pertenecer al context. Si es una dependencia que tu código necesita para hacer su trabajo, pásala explícitamente.

Usa claves tipadas para los valores de context

No uses cadenas de texto simples como claves de context.

Malo:

ctx = context.WithValue(ctx, "userID", "123")

Esto puede colisionar con otros paquetes.

Usa un tipo de clave personalizado no exportado:

type userIDKey struct{}

func WithUserID(ctx context.Context, userID string) context.Context {
	return context.WithValue(ctx, userIDKey{}, userID)
}

func UserIDFromContext(ctx context.Context) (string, bool) {
	userID, ok := ctx.Value(userIDKey{}).(string)
	return userID, ok
}

Este patrón te da seguridad de tipos en la frontera del paquete, evita colisiones de claves con otros paquetes y mantiene la superficie de la API de context limpia con funciones de acceso tipadas.

No uses valores de context para parámetros opcionales

Esto es malo:

ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)

Esto oculta el contrato de la función.

Prefiere parámetros explícitos:

users, err := repo.ListUsers(ctx, ListUsersOptions{
	PageSize: 100,
})

Los valores de context no deberían reemplazar los argumentos de función. La entrada oculta hace que el código sea más difícil de entender, probar y revisar. Y cualquier persona que lea la firma de la función no se dará cuenta de que el parámetro existe.

Registro de eventos (Logging) y context

Hay dos enfoques comunes para el registro de eventos con context. Los ejemplos de aquí usan el paquete log/slog de Go. Para un análisis más profundo del registro estructurado con slog en servicios de producción, consulta Registro estructurado en Go con slog.

Enfoque 1: Extraer valores y adjuntarlos a los registros

func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
	if requestID, ok := RequestIDFromContext(ctx); ok {
		logger = logger.With("request_id", requestID)
	}

	logger.Info(msg)
}

Esto mantiene el registrador de eventos explícito como una dependencia adecuada y usa el context solo para valores con alcance de solicitud que genuinamente necesitan cruzar fronteras de API.

Enfoque 2: Almacenar el registrador de eventos en el context

Algunas bases de código almacenan un registrador de eventos en el context.

Esto puede ser conveniente, pero no lo recomiendo como predeterminado. Convierte el context en un contenedor de dependencias.

Mi preferencia:

  • Pasa las dependencias del registrador de eventos explícitamente.
  • Almacena los IDs de trazas y los IDs de solicitudes en el context.
  • Añade esos valores a los registros en las fronteras o el middleware.

Esto mantiene las dependencias visibles.

Context y trazas (Tracing)

La trazabilidad es uno de los casos de uso más fuertes para los valores de context, y es un ajuste genuinamente bueno. OpenTelemetry y sistemas similares usan el context para propagar spans de traza a través de llamadas de funciones y fronteras de proceso, porque los datos de traza son exactamente el tipo de metadatos con alcance de solicitud para los que el context fue diseñado para cargar.

Un patrón típico se ve así:

func (s *Service) DoWork(ctx context.Context) error {
	ctx, span := s.tracer.Start(ctx, "Service.DoWork")
	defer span.End()

	return s.repo.Query(ctx)
}

El context lleva el span de traza activo, y el repositorio puede crear un span hijo a partir de él. Cada capa añade su propio span sin ninguna transferencia explícita de objetos de tracer. El context hace ese trabajo de manera transparente a través de todo el árbol de llamadas.

Manejo de errores con context

Cuando una operación se detiene debido a la cancelación de un context, preserva esa información. Los patrones de aquí complementan las estrategias de diseño de errores más amplias cubiertas en Arquitectura de manejo de errores en Go.

Ejemplo:

err := svc.DoWork(ctx)
if err != nil {
	if errors.Is(err, context.Canceled) {
		// Client canceled or caller stopped the work.
		return err
	}

	if errors.Is(err, context.DeadlineExceeded) {
		// Timeout.
		return err
	}

	return err
}

No envuelvas los errores de context ciegamente de una manera que los oculte.

Envolver con %w preserva errors.Is, por lo que los llamadores aún pueden detectar la cancelación o el timeout:

if err != nil {
	return fmt.Errorf("query user: %w", err)
}

Reemplazar el error por completo descarta esa información y rompe cualquier llamador que compruebe tipos específicos de error de context:

if err != nil {
	return errors.New("query user failed")
}

Mapeando errores de context a respuestas HTTP

Los errores de context a menudo se mapean a diferentes resultados HTTP.

Ejemplo:

func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, context.Canceled):
		// The client likely went away.
		// Some systems log this as a client closed request.
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)
		return

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
}

No trates la cancelación del cliente como un fallo de la aplicación. Si el usuario cerró la pestaña del navegador, eso no es que tu servicio esté fallando, y registrarlo como un error añade ruido sin señal.

Context en middleware

El middleware HTTP es un lugar común para añadir valores con alcance de solicitud.

Ejemplo de middleware de ID de solicitud:

type requestIDKey struct{}

func WithRequestID(ctx context.Context, requestID string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, requestID)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
	requestID, ok := ctx.Value(requestIDKey{}).(string)
	return requestID, ok
}

func RequestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestID := r.Header.Get("X-Request-ID")
		if requestID == "" {
			requestID = newRequestID()
		}

		ctx := WithRequestID(r.Context(), requestID)

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Este es un buen uso del context. El ID de solicitud pertenece a la solicitud, debería viajar a través de toda la cadena de llamadas, y adjuntarlo a los registros y trazas en cada capa es exactamente el tipo de preocupación de observabilidad transversal para la que están diseñados los valores de context.

Context en pruebas

En las pruebas, evita usar context.Background() ciegamente.

Prefiere t.Context() cuando el trabajo pertenece a la vida útil de la prueba:

func TestService(t *testing.T) {
	ctx := t.Context()

	err := service.DoWork(ctx)
	if err != nil {
		t.Fatal(err)
	}
}

Para el comportamiento de timeout, prueba con un timeout real solo si el timeout es pequeño y significativo.

Para código concurrente y dependiente del tiempo, considera usar testing/synctest. Probando código concurrente de Go con synctest cubre esta herramienta a fondo:

func TestTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
		defer cancel()

		time.Sleep(30 * time.Second)

		if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
			t.Fatalf("got %v, want deadline exceeded", ctx.Err())
		}
	})
}

Esto te permite probar valores de timeout reales sin esperar tiempo real.

Context y errgroup

Para grupos de goroutines que deberían cancelarse juntos, errgroup a menudo es un buen ajuste.

errgroup coordina la cancelación, pero no limita nada por sí mismo: sin un límite, un bucle de mil elementos lanza mil goroutines. Para el lado de la capacidad, SetLimit, canales de semáforo, pools de trabajadores y la forma de fuga que sobrevive a Wait, consulta Limitando la concurrencia en Go.

Ejemplo:

func FetchAll(ctx context.Context, ids []string, client *Client) error {
	g, ctx := errgroup.WithContext(ctx)

	for _, id := range ids {
		id := id

		g.Go(func() error {
			_, err := client.Fetch(ctx, id)
			return err
		})
	}

	return g.Wait()
}

Si una goroutine devuelve un error, el context del grupo se cancela y otras goroutines que respeten ctx.Done() pueden detenerse temprano. Esto es mucho más limpio que gestionar manualmente múltiples goroutines, canales y rutas de cancelación. La frase clave aquí es “respetar el context”. errgroup no puede detener el trabajo que ignora ctx.Done().

Apagado elegante (Graceful shutdown)

El context es central para el apagado elegante.

Una configuración típica de servidor tiene:

  • un context raíz cancelado por señales del SO
  • un servidor HTTP
  • trabajadores en segundo plano
  • un timeout de apagado
  • lógica de limpieza

Ejemplo:

func main() {
	root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	server := &http.Server{
		Addr:    ":8080",
		Handler: routes(),
	}

	go func() {
		<-root.Done()

		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()

		if err := server.Shutdown(shutdownCtx); err != nil {
			slog.Error("server shutdown failed", "err", err)
		}
	}()

	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.Error("server failed", "err", err)
		os.Exit(1)
	}
}

Nota que el context de apagado no es el mismo que el context raíz. El raíz ya está cancelado cuando llega la señal del SO. Un context de timeout separado da al proceso de apagado una cantidad de tiempo acotada para drenar las solicitudes en vuelo antes de forzar la salida, lo cual es la distinción sutil pero importante que hace que el apagado elegante funcione realmente.

Patrones antidependencia comunes (Anti-patterns)

Anti-pattern 1: Usar el context como contenedor de dependencias

Malo:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)

Pasa las dependencias explícitamente.

Anti-pattern 2: Crear context.Background dentro de la lógica de negocio

Malo:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Save(context.Background())
}

Esto rompe la propagación de la cancelación.

Anti-pattern 3: Olvidar cancel

Malo:

ctx, _ := context.WithTimeout(parent, time.Second)

Bueno:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

Anti-pattern 4: Poner parámetros opcionales en el context

Malo:

ctx = context.WithValue(ctx, "includeDeleted", true)

Usa structs de opciones explícitas.

Anti-pattern 5: Pasar el context demasiado profundo al código puro

Malo:

func Add(ctx context.Context, a, b int) int {
	return a + b
}

El cálculo puro no necesita context a menos que sea de larga duración o cancelable.

Anti-pattern 6: Ignorar la cancelación en bucles

Malo:

for item := range items {
	process(item)
}

Mejor:

for item := range items {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err := process(ctx, item); err != nil {
		return err
	}
}

Anti-pattern 7: Tragar los errores de context

Malo:

if err != nil {
	return errors.New("operation failed")
}

Bueno:

if err != nil {
	return fmt.Errorf("operation failed: %w", err)
}

Preserva los errores de cancelación y deadline.

Una lista de verificación práctica para el context

Usa esta lista para el código de backend en Go.

Firmas de función

  • El context es el primer parámetro.
  • El context no se almacena en structs de larga duración.
  • El context no se pasa a funciones auxiliares puras a menos que sea necesario.
  • Nunca se usa context nil.

Cancelación

  • Los bucles de larga duración comprueban ctx.Done().
  • Las goroutines tienen una ruta de apagado.
  • Las vidas útiles de los trabajadores están atadas a un context padre.
  • La cancelación del context se propaga a las llamadas descendientes.

Timeouts

  • Los timeouts de solicitud exterior se establecen en la frontera.
  • Los timeouts de suboperaciones son más pequeños que el presupuesto exterior.
  • Las funciones de cancelación siempre se llaman.
  • Los timeouts no se apilan ciegamente en cada capa.

Valores

  • Los valores de context tienen alcance de solicitud.
  • Las claves usan tipos personalizados, no cadenas simples.
  • Las dependencias no se almacenan en el context.
  • Los parámetros opcionales no se almacenan en el context.

Errores

  • context.Canceled y context.DeadlineExceeded se preservan.
  • Los errores de context se mapean correctamente en las fronteras de la API.
  • La cancelación con conocimiento de causa se usa solo cuando la razón importa.

Pruebas

  • Las pruebas usan t.Context() donde sea apropiado.
  • Las pruebas de timeout evitan los sleeps reales lentos.
  • El comportamiento de timeout concurrente se prueba con testing/synctest cuando sea útil.
  • Se comprueban las fugas de goroutines asegurando que existan rutas de apagado.

Cómo auditar el uso de context en una base de código de Go

Busca estos patrones:

grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .

Luego pregunta:

  • ¿Se usa context.Background() solo en fronteras de nivel superior?
  • ¿Se llaman siempre las funciones de cancelación?
  • ¿Los timeouts se colocan en fronteras sensatas?
  • ¿Los valores de context son realmente con alcance de solicitud?
  • ¿Hay dependencias ocultas en valores de context?
  • ¿Se pueden detener las goroutines?
  • ¿Se preservan los errores de context?

Este es un buen hábito de revisión de código, porque muchos errores de context no son errores de sintaxis. Son errores de vida útil que solo se manifiestan bajo cancelación, carga o condiciones de apagado.

Mis reglas opinadas

Estas reglas son aburridas, pero funcionan.

Regla 1: El context es flujo de control

Usa el context para controlar la cancelación, los deadlines y los metadatos de la solicitud.

No lo uses para contrabandear dependencias.

Regla 2: El llamador posee el presupuesto

Una función normalmente debería respetar el context que recibe.

Solo crea un timeout hijo más corto cuando la suboperación necesite un presupuesto específico más pequeño.

Regla 3: Background pertenece en el borde

Usa context.Background() en main, pruebas y configuración de nivel superior.

No lo uses dentro de métodos de servicio y repositorio para escapar de la cancelación.

Regla 4: Los valores deberían ser aburridos

ID de solicitud, ID de traza, ID de usuario e ID de inquilino pertenecen al context. Conexiones a base de datos, registradores de eventos, structs de configuración y clientes de servicio no. Son dependencias y deberían pasarse explícitamente.

Regla 5: Cada goroutine necesita una vida útil

Si una goroutine comienza, deberías saber exactamente cómo se detiene. El context a menudo es la respuesta correcta, y si no es el context, debería haber algún otro mecanismo claro: un canal, un primitivo de sincronización o una señal explícita.

Pensamientos finales

context.Context no es complicado porque la API sea grande. La API es pequeña. Es complicado porque representa la vida útil, y la vida útil es arquitectura. Cada decisión sobre dónde fluye el context, donde se deriva y donde se detiene es una decisión sobre cómo tu servicio maneja el fallo, la carga y el apagado.

Un context bien usado hace que los servicios de Go sean más fáciles de cancelar, más fáciles de apagar, más fáciles de observar y menos propensos a fugas de goroutines. Un context mal usado oculta dependencias, descarta deadlines y hace que el código sea más difícil de razonar bajo presión.

La conclusión práctica es simple:

Pasa el context hacia abajo.
No lo almacenes.
No reemplaces parámetros explícitos con valores.
Respetar la cancelación.
Usa timeouts en las fronteras.
Siempre llama a cancel.

Eso es el context de Go bien hecho.

Este artículo es parte del clúster Arquitectura de Aplicaciones en Producción, que cubre la estructura de código, el acceso a datos, los patrones de integración y la arquitectura de pruebas para sistemas de producción en Go y Python.

Fuentes

Suscribirse

Recibe nuevas publicaciones sobre sistemas, infraestructura e ingeniería de IA.