Context en Go: timeout, cancel y paso de valores

1. Descripción general

Cuando escribes un servicio Go que maneja múltiples goroutines, necesitas una forma unificada de decirle a las goroutines hijas: “detente, ya no necesito el resultado” o “solo ejecuta durante 2 segundos y luego abandona”. context.Context fue creado precisamente para resolver este problema.

Este artículo cubre tres tareas principales que realizas con context: cancelación manual, establecer timeout/deadline y pasar valores request-scoped a través de capas de llamadas. Cuando termines de leer, podrás escribir handlers HTTP, funciones que llaman a la base de datos y funciones que lanzan goroutines sabiendo que se detendrán en el momento adecuado, sin dejar goroutines con fugas.

2. Qué es Context y por qué lo necesitas

context.Context es una interfaz que describe el “contexto” de un flujo de procesamiento (generalmente una solicitud). Lleva consigo:

  • Una señal de cancelación (cancel signal).
  • Deadline/timeout.
  • Algunos valores key-value pequeños, asociados con la solicitud.
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
  • Done() devuelve un canal, se cierra cuando el context es cancelado o expira.
  • Err() te dice la razón: context.Canceled o context.DeadlineExceeded.
  • Value() obtiene el valor asociado al context.

Nota: Context no se utiliza para pasar parámetros opcionales arbitrarios. Solo debe llevar señales de control y datos request-scoped como request ID, usuario actual — no configuración ni objetos de negocio importantes.

3. Cancelación manual con WithCancel

Úsalo cuando quieras decidir cuándo cancelar, por ejemplo, cuando una de varias goroutines ya tiene un resultado, cancela las demás goroutines.

func fetchFromMultipleSources(ctx context.Context) (string, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // siempre llama cancel para liberar recursos

	resultCh := make(chan string, 1)

	for _, src := range []string{"cache", "db", "api"} {
		go func(source string) {
			select {
			case <-time.After(100 * time.Millisecond):
				resultCh <- source
				cancel() // ya tenemos un resultado, cancela las otras fuentes
			case <-ctx.Done():
				return // cancelado, salir temprano
			}
		}(src)
	}

	select {
	case res := <-resultCh:
		return res, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}
  • context.WithCancel(ctx) crea un context hijo y la función cancel para cancelarlo.
  • defer cancel() es obligatorio: si no lo llamas, el context hijo “cuelga” hasta que el padre sea cancelado, causando una fuga de goroutines esperando en ctx.Done().
  • La goroutine que termina primero llama cancel(), las demás reciben la señal a través de ctx.Done() y se detienen.

Error común: olvidar defer cancel(). Si la función retorna temprano (por ejemplo, por error) sin cancelar el context, la goroutine hijo sigue viva hasta que sea recolectada por el recolector de basura indirectamente — esencialmente es una fuga.

4. Timeout y deadline con WithTimeout, WithDeadline

4.1. WithTimeout para límite de tiempo relativo

func callPaymentService(ctx context.Context, orderID string) error {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://payment.local/orders/"+orderID, nil)
	if err != nil {
		return err
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err // puede ser context.DeadlineExceeded
	}
	defer resp.Body.Close()
	return nil
}
  • context.WithTimeout(ctx, 2*time.Second) cancela automáticamente el context después de 2 segundos.
  • http.NewRequestWithContext asocia el context a la solicitud; cuando el context expira, http.Client cancela automáticamente la solicitud en ejecución.
  • Si pasas 2 segundos, resp, err := http.DefaultClient.Do(req) devuelve err envolviendo context.DeadlineExceeded.

Verificar el error de timeout:

if err != nil {
	if errors.Is(err, context.DeadlineExceeded) {
		log.Println("payment service timeout")
	}
	return err
}

4.2. WithDeadline para punto de tiempo absoluto

func processBeforeMidnight(ctx context.Context) error {
	deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.Local)
	ctx, cancel := context.WithDeadline(ctx, deadline)
	defer cancel()

	select {
	case <-time.After(5 * time.Hour):
		return errors.New("ya se procesó")
	case <-ctx.Done():
		return ctx.Err()
	}
}
  • WithDeadline se usa cuando tienes un punto de tiempo específico (por ejemplo, final del día), a diferencia de WithTimeout que es un intervalo relativo desde ahora.
  • Esencialmente, WithTimeout(ctx, d) es solo un envoltorio que llama WithDeadline(ctx, time.Now().Add(d)).

5. Pasar valores a través de context con WithValue

Úsalo para datos asociados con la solicitud, no parámetros de negocio.

type requestIDKey struct{}

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

func RequestIDFromContext(ctx context.Context) (string, bool) {
	id, ok := ctx.Value(requestIDKey{}).(string)
	return id, ok
}
func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		ctx := WithRequestID(r.Context(), id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func orderHandler(w http.ResponseWriter, r *http.Request) {
	if id, ok := RequestIDFromContext(r.Context()); ok {
		log.Printf("[%s] procesando orden", id)
	}
}
  • Usa type requestIDKey struct{} como clave privada — evita usar string directamente porque es fácil colisionar con claves de otros paquetes.
  • WithRequestID y RequestIDFromContext encapsulan la lógica de set/get, otros paquetes no necesitan saber cuál es la clave real.
  • r.WithContext(ctx) crea una nueva solicitud con el context que lleva el valor asociado, pasándola a través de la cadena de middleware.

Nota: No uses context.WithValue para pasar *sql.DB, configuración, o cualquier cosa que la función necesite para funcionar correctamente. Esas cosas deben ser parámetros explícitos o campos de struct. Los valores de context solo son apropiados para datos “nice to have” como request ID, trace ID, usuario autenticado.

6. context.TODO y context.Background

func main() {
	ctx := context.Background() // context raíz, nunca será cancelado
	run(ctx)
}

func legacyFunctionBeingRefactored() {
	ctx := context.TODO() // marcador de posición, se reemplazará con el context real después
	_ = ctx
}
  • context.Background() es el context raíz, se usa en main, tests, o donde se inicializa el nivel superior.
  • context.TODO() se usa cuando estás refactorizando y no estás seguro de qué context pasar — funciona como Background() pero señala claramente la intención al lector del código y a herramientas linter.

7. Propagación de context a través de múltiples capas de llamadas

Convención: ctx context.Context siempre es el primer parámetro.

func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
	if err := s.validate(ctx, order); err != nil {
		return err
	}
	return s.repo.Save(ctx, order)
}

func (r *OrderRepository) Save(ctx context.Context, order Order) error {
	_, err := r.db.ExecContext(ctx,
		"INSERT INTO orders (id, total) VALUES (?, ?)", order.ID, order.Total)
	return err
}
  • ExecContext acepta context, si la solicitud HTTP de arriba es cancelada (el cliente cierra la conexión), el driver de BD cancela inmediatamente la consulta en ejecución — evitando mantener conexiones innecesarias.
  • Regla: cualquier función que llame I/O (HTTP, DB, gRPC) debe recibir context y pasarlo a las APIs que lo soportan (sufijo *Context).

8. Verificar el comportamiento con tests

func TestFetchFromMultipleSources_ReturnsFastestSource(t *testing.T) {
	ctx := context.Background()

	result, err := fetchFromMultipleSources(ctx)

	require.NoError(t, err)
	assert.Contains(t, []string{"cache", "db", "api"}, result)
}

func TestCallPaymentService_TimesOutWhenSlow(t *testing.T) {
	slowServer := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			time.Sleep(3 * time.Second)
		}))
	defer slowServer.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
	_, err := http.DefaultClient.Do(req)

	assert.ErrorIs(t, err, context.DeadlineExceeded)
}
<!-- go.mod: añade librería de assertions (no es XML, solo una nota) -->
go get github.com/stretchr/testify
  • El primer test usa context.Background() porque no necesitas controlar el timeout en un test unitario normal.
  • El segundo test intenta establecer un timeout de 500ms más corto que el tiempo de procesamiento del servidor (3s) para confirmar que el error devuelto es realmente context.DeadlineExceeded.
  • httptest.NewServer crea un servidor ficticio, sin necesidad de mocking complejo.

9. Errores comunes

  • No llamar cancel(): siempre usa defer cancel() inmediatamente después de llamar WithCancel/WithTimeout/WithDeadline, incluso si el context expirará automáticamente — Go vet te advertirá si lo olvidas.
  • Guardar context en un campo de struct: context debe pasarse como parámetro de función, no almacenarse como campo de struct (excepto en casos especiales como http.Request). Almacenarlo en un campo fácilmente lleva a un context viejo, obsoleto.
  • Usar context para pasar parámetros obligatorios: si la función no funciona correctamente sin ese valor, pásalo como parámetro regular, no lo escondas en context.
  • Crear un nuevo context en lugar de usar el context padre: si llamas context.Background() dentro de una función que recibe ctx del llamador, pierdes la capacidad de cancelación/timeout desde arriba — siempre deriva del context recibido: context.WithTimeout(ctx, ...), no context.WithTimeout(context.Background(), ...).

Cuándo no necesitas context: para funciones puras (sin I/O, sin goroutines), no necesitas context como parámetro — agregarlo solo causa ruido.

10. Conclusión

Context resuelve tres tareas clave en concurrencia de Go: señalización de cancelación, imposición de límites de tiempo y transporte de pocos valores a través de capas de llamadas. La regla más segura es siempre recibir ctx como el primer parámetro, siempre usar defer cancel(), y solo usar WithValue para datos realmente auxiliares request-scoped. Dominando estos principios, podrás escribir servicios Go sin fugas de goroutines y que responden correctamente cuando el cliente agota el timeout o cancela la solicitud.

golangcontextconcurrencygoroutinetimeout