El contexto de Go usado correctamente: cancelación, tiempos de espera y valores
El context de Go es para el flujo de control, no para el almacenamiento.
El context.Context de Go es lo suficientemente simple para usarse mal, y ese es el problema.
La mayoría de los desarrolladores de Go aprenden rápidamente las reglas superficiales: pasar el contexto como primer argumento, verificar 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 contexto no es solo una convención de parámetros; es el plano de control para el ciclo de vida de la solicitud.

El contexto 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 necesitan cruzar los límites de la API. Usado bien, previene fugas de goroutines, evita trabajo desperdiciado, propaga los plazos y hace que los servicios sean más fáciles de detener. Usado mal, se convierte en un saco de dependencias ocultas, globales falsas, tiempos de espera olvidados, temporizadores filtrados y comportamientos de cancelación confusos.
La versión ligeramente de opinión es esta: use el contexto para la cancelación, los plazos y los metadatos con alcance de solicitud, y no lo use como contenedor de dependencias.
Para qué sirve el contexto
El paquete context tiene tres trabajos principales: cancelación, plazos y tiempos de espera, y valores con alcance de solicitud; y esos tres trabajos cubren todo para lo que está diseñado.
Un contexto debería responder preguntas como:
¿Se ha cancelado esta solicitud?
¿Cuánto tiempo le queda a esta operación?
¿Qué ID de solicitud debe adjuntarse a los registros?
¿Qué usuario autenticado está asociado con esta solicitud?
Un contexto no debería responder preguntas como:
¿Dónde está mi conexión a la base de datos?
¿Dónde está mi registrador (logger)?
¿Dónde está mi configuración?
¿Qué implementación de servicio debo usar?
Esas son dependencias; páselas explícitamente a través de los parámetros de la función (consulte Inyección de Dependencias en Go para conocer patrones sobre cómo hacer esto de manera limpia). El contexto es para el ciclo de vida de la solicitud y los metadatos de la solicitud, no para el cableado de la aplicación.
La forma básica del contexto
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 contexto se cancela o expira su plazo.Err()explica por qué terminó el contexto.Deadline()le indica si el contexto tiene un plazo.Value()almacena datos con alcance de solicitud.
La mayoría del código no implementa esta interfaz. Recibe un contexto y lo pasa hacia abajo.
La primera regla: pasar el contexto explícitamente
Para funciones que realizan trabajo con alcance de solicitud o cancelable, pase el contexto como primer parámetro; esta es la convención estándar de Go y lo que cada biblioteca y herramienta del ecosistema esperan:
func GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Haga esto para funciones que puedan:
- Llamar a una base de datos
- Llamar a otro servicio
- Esperar en una cola
- Iniciar trabajo en segundo plano
- Bloquearse en E/S
- Usar un tiempo de espera
- Necesitar valores con alcance de solicitud
- Necesitar cancelación
No agregue contexto a funciones puras diminutas que no lo necesiten.
Esto está bien:
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
No todas las funciones necesitan un contexto. Agregar contexto en todas partes hace que el código sea ruidoso.
No almacene el contexto en structs
Almacenar un contexto en un struct es uno de los errores más comunes en los repositorios de código de Go, y vale la pena señalarlo explícitamente. No haga esto:
type UserService struct {
ctx context.Context
db *sql.DB
}
Haga esto en su lugar:
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Un contexto 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 esos ciclos de vida hace que la cancelación sea poco clara y dificulta razonar sobre a qué operación pertenece un contexto.
Hay raras excepciones para tipos que representan genuinamente el ciclo de vida de una sola operación, pero son tan raras que la regla por defecto debería ser simple:
Pase el contexto. No lo almacene.
No pase un contexto nil
Nunca pase nil como contexto.
Mal:
err := svc.DoWork(nil)
Use context.Background() cuando no haya un contexto existente:
err := svc.DoWork(context.Background())
En las pruebas, use el contexto de prueba cuando sea posible:
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Un contexto nil puede causar un pánico cuando el código llama a métodos en él. Un contexto de fondo es explícito y seguro.
Contextos de fondo, TODO y de solicitud
Hay tres puntos de partida comunes.
context.Background
Use context.Background() en el nivel superior de un programa cuando no exista un contexto padre; es el contexto raíz del cual se derivan todos los contextos secundarios:
func main() {
ctx := context.Background()
_ = run(ctx)
}
o:
func TestSomething(t *testing.T) {
ctx := context.Background()
_ = ctx
}
context.TODO
Use context.TODO() cuando sepa que se debe usar un contexto pero aún no haya decidido cuál.
ctx := context.TODO()
Esto es útil durante la migración, pero no debería convertirse en permanente si existe un contexto real.
Contexto de solicitud
En los servidores HTTP, use el contexto de la solicitud:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
El contexto 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, este suele ser el contexto que debe pasar al código de la aplicación.
Cancelación con context.WithCancel
Use context.WithCancel cuando desee detener el trabajo explícitamente.
ctx, cancel := context.WithCancel(parent)
defer cancel()
La función cancel devuelta cancela el contexto secundario y libera los recursos asociados con él. Siempre llámelo cuando haya terminado; incluso si el contexto eventualmente excederá el tiempo de espera, llamar a cancel antes evita mantener los recursos activos por 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:
- Derive un contexto secundario.
- Defer cancel.
- Pase el contexto secundario al trabajo que debería detenerse junto con él.
- Observe
ctx.Done().
Tiempos de espera con context.WithTimeout
Use context.WithTimeout cuando una operación tenga 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 hace que el tiempo de espera sea parte de la operación, no una configuración global oculta.
Siempre llame a cancel
Cuando llame a WithCancel, WithTimeout o WithDeadline, siempre llame a la función cancel devuelta; esto es importante para la corrección.
Bien:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Mal:
ctx, _ := context.WithTimeout(parent, 5*time.Second)
No llamar a cancel puede mantener los temporizadores y los contextos secundarios activos por más tiempo del necesario.
Plazos vs tiempos de espera
Un tiempo de espera es relativo:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Un plazo es absoluto:
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
La mayoría del código de la aplicación usa tiempos de espera. Los plazos son útiles cuando una solicitud tiene un tiempo final fijo que debe compartirse entre múltiples operaciones; por ejemplo, si una solicitud tiene 900 milisegundos restantes, no asigne un tiempo de espera fresco de 1 segundo a cada llamada aguas abajo; propague el presupuesto restante en su lugar.
Presupuestos de tiempo de espera en capas de servicio
Un error común es apilar tiempos de espera 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 generalmente debería respetar el plazo 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)
}
Agregue un tiempo de espera secundario solo cuando una sub-operació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 sencillo: toda la solicitud tiene un presupuesto externo, las sub-operaciones específicas pueden tener presupuestos más pequeños recortados de ese presupuesto, y ninguna capa extiende silenciosamente la solicitud más allá de lo que el llamador pretendía.
Verifique ctx.Err() para distinguir la cancelación del tiempo de espera
Cuando un contexto termina, ctx.Err() devuelve la razón.
Generalmente es una de:
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 tiempo de espera, 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 plazo excedido generalmente significa que su servicio fue demasiado lento; no siempre deben registrarse, reintentarse o informarse de la misma manera.
Use context.Cause para mejores razones de cancelación
El Go moderno también admite cancelación consciente de la causa.
Las funciones útiles incluyen:
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
El ctx.Err() plano le dice la razón general: cancelado o plazo excedido.
context.Cause(ctx) puede indicarle 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)
}
Use la cancelación consciente de la causa cuando la razón sea importante para los llamadores, los registros o el comportamiento de limpieza, y evítela donde un ctx.Err() plano sea suficiente; el detalle extra solo vale la pena cuando el diagnóstico realmente lo requiere.
Ejemplo de servidor HTTP
Un manejador HTTP normal debería comenzar desde r.Context(). Para una guía completa sobre cómo estructurar servicios HTTP de Go, consulte Construcción de APIs REST en Go.
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 contexto:
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 contexto:
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 contexto a la siguiente:
No rompa la cadena creando context.Background() en el medio.
El error de context.Background(): romper 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 plazo del llamador. Si el cliente se desconecta, la consulta a la base de datos continúa ejecutándose. Si la solicitud excede el tiempo de espera, el trabajo aguas abajo aún puede estar en tránsito. Si el servidor se está apagando, este código lo ignora por completo. Reemplazar el contexto recibido con context.Background() dentro de la lógica de negocio casi siempre es incorrecto.
Use el contexto que se le dio:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Solo use context.Background() en el borde donde no exista un contexto padre.
Ejemplo de cliente HTTP
Para solicitudes HTTP salientes, adjunte el contexto 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 haga esto:
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
Eso crea una solicitud sin el contexto de la operación.
También evite confiar solo en http.Client.Timeout. Puede ser útil como límite de seguridad, pero los contextos de solicitud le 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)
}
Use esto cuando la llamada a la API aguas abajo tenga un presupuesto específico dentro de una solicitud más grande.
Ejemplo de base de datos
La mayoría de las API de bases de datos de Go tienen métodos conscientes del contexto. Para una visión más amplia de cómo las bibliotecas de acceso a datos de Go manejan el contexto, incluyendo GORM, Ent, Bun y sqlc, consulte Comparación de ORMs de Go para PostgreSQL.
Úselos.
Bien:
rows, err := db.QueryContext(ctx, query, args...)
Bien:
err := db.QueryRowContext(ctx, query, id).Scan(&name)
Bien:
result, err := db.ExecContext(ctx, query, args...)
Mal:
rows, err := db.Query(query, args...)
Las formas conscientes del contexto permiten que las operaciones de la base de datos se detengan cuando la solicitud se cancela o excede el tiempo de espera, 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 contexto
Las transacciones necesitan un manejo cuidadoso del contexto.
Una transacción generalmente debería comenzar con el contexto de la operación:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Luego use el mismo contexto para las operaciones de la transacción:
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
Tenga cuidado con los tiempos de espera alrededor de las transacciones. Si el contexto se cancela antes de Commit, la transacción puede revertirse. Eso puede ser lo que desea, pero debe ser intencional.
Para transacciones largas, la mejor respuesta generalmente no es un tiempo de espera más largo; es una transacción más corta que hace menos trabajo por unidad.
Trabajadores en segundo plano y contexto
Los trabajadores en segundo plano deberían recibir un contexto que represente su ciclo de vida.
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 contexto se cancela, y su temporizador se limpia correctamente mediante defer ticker.Stop(). En main, crearía un contexto raíz vinculado a las señales del sistema operativo:
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 contexto utilizado correctamente: describe el ciclo de vida del trabajo del proceso, y cuando el sistema operativo envía una señal, todo el árbol de goroutines que comparten este contexto se detendrán juntos.
Prevención de fugas de goroutines con cancelación de contexto
Una fuga de goroutine ocurre cuando una goroutine permanece bloqueada para siempre después de que ya no es útil.
El contexto ayuda a prevenir esto.
Mal:
func StartWorker() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
}
Esta goroutine no tiene 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 bucee casi siempre debería tener una ruta de cancelación.
Eso no significa que cada goroutine deba recibir el contexto directamente, pero el sistema debería tener una forma clara de detenerla.
context.AfterFunc
context.AfterFunc ejecuta una función después de que un contexto se cancela.
Puede ser útil para la limpieza, desbloquear operaciones o conectar APIs que no admiten nativamente el contexto.
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
}
}
Use AfterFunc con cuidado; inicia 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 la aplicación, un select normal en ctx.Done() es más claro y más fácil de razonar. AfterFunc es más valioso cuando necesita adaptar la cancelación del contexto a una API que ya no acepta contexto.
context.WithoutCancel
context.WithoutCancel crea un contexto que no se cancela cuando el padre se cancela.
Esto es útil, pero también es fácil de mal usar.
Ejemplo de caso de uso:
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 la auditoría puede necesitar continuar brevemente incluso después de que el contexto de la solicitud se cancele. Esto debería ser raro y deliberado; no use WithoutCancel como una forma de evitar lidiar con la cancelación. Úselo solo cuando el trabajo secundario realmente deba sobrevivir a la cancelación del padre, y siempre agregue un nuevo tiempo de espera: un contexto que ignora la cancelación pero no lleva un plazo puede crear fácilmente fugas de goroutines en segundo plano.
Valores de contexto hechos correctamente
Los valores de contexto son para datos con alcance de solicitud que cruzan los límites de la API.
Buenos ejemplos:
- ID de solicitud
- ID de traza
- ID de usuario autenticado
- ID de inquilino (tenant)
- Localización
- Principio de seguridad
- Metadatos de correlación
Malos ejemplos:
- Conexión a la base de datos
- Registrador como dependencia oculta
- Banderas de características 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 el contexto de observabilidad, puede pertenecer al contexto. Si es una dependencia que su código necesita para hacer su trabajo, pásela explícitamente.
Use claves tipificadas para valores de contexto
No use cadenas simples como claves de contexto.
Mal:
ctx = context.WithValue(ctx, "userID", "123")
Esto puede colisionar con otros paquetes.
Use 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 le da seguridad de tipos en el límite del paquete, evita colisiones de claves con otros paquetes y mantiene la superficie de la API del contexto limpia con funciones de acceso tipificadas.
No use valores de contexto 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.
Prefiera parámetros explícitos:
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
Los valores de contexto no deberían reemplazar los argumentos de la 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 tendrá idea de que el parámetro existe.
Registro y contexto
Hay dos enfoques comunes para el registro con contexto. Los ejemplos 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, consulte 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 explícito como una dependencia adecuada y usa el contexto solo para valores con alcance de solicitud que legítimamente necesitan cruzar los límites de la API.
Enfoque 2: Almacenar el registrador en el contexto
Algunos repositorios de código almacenan un registrador en el contexto.
Esto puede ser conveniente, pero no lo recomiendo como predeterminado. Convierte el contexto en un contenedor de dependencias.
Mi preferencia:
- Pase las dependencias del registrador explícitamente.
- Almacene los IDs de traza y de solicitud en el contexto.
- Agregue esos valores a los registros en los límites o middleware.
Esto mantiene las dependencias visibles.
Contexto y trazabilidad
La trazabilidad es uno de los casos de uso más fuertes para los valores de contexto, y es un ajuste genuinamente bueno. OpenTelemetry y sistemas similares usan el contexto para propagar spans de traza a través de llamadas a funciones y límites de proceso, porque los datos de traza son exactamente el tipo de metadatos con alcance de solicitud para los que el contexto fue diseñado.
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 contexto lleva el span de traza activo, y el repositorio puede crear un span secundario a partir de él. Cada capa agrega su propio span sin pasar explícitamente objetos de trazador; el contexto hace ese trabajo de manera transparente en todo el árbol de llamadas.
Manejo de errores con contexto
Cuando una operación se detiene debido a la cancelación del contexto, preserve esa información. Los patrones 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 envuelva ciegamente errores de contexto 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 tiempo de espera:
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Reemplazar el error por completo descarta esa información y rompe cualquier llamador que verifique tipos de error de contexto específicos:
if err != nil {
return errors.New("query user failed")
}
Mapeo de errores de contexto a respuestas HTTP
Los errores de contexto 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 trate la cancelación del cliente como un fallo de la aplicación; si el usuario cerró la pestaña del navegador, eso no es un mal comportamiento de su servicio, y registrarlo como un error agrega ruido sin señal.
Contexto en middleware
El middleware HTTP es un lugar común para agregar 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 contexto. 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 que los valores de contexto están diseñados para soportar.
Contexto en pruebas
En las pruebas, evite usar context.Background() ciegamente.
Prefiera t.Context() cuando el trabajo pertenezca al ciclo de vida 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 tiempo de espera, pruebe con un tiempo de espera real solo si el tiempo de espera es pequeño y significativo.
Para código concurrente y dependiente del tiempo, considere usar testing/synctest; Pruebas de Código Concurrente de Go con synctest cubre esta herramienta en profundidad:
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 le permite probar valores reales de tiempo de espera sin esperar tiempo real.
Contexto y errgroup
Para grupos de goroutines que deberían cancelarse juntos, errgroup a menudo es un buen ajuste.
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 contexto del grupo se cancela y otras goroutines que respeten ctx.Done() pueden detenerse temprano. Esto es mucho más limpio que administrar manualmente múltiples goroutines, canales y rutas de cancelación. La frase clave aquí es “respetar el contexto”; errgroup no puede detener el trabajo que ignora ctx.Done().
Apagado elegante
El contexto es central para el apagado elegante.
Una configuración típica de servidor tiene:
- un contexto raíz cancelado por señales del sistema operativo
- un servidor HTTP
- trabajadores en segundo plano
- un tiempo de espera 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)
}
}
Note que el contexto de apagado no es el mismo que el contexto raíz; el raíz ya está cancelado cuando llega la señal del sistema operativo. Un contexto de tiempo de espera separado da al proceso de apagado una cantidad acotada de tiempo para drenar las solicitudes en tránsito antes de salir a la fuerza, lo cual es la distinción sutil pero importante que hace que el apagado elegante funcione realmente.
Antipatrones comunes
Antipatrón 1: Usar el contexto como contenedor de dependencias
Mal:
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)
Pase las dependencias explícitamente.
Antipatrón 2: Crear context.Background dentro de la lógica de negocio
Mal:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Save(context.Background())
}
Esto rompe la propagación de la cancelación.
Antipatrón 3: Olvidar cancel
Mal:
ctx, _ := context.WithTimeout(parent, time.Second)
Bien:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Antipatrón 4: Poner parámetros opcionales en el contexto
Mal:
ctx = context.WithValue(ctx, "includeDeleted", true)
Use structs de opciones explícitas.
Antipatrón 5: Pasar el contexto demasiado profundo en código puro
Mal:
func Add(ctx context.Context, a, b int) int {
return a + b
}
El cálculo puro no necesita contexto a menos que sea de larga ejecución o cancelable.
Antipatrón 6: Ignorar la cancelación en bucles
Mal:
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
}
}
Antipatrón 7: Engullir errores de contexto
Mal:
if err != nil {
return errors.New("operation failed")
}
Bien:
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
Preserve los errores de cancelación y plazo.
Una lista de verificación práctica de contexto
Use esta lista de verificación para código backend de Go.
Firmas de función
- El contexto es el primer parámetro.
- El contexto no se almacena en structs de larga vida.
- El contexto no se pasa a funciones auxiliares puras a menos que sea necesario.
- Nunca se usa un contexto nil.
Cancelación
- Los bucles de larga ejecución verifican
ctx.Done(). - Las goroutines tienen una ruta de apagado.
- Los ciclos de vida de los trabajadores están vinculados a un contexto padre.
- La cancelación del contexto se propaga a las llamadas aguas abajo.
Tiempos de espera
- Los tiempos de espera de la solicitud externa se establecen en el límite.
- Los tiempos de espera de las sub-operaciones son más pequeños que el presupuesto externo.
- Las funciones cancel siempre se llaman.
- Los tiempos de espera no se apilan ciegamente en cada capa.
Valores
- Los valores de contexto tienen alcance de solicitud.
- Las claves usan tipos personalizados, no cadenas simples.
- Las dependencias no se almacenan en el contexto.
- Los parámetros opcionales no se almacenan en el contexto.
Errores
context.Canceledycontext.DeadlineExceededse preservan.- Los errores de contexto se mapean correctamente en los límites de la API.
- La cancelación consciente de la causa se usa solo cuando la razón es importante.
Pruebas
- Las pruebas usan
t.Context()donde corresponde. - Las pruebas de tiempo de espera evitan descansos reales lentos.
- El comportamiento de tiempo de espera concurrente se prueba con
testing/synctestcuando es útil. - Las fugas de goroutines se verifican asegurando que existan rutas de apagado.
Cómo auditar el uso de contexto en un repositorio de código de Go
Busque estos patrones:
grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .
Luego pregunte:
- ¿Se usa
context.Background()solo en los límites de nivel superior? - ¿Se llaman siempre las funciones cancel?
- ¿Se colocan los tiempos de espera en límites sensatos?
- ¿Los valores de contexto realmente tienen alcance de solicitud?
- ¿Se ocultan dependencias en valores de contexto?
- ¿Se pueden detener las goroutines?
- ¿Se preservan los errores de contexto?
Este es un buen hábito de revisión de código, porque muchos errores de contexto no son errores de sintaxis; son errores de ciclo de vida que solo aparecen bajo condiciones de cancelación, carga o apagado.
Mis reglas de opinión
Estas reglas son aburridas, pero funcionan.
Regla 1: El contexto es flujo de control
Use el contexto para controlar la cancelación, los plazos y los metadatos de la solicitud.
No lo use para contrabandear dependencias.
Regla 2: El llamador posee el presupuesto
Una función generalmente debería respetar el contexto que recibe.
Solo cree un tiempo de espera secundario más corto cuando la sub-operación necesite un presupuesto específico más pequeño.
Regla 3: Background pertenece al borde
Use context.Background() en main, pruebas y configuración de nivel superior.
No lo use dentro de los métodos de servicio y repositorio para escapar de la cancelación.
Regla 4: Los valores deberían ser aburridos
El ID de solicitud, ID de traza, ID de usuario e ID de inquilino pertenecen al contexto. Las conexiones de base de datos, registradores, structs de configuración y clientes de servicio no; son dependencias y deberían pasarse explícitamente.
Regla 5: Cada goroutine necesita un ciclo de vida
Si una goroutine comienza, debería saber exactamente cómo se detiene. El contexto a menudo es la respuesta correcta, y si no es el contexto, debería haber algún otro mecanismo claro; un canal, una primitiva de sincronización o una señal explícita.
Pensamientos finales
context.Context no es complicado porque la API es grande; la API es pequeña. Es complicado porque representa el ciclo de vida, y el ciclo de vida es arquitectura. Cada decisión sobre dónde fluye el contexto, dónde se deriva y dónde se detiene es una decisión sobre cómo su servicio maneja el fallo, la carga y el apagado.
Un contexto bien utilizado hace que los servicios de Go sean más fáciles de cancelar, más fáciles de detener, más fáciles de observar y menos propensos a filtrar goroutines. Un contexto mal utilizado oculta dependencias, descarta plazos y hace que el código sea más difícil de razonar bajo presión.
La conclusión práctica es simple:
Pass context down.
Do not store it.
Do not replace explicit parameters with values.
Respect cancellation.
Use timeouts at boundaries.
Always call cancel.
Eso es el contexto de Go hecho correctamente.
Este artículo es parte del clúster Arquitectura de Aplicaciones en Producción, que cubre la estructura del código, el acceso a datos, patrones de integración y arquitectura de pruebas para sistemas de Go y Python en producción.