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
- Go Concurrency Patterns: Context — Artigo oficial do Go explicando o pacote context e seu uso para propagação de deadlines e cancelamento.
- Context Package Documentation — Documentação oficial do pacote context do Go, incluindo WithTimeout, WithDeadline e Deadline.
- Go by Example: Context — Exemplos práticos de uso de context para timeout e cancelamento em Go.
- The Go Programming Language: Context — Capítulo do livro "The Go Programming Language" sobre context e propagação de deadlines.
- OpenTelemetry Go Documentation — Documentação oficial do OpenTelemetry para Go, incluindo tracing distribuído com context.
- errgroup Package — Documentação do pacote errgroup para propagação de contexto em goroutines concorrentes.
- Go Timeout Patterns — Artigo técnico sobre padrões de timeout em Go usando context.