Timeout propagation em cadeias de chamadas

1. Fundamentos de Timeout em Sistemas Distribuídos

1.1. O problema do efeito "cascata" de timeouts não gerenciados

Em sistemas distribuídos, uma requisição frequentemente atravessa múltiplos serviços e camadas. Sem uma estratégia de propagação de timeout, cada camada pode definir seu próprio timeout fixo, criando um efeito cascata: se o serviço A tem timeout de 2s, B de 3s e C de 4s, a latência total pode chegar a 9s antes que o erro seja detectado. Isso causa degradação progressiva, consumo desnecessário de recursos e experiência ruim para o usuário.

1.2. Conceito de contexto (context.Context) como veículo de propagação

O pacote context do Go é a ferramenta padrão para propagar deadlines, sinais de cancelamento e valores entre goroutines. Um contexto carrega um deadline absoluto que pode ser herdado por contextos filhos, permitindo que o tempo restante seja calculado dinamicamente em cada nível da cadeia.

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

1.3. Diferença entre deadline absoluto e timeout relativo

Um timeout relativo (context.WithTimeout) é convertido internamente em um deadline absoluto (context.WithDeadline). A diferença prática é que o deadline absoluto permite calcular o tempo restante em qualquer ponto da cadeia, enquanto um timeout relativo é definido a partir do momento da criação do contexto.

// Timeout relativo: 2 segundos a partir de agora
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

// Deadline absoluto equivalente
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

2. Propagação de Contexto com Deadline em Chamadas Aninhadas

2.1. Uso de context.WithTimeout e context.WithDeadline em funções pai-filho

Ao criar um contexto filho a partir de um pai com deadline, o filho herda automaticamente o deadline do pai. Se o timeout especificado no filho for maior que o tempo restante do pai, o deadline do pai prevalece.

func handler(ctx context.Context) error {
    // Contexto pai com deadline de 5s
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    result, err := callServiceA(ctx)
    if err != nil {
        return err
    }
    return processResult(ctx, result)
}

func callServiceA(ctx context.Context) (string, error) {
    // Timeout relativo de 3s, mas respeita o deadline do pai
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    // Simulação de chamada externa
    select {
    case <-time.After(2 * time.Second):
        return "ok", nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

2.2. Deadline herdado: como o contexto filho respeita o limite do pai

O Go garante que, se o deadline do pai for mais curto que o timeout do filho, o filho será cancelado quando o deadline do pai expirar. Isso evita que uma operação continue após o prazo máximo estabelecido.

func verifyDeadlineInheritance() {
    parentCtx, parentCancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer parentCancel()

    // Filho tenta definir 10s, mas o pai expira em 1s
    childCtx, childCancel := context.WithTimeout(parentCtx, 10*time.Second)
    defer childCancel()

    deadline, _ := childCtx.Deadline()
    fmt.Printf("Deadline do filho: %v\n", deadline)
    // O deadline será aproximadamente 1s a partir de agora, não 10s
}

2.3. Boas práticas: evitar context.Background() no meio da cadeia

Usar context.Background() em funções intermediárias quebra a cadeia de propagação. Sempre receba e propague o contexto recebido como parâmetro.

// ERRADO: quebra a propagação
func processData(data string) error {
    ctx := context.Background()
    return saveToDatabase(ctx, data)
}

// CORRETO: recebe e propaga o contexto
func processData(ctx context.Context, data string) error {
    return saveToDatabase(ctx, data)
}

3. Cálculo de Timeout Residual (Remaining Time)

3.1. Função context.Deadline() para obter o deadline e calcular o tempo restante

func calculateRemainingTime(ctx context.Context) (time.Duration, error) {
    deadline, ok := ctx.Deadline()
    if !ok {
        // Sem deadline definido, retorna um valor padrão
        return 30 * time.Second, nil
    }

    remaining := time.Until(deadline)
    if remaining <= 0 {
        return 0, context.DeadlineExceeded
    }
    return remaining, nil
}

3.2. Ajuste dinâmico de timeout em cada nó da cadeia

func callWithResidualTimeout(ctx context.Context, serviceName string) error {
    remaining, err := calculateRemainingTime(ctx)
    if err != nil {
        return err
    }

    // Reserva 80% do tempo restante para esta chamada
    callTimeout := time.Duration(float64(remaining) * 0.8)
    callCtx, cancel := context.WithTimeout(ctx, callTimeout)
    defer cancel()

    return makeServiceCall(callCtx, serviceName)
}

3.3. Estratégias de reserva de tempo para operações internas

func processChain(ctx context.Context) error {
    remaining, _ := calculateRemainingTime(ctx)

    // Reserva tempo para processamento local
    localProcessing := 100 * time.Millisecond
    if remaining < localProcessing {
        return context.DeadlineExceeded
    }

    // Tempo disponível para chamadas externas
    externalTime := remaining - localProcessing

    ctx, cancel := context.WithTimeout(ctx, externalTime)
    defer cancel()

    return callExternalServices(ctx)
}

4. Padrões de Propagação com Cancelamento em Cascata

4.1. Cancelamento síncrono: select com <-ctx.Done() e defer cancel()

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

    resultCh := make(chan string, 1)
    go func() {
        resultCh <- expensiveOperation()
    }()

    select {
    case result := <-resultCh:
        fmt.Println("Resultado:", result)
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

4.2. Cancelamento assíncrono: goroutines filhas monitorando o contexto pai

func asyncWorker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker cancelado:", ctx.Err())
            return
        default:
            // Continua trabalhando
            doWork()
        }
    }
}

func startAsyncWorkers(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()

    for i := 0; i < 3; i++ {
        go asyncWorker(ctx)
    }
}

4.3. Tratamento de context.Canceled vs. context.DeadlineExceeded em cada nível

func handleContextError(err error) {
    switch {
    case errors.Is(err, context.DeadlineExceeded):
        log.Println("Timeout excedido - recurso liberado")
    case errors.Is(err, context.Canceled):
        log.Println("Operação cancelada pelo pai")
    default:
        log.Println("Erro genérico:", err)
    }
}

5. Timeout Parcial em Cadeias com Múltiplos Destinos

5.1. Uso de errgroup com contexto compartilhado para fan-out controlado

import "golang.org/x/sync/errgroup"

func fanOutWithTimeout(ctx context.Context, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)

    for _, url := range urls {
        url := url
        g.Go(func() error {
            return fetchURL(ctx, url)
        })
    }

    return g.Wait()
}

5.2. Timeout individual por sub-chamada vs. timeout agregado da cadeia

func mixedTimeouts(ctx context.Context) error {
    // Timeout agregado de 2s para toda a operação
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    g, ctx := errgroup.WithContext(ctx)

    // Cada chamada tem timeout individual de 1s, mas respeita o agregado
    g.Go(func() error {
        callCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
        defer cancel()
        return callServiceA(callCtx)
    })

    g.Go(func() error {
        callCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
        defer cancel()
        return callServiceB(callCtx)
    })

    return g.Wait()
}

5.3. Exemplo prático: serviço que consulta 3 backends com deadline total de 2s

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

    results := make([]string, 0, 3)
    resultCh := make(chan string, 3)
    errCh := make(chan error, 3)

    backends := []string{"backend1", "backend2", "backend3"}
    for _, backend := range backends {
        go func(b string) {
            result, err := queryBackend(ctx, b)
            if err != nil {
                errCh <- err
                return
            }
            resultCh <- result
        }(backend)
    }

    for i := 0; i < len(backends); i++ {
        select {
        case result := <-resultCh:
            results = append(results, result)
        case err := <-errCh:
            log.Printf("Erro em backend: %v", err)
        case <-ctx.Done():
            return results, ctx.Err()
        }
    }

    return results, nil
}

6. Instrumentação e Observabilidade de Timeouts

6.1. Logging do tempo restante e do ponto de falha na cadeia

func instrumentedCall(ctx context.Context, name string) error {
    remaining, _ := calculateRemainingTime(ctx)
    log.Printf("[%s] Tempo restante: %v", name, remaining)

    err := actualCall(ctx)
    if err != nil {
        log.Printf("[%s] Falha: %v, tempo restante: %v", name, err, remaining)
    }
    return err
}

6.2. Métricas: histogramas de duração por camada e contagem de timeouts

var (
    requestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name: "request_duration_seconds",
            Help: "Duração das requisições por camada",
            Buckets: []float64{0.1, 0.5, 1, 2, 5},
        },
        []string{"layer"},
    )
    timeoutCount = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "timeout_total",
            Help: "Total de timeouts por camada",
        },
        []string{"layer"},
    )
)

func monitoredCall(ctx context.Context, layer string) error {
    start := time.Now()
    defer func() {
        requestDuration.WithLabelValues(layer).Observe(time.Since(start).Seconds())
    }()

    err := actualCall(ctx)
    if errors.Is(err, context.DeadlineExceeded) {
        timeoutCount.WithLabelValues(layer).Inc()
    }
    return err
}

6.3. Tracing distribuído (OpenTelemetry) com spans e atributos de deadline

import "go.opentelemetry.io/otel"

func tracedCall(ctx context.Context, name string) error {
    tracer := otel.Tracer("service")
    ctx, span := tracer.Start(ctx, name)
    defer span.End()

    deadline, _ := ctx.Deadline()
    remaining := time.Until(deadline)
    span.SetAttributes(
        attribute.String("deadline", deadline.String()),
        attribute.String("remaining_time", remaining.String()),
    )

    return actualCall(ctx)
}

7. Anti-Padrões e Armadilhas Comuns

7.1. Criar novo contexto com context.WithTimeout em vez de herdar o deadline

// ANTI-PADRÃO: ignora o deadline do pai
func badPattern() {
    ctx := context.Background()
    newCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()
    callService(newCtx)
}

// CORRETO: herda e ajusta o deadline
func goodPattern(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()
    callService(ctx)
}

7.2. Ignorar o erro de timeout e tentar continuar a execução

// ANTI-PADRÃO: continua após timeout
func ignoreTimeout(ctx context.Context) {
    result, err := callService(ctx)
    if err != nil {
        log.Printf("Erro ignorado: %v", err)
    }
    // Continua processando mesmo com timeout
    processResult(result)
}

// CORRETO: propaga o erro
func handleTimeout(ctx context.Context) error {
    result, err := callService(ctx)
    if err != nil {
        return err
    }
    return processResult(result)
}

7.3. Timeout fixo em todas as camadas sem considerar o tempo acumulado

// ANTI-PADRÃO: timeout fixo em todas as camadas
func layer1() error {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    return layer2(ctx)
}

func layer2(ctx context.Context) error {
    // Ignora o contexto recebido e cria novo timeout fixo
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    return layer3(ctx)
}

// CORRETO: propaga o contexto com ajuste dinâmico
func layer1(ctx context.Context) error {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()
    return layer2(ctx)
}

func layer2(ctx context.Context) error {
    remaining, _ := calculateRemainingTime(ctx)
    callTimeout := time.Duration(float64(remaining) * 0.5)
    ctx, cancel := context.WithTimeout(ctx, callTimeout)
    defer cancel()
    return layer3(ctx)
}

Referências