Solución de problemas de Go Context Timeout: 7 causas raíz de context deadline exceeded y control de precisión
Tu microservicio fue aplastado por context deadline exceeded otra vez
3 AM, el canal de alertas explota — la latencia P99 del servicio de órdenes sube a 5 segundos, el gateway ascendente reporta context deadline exceeded sin parar. Incrementas el timeout de 3s a 5s, de 5s a 10s, pero solo estás posponiendo el problema. Peor aún, descubres miles de fugas de goroutine, el uso de memoria sube constantemente, hasta que finalmente ocurre un OOM kill.
El control de timeout de Go Context no es solo agregar context.WithTimeout. ¿Cuánto debería ser el timeout? ¿Cómo se propaga la cancelación? ¿Cómo se limpian las goroutines hijas? ¿Cómo se cascaden los timeouts en la cadena de microservicios? Sin entender esto, context deadline exceeded te perseguirá repetidamente.
Este artículo parte de 7 causas raíz y te guía a través del pipeline completo configuración de timeout → propagación de cancelación → limpieza de goroutine → control de cascada de microservicios.
Conceptos centrales de Context
| Concepto | Descripción |
|---|---|
| context.Context | Interfaz de la biblioteca estándar de Go que transporta deadline, señal de cancelación y valores con alcance de solicitud |
| context.WithTimeout | Crea un Context hijo con timeout, se cancela automáticamente después de la duración |
| context.WithDeadline | Crea un Context hijo con deadline absoluto |
| context.WithCancel | Crea un Context hijo cancelable manualmente |
| context.WithoutCancel | Go 1.21+, crea un Context hijo no afectado por la cancelación del padre |
| context.AfterFunc | Go 1.21+, ejecuta automáticamente un callback después de la cancelación del Context |
| context.Cause | Go 1.20+, recupera la causa raíz de la cancelación del Context |
Mecanismo de propagación de timeout
Request Chain:
Gateway(5s) → OrderService(3s) → PaymentService(2s) → InventoryService(1s)
Propagation Rules:
1. Child Context timeout cannot exceed parent Context
2. Parent Context cancellation auto-cancels all children
3. After timeout, Done channel closes, Err returns deadline exceeded
4. Cancellation is idempotent, multiple Cancel() calls won't error
Análisis del problema: 7 causas raíz de context deadline exceeded
- Timeout demasiado corto: No se consideró la fluctuación de red y la carga del servicio, la latencia P99 excede el umbral de timeout
- Context no propagado: La función acepta Context pero el llamador pasa
context.TODO()ocontext.Background() - Fuga de goroutine: Después de la cancelación del Context, las goroutines hijas no verifican el canal Done, siguen ejecutándose
- Amplificación en cascada del timeout: Cada capa de microservicio establece timeout, el timeout total se comprime capa por capa
- Cliente HTTP no configurado:
http.Client{}no tiene timeout por defecto, las solicitudes pueden bloquearse indefinidamente - Consulta de base de datos sin timeout: El tiempo de ejecución SQL no está controlado, las consultas largas arrastran toda la solicitud
- Sobrescritura de Context: La función interna crea un nuevo Context con
context.Background(), perdiendo la señal de cancelación externa
Paso a paso: Implementación de control de timeout de precisión
Paso 1: Control de timeout básico
package main
import (
"context"
"fmt"
"time"
)
func fetchUserData(ctx context.Context, userID string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resultCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
data, err := queryDatabase(ctx, userID)
if err != nil {
errCh <- err
return
}
resultCh <- data
}()
select {
case data := <-resultCh:
return data, nil
case err := <-errCh:
return "", err
case <-ctx.Done():
return "", fmt.Errorf("fetch user data: %w", ctx.Err())
}
}
func queryDatabase(ctx context.Context, userID string) (string, error) {
select {
case <-time.After(2 * time.Second):
return fmt.Sprintf("user_data_%s", userID), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx := context.Background()
data, err := fetchUserData(ctx, "12345")
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Data: %s\n", data)
}
Paso 2: Propagación de timeout en cadena de microservicios
package middleware
import (
"context"
"time"
)
type TimeoutConfig struct {
GatewayTimeout time.Duration
OrderServiceTimeout time.Duration
PaymentTimeout time.Duration
InventoryTimeout time.Duration
}
func DefaultTimeoutConfig() *TimeoutConfig {
return &TimeoutConfig{
GatewayTimeout: 5 * time.Second,
OrderServiceTimeout: 3 * time.Second,
PaymentTimeout: 2 * time.Second,
InventoryTimeout: 1 * time.Second,
}
}
type contextKey string
const timeoutKey contextKey = "timeout_config"
func WithTimeoutConfig(ctx context.Context, cfg *TimeoutConfig) context.Context {
return context.WithValue(ctx, timeoutKey, cfg)
}
func TimeoutConfigFromContext(ctx context.Context) *TimeoutConfig {
if cfg, ok := ctx.Value(timeoutKey).(*TimeoutConfig); ok {
return cfg
}
return DefaultTimeoutConfig()
}
func DeriveServiceTimeout(ctx context.Context, serviceTimeout time.Duration) (context.Context, context.CancelFunc) {
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining < serviceTimeout {
return context.WithDeadline(ctx, deadline)
}
}
return context.WithTimeout(ctx, serviceTimeout)
}
Paso 3: Prevención de fuga de goroutine
package pool
import (
"context"
"sync"
)
type WorkerPool struct {
maxWorkers int
tasks chan func() error
wg sync.WaitGroup
}
func NewWorkerPool(maxWorkers, queueSize int) *WorkerPool {
return &WorkerPool{
maxWorkers: maxWorkers,
tasks: make(chan func() error, queueSize),
}
}
func (p *WorkerPool) Start(ctx context.Context) {
for i := 0; i < p.maxWorkers; i++ {
p.wg.Add(1)
go func(workerID int) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
case task, ok := <-p.tasks:
if !ok {
return
}
task()
}
}
}(i)
}
}
func (p *WorkerPool) Submit(ctx context.Context, task func() error) error {
select {
case p.tasks <- task:
return nil
case <-ctx.Done():
return ctx.Err()
default:
return fmt.Errorf("task queue is full")
}
}
func (p *WorkerPool) Stop() {
close(p.tasks)
p.wg.Wait()
}
Paso 4: Configuración de timeout del cliente HTTP
package httpclient
import (
"context"
"net"
"net/http"
"time"
)
type ClientConfig struct {
Timeout time.Duration
DialTimeout time.Duration
TLSHandshakeTimeout time.Duration
MaxIdleConns int
MaxIdleConnsPerHost int
IdleConnTimeout time.Duration
}
func DefaultClientConfig() *ClientConfig {
return &ClientConfig{
Timeout: 10 * time.Second,
DialTimeout: 3 * time.Second,
TLSHandshakeTimeout: 3 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
}
func NewClient(cfg *ClientConfig) *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.DialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
IdleConnTimeout: cfg.IdleConnTimeout,
ResponseHeaderTimeout: cfg.Timeout,
}
return &http.Client{
Timeout: cfg.Timeout,
Transport: transport,
}
}
func DoRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("request cancelled: %w", ctx.Err())
}
return nil, fmt.Errorf("request failed: %w", err)
}
return resp, nil
}
Paso 5: Timeout de consulta de base de datos
package db
import (
"context"
"database/sql"
"time"
)
type DBOption struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
QueryTimeout time.Duration
}
func DefaultDBOption() *DBOption {
return &DBOption{
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: 5 * time.Minute,
ConnMaxIdleTime: 1 * time.Minute,
QueryTimeout: 3 * time.Second,
}
}
func OpenDB(driverName, dataSource string, opt *DBOption) (*sql.DB, error) {
db, err := sql.Open(driverName, dataSource)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(opt.MaxOpenConns)
db.SetMaxIdleConns(opt.MaxIdleConns)
db.SetConnMaxLifetime(opt.ConnMaxLifetime)
db.SetConnMaxIdleTime(opt.ConnMaxIdleTime)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
func QueryWithTimeout(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("query timeout after 3s: %w", err)
}
return nil, err
}
return rows, nil
}
Guía de errores comunes
Error 1: Almacenar Context en un Struct
// ❌ Wrong: storing Context in a struct
type UserService struct {
ctx context.Context
}
func (s *UserService) GetUser(id string) error {
return s.queryDatabase(s.ctx, id)
}
// ✅ Correct: pass Context as function parameter
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) error {
return s.queryDatabase(ctx, id)
}
Error 2: Olvidar llamar Cancel causa fuga
// ❌ Wrong: cancel from WithTimeout not called
ctx, _ := context.WithTimeout(parentCtx, 5*time.Second)
result, err := doWork(ctx)
// ✅ Correct: always defer cancel
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
result, err := doWork(ctx)
Error 3: Sobrescribir el Context padre con context.Background()
// ❌ Wrong: inner function uses Background, losing cancellation signal
func processOrder(ctx context.Context, orderID string) error {
innerCtx := context.Background()
return paymentService.charge(innerCtx, orderID)
}
// ✅ Correct: pass parent Context to maintain cancellation propagation
func processOrder(ctx context.Context, orderID string) error {
return paymentService.charge(ctx, orderID)
}
Error 4: La goroutine no verifica el canal Done
// ❌ Wrong: goroutine doesn't respond to cancellation
go func() {
result := heavyComputation()
resultCh <- result
}()
// ✅ Correct: check Context cancellation inside goroutine
go func() {
result, err := heavyComputationWithCtx(ctx)
if err != nil {
return
}
select {
case resultCh <- result:
case <-ctx.Done():
}
}()
Error 5: Conflicto entre timeout del cliente HTTP y del Transport
// ❌ Wrong: Client.Timeout and Transport timeout overlap, may timeout early
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
ResponseHeaderTimeout: 5 * time.Second,
},
}
// ✅ Correct: Client.Timeout controls overall, Transport only connection phase
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
},
}
Solución de problemas de errores
| # | Mensaje de error | Causa | Solución |
|---|---|---|---|
| 1 | context deadline exceeded |
La operación excedió el timeout del Context | Verificar si el timeout es razonable, optimizar consultas/solicitudes lentas |
| 2 | context canceled |
Context cancelado manualmente (se llamó cancel()) | Verificar la fuente de cancelación, confirmar si es comportamiento esperado |
| 3 | grpc: context canceled |
Context cancelado durante una llamada gRPC | Verificar timeout del cliente y tiempo de procesamiento del servidor |
| 4 | net/http: request canceled |
Solicitud HTTP cancelada durante la transferencia | Verificar Client.Timeout y timeout del Context |
| 5 | driver: bad connection |
Conexión de BD expiró durante la consulta | Incrementar ConnMaxLifetime, verificar timeout de QueryContext |
| 6 | i/o timeout |
Operación de I/O de red expiró | Incrementar DialTimeout, verificar conectividad de red |
| 7 | TLS handshake timeout |
Handshake TLS expiró | Incrementar TLSHandshakeTimeout, verificar cadena de certificados |
| 8 | connection reset by peer |
El par cerró la conexión después del timeout | Verificar configuración de timeout del par, asegurar que ambos lados coincidan |
| 9 | goroutine leak detected |
La goroutine no salió después de la cancelación del Context | Verificar ctx.Done() en goroutines, asegurar salida oportuna |
| 10 | queue is full |
Cola de solicitudes llena, envío fallido | Incrementar capacidad de cola, agregar workers, agregar retroalimentación |
Optimización avanzada
1. Control de timeout adaptativo
package adaptive
import (
"context"
"sort"
"sync"
"time"
)
type AdaptiveTimeout struct {
mu sync.Mutex
history []time.Duration
maxHistory int
percentile float64
minTimeout time.Duration
maxTimeout time.Duration
safetyMargin float64
}
func NewAdaptiveTimeout(percentile float64, minTimeout, maxTimeout time.Duration) *AdaptiveTimeout {
return &AdaptiveTimeout{
history: make([]time.Duration, 0, 100),
maxHistory: 100,
percentile: percentile,
minTimeout: minTimeout,
maxTimeout: maxTimeout,
safetyMargin: 1.5,
}
}
func (a *AdaptiveTimeout) Record(duration time.Duration) {
a.mu.Lock()
defer a.mu.Unlock()
a.history = append(a.history, duration)
if len(a.history) > a.maxHistory {
a.history = a.history[1:]
}
}
func (a *AdaptiveTimeout) Timeout() time.Duration {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.history) == 0 {
return a.maxTimeout
}
sorted := make([]time.Duration, len(a.history))
copy(sorted, a.history)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
idx := int(float64(len(sorted)) * a.percentile)
if idx >= len(sorted) {
idx = len(sorted) - 1
}
timeout := time.Duration(float64(sorted[idx]) * a.safetyMargin)
if timeout < a.minTimeout {
timeout = a.minTimeout
}
if timeout > a.maxTimeout {
timeout = a.maxTimeout
}
return timeout
}
func (a *AdaptiveTimeout) Context(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, a.Timeout())
}
2. Middleware de propagación de timeout
package middleware
import (
"context"
"fmt"
"strconv"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
const timeoutMetadataKey = "x-request-timeout-ms"
func TimeoutPropagationInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if deadline, ok := ctx.Deadline(); ok {
remainingMs := time.Until(deadline).Milliseconds()
if remainingMs > 0 {
md, _ := metadata.FromOutgoingContext(ctx)
md = md.Copy()
md.Set(timeoutMetadataKey, fmt.Sprintf("%d", remainingMs))
ctx = metadata.NewOutgoingContext(ctx, md)
}
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
func TimeoutPropagationServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return handler(ctx, req)
}
values := md.Get(timeoutMetadataKey)
if len(values) == 0 {
return handler(ctx, req)
}
remainingMs, err := strconv.ParseInt(values[0], 10, 64)
if err != nil || remainingMs <= 0 {
return handler(ctx, req)
}
remaining := time.Duration(remainingMs) * time.Millisecond
if deadline, ok := ctx.Deadline(); ok {
if time.Until(deadline) < remaining {
remaining = time.Until(deadline)
}
}
ctx, cancel := context.WithTimeout(ctx, remaining)
defer cancel()
return handler(ctx, req)
}
}
3. Detección de fuga de goroutine
package leak
import (
"context"
"log"
"runtime"
"time"
)
type LeakDetector struct {
checkInterval time.Duration
threshold int
}
func NewLeakDetector(checkInterval time.Duration, threshold int) *LeakDetector {
return &LeakDetector{
checkInterval: checkInterval,
threshold: threshold,
}
}
func (d *LeakDetector) Start(ctx context.Context) {
ticker := time.NewTicker(d.checkInterval)
defer ticker.Stop()
var prevGoroutines int
var growthCount int
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
current := runtime.NumGoroutine()
if current > d.threshold {
log.Printf("[LEAK WARNING] goroutine count %d exceeds threshold %d", current, d.threshold)
}
growth := current - prevGoroutines
if growth > 10 {
growthCount++
if growthCount >= 3 {
log.Printf("[LEAK ALERT] goroutine count growing continuously: %d -> %d (%d consecutive growths)", prevGoroutines, current, growthCount)
}
} else {
growthCount = 0
}
prevGoroutines = current
}
}
}
Análisis comparativo
| Dimensión | context.WithTimeout | context.WithDeadline | context.WithCancel | time.After | select+timer |
|---|---|---|---|---|---|
| Precisión de timeout | Milisegundo | Tiempo absoluto | Sin timeout | Milisegundo | Milisegundo |
| Propagación de cancelación | ✅ Automática | ✅ Automática | ✅ Manual | ❌ Ninguna | ❌ Ninguna |
| Seguro para goroutine | ✅ | ✅ | ✅ | ⚠️ Riesgo de fuga | ⚠️ Riesgo de fuga |
| Cascada de microservicios | ✅ | ✅ | ✅ | ❌ | ❌ |
| Propagación de valores | ✅ | ✅ | ✅ | ❌ | ❌ |
| Sobrecarga de recursos | Baja | Baja | Baja | Alta (fuga) | Media |
| Caso de uso | Timeout general | Tareas programadas | Cancelación manual | Espera simple | Timeout local |
Resumen:
context deadline exceededno se resuelve "incrementando el timeout." Entre las 7 causas raíz, las más peligrosas son las fugas de goroutine y la sobrescritura de Context — la primera "sangra" lentamente tu servicio hasta el OOM, la segunda hace inútil el control de timeout. Prácticas de timeout en microservicios Go 2026: 1) Establecer timeout global en el gateway, propagar el tiempo restante vía metadata; 2) Cada capa de servicio usaDeriveServiceTimeouttomando min(timeout propio, tiempo restante); 3) Todas las goroutines deben verificarctx.Done(); 4) Usar timeout adaptativo en lugar de timeout estático; 5) Desplegar detección de fuga de goroutine. Recuerda: el timeout no se trata de ser más largo, se trata de ser preciso.
Herramientas en línea recomendadas
- Formateador JSON: /es-419/json/format
- Codificación/Decodificación Base64: /es-419/encode/base64
- Calculadora Hash: /es-419/encode/hash
- Decodificador JWT: /es-419/encode/jwt-decode
Prueba estas herramientas que se ejecutan en tu navegador — no requieren registro →