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.Canceledocontext.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óncancelpara 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 enctx.Done().- La goroutine que termina primero llama
cancel(), las demás reciben la señal a través dectx.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.NewRequestWithContextasocia el context a la solicitud; cuando el context expira,http.Clientcancela automáticamente la solicitud en ejecución.- Si pasas 2 segundos,
resp, err := http.DefaultClient.Do(req)devuelveerrenvolviendocontext.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()
}
}
WithDeadlinese usa cuando tienes un punto de tiempo específico (por ejemplo, final del día), a diferencia deWithTimeoutque es un intervalo relativo desde ahora.- Esencialmente,
WithTimeout(ctx, d)es solo un envoltorio que llamaWithDeadline(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 usarstringdirectamente porque es fácil colisionar con claves de otros paquetes. WithRequestIDyRequestIDFromContextencapsulan 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.WithValuepara 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 enmain, 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 comoBackground()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
}
ExecContextacepta 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.NewServercrea un servidor ficticio, sin necesidad de mocking complejo.
9. Errores comunes
- No llamar
cancel(): siempre usadefer cancel()inmediatamente después de llamarWithCancel/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 recibectxdel llamador, pierdes la capacidad de cancelación/timeout desde arriba — siempre deriva del context recibido:context.WithTimeout(ctx, ...), nocontext.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.