Context no Go: timeout, cancel e passagem de valores

1. Visão geral

Ao escrever um serviço Go que lida com muitas goroutines, você precisa de uma forma unificada de dizer à goroutine filha: “pare, não preciso mais do resultado” ou “execute por apenas 2 segundos e depois desista”. context.Context foi criado para resolver exatamente esse problema.

Este artigo passa por três coisas principais que você faz com context: cancelamento (cancel) manual, definir timeout/deadline e passar valores request-scoped através das camadas de chamadas de funções. Após ler, você será capaz de escrever handlers HTTP, funções que chamam DB e funções que chamam goroutines filhas que sabem se parar no momento certo, sem deixar goroutines vazarem.

2. O que é Context e por que você precisa dele

context.Context é uma interface que descreve o “contexto” de uma thread de processamento (geralmente uma requisição). Ele carrega consigo:

  • Sinal de cancelamento (cancel signal).
  • Deadline/timeout.
  • Alguns valores pequenos de chave-valor, anexados à requisição.
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
  • Done() retorna um channel que é fechado quando o context é cancelado ou expira.
  • Err() diz o motivo: context.Canceled ou context.DeadlineExceeded.
  • Value() obtém o valor anexado ao context.

Nota: Context não é usado para passar parâmetros opcionais arbitrários. Ele deve apenas carregar sinais de controle e dados request-scoped como ID de requisição, usuário atual — não config ou objetos de negócio importantes.

3. Cancelamento manual com WithCancel

Use quando você quer decidir quando cancelar, por exemplo, quando uma das muitas goroutines tem um resultado, cancele as goroutines restantes.

func fetchFromMultipleSources(ctx context.Context) (string, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // sempre chame 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() // tem resultado, cancele as outras fontes
			case <-ctx.Done():
				return // foi cancelado, saia cedo
			}
		}(src)
	}

	select {
	case res := <-resultCh:
		return res, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}
  • context.WithCancel(ctx) cria um context filho e uma função cancel para cancelá-lo.
  • defer cancel() é obrigatório: se você não chamar, o context filho fica “pendurado” até que o pai seja cancelado, causando vazamento de goroutine esperando em ctx.Done().
  • Qualquer goroutine que termine primeiro chama cancel(), as goroutines restantes recebem o sinal via ctx.Done() e param.

Erro comum: esquecer de defer cancel(). Se a função retornar cedo (por exemplo, por erro) sem cancelar o context, a goroutine filha ainda vive até ser coletada pelo garbage collector indiretamente — na verdade, é um vazamento.

4. Timeout e deadline com WithTimeout, WithDeadline

4.1. WithTimeout para limite de tempo 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 // pode ser context.DeadlineExceeded
	}
	defer resp.Body.Close()
	return nil
}
  • context.WithTimeout(ctx, 2*time.Second) cancela automaticamente o context após 2 segundos.
  • http.NewRequestWithContext anexa o context à requisição; quando o context expira, http.Client cancela automaticamente a requisição em andamento.
  • Se passar de 2 segundos, resp, err := http.DefaultClient.Do(req) retorna err contendo context.DeadlineExceeded.

Verificando o erro de timeout:

if err != nil {
	if errors.Is(err, context.DeadlineExceeded) {
		log.Println("serviço de pagamento expirou")
	}
	return err
}

4.2. WithDeadline para ponto de tempo 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("processamento concluído")
	case <-ctx.Done():
		return ctx.Err()
	}
}
  • WithDeadline é usado quando você tem um ponto de tempo específico (por exemplo, fim do dia), diferente de WithTimeout que é um intervalo de tempo relativo a partir de agora.
  • Essencialmente, WithTimeout(ctx, d) é apenas um wrapper fino que chama WithDeadline(ctx, time.Now().Add(d)).

5. Passando valores através de context com WithValue

Use para dados anexados à requisição, não para parâmetros de negócio.

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] processando pedido", id)
	}
}
  • Use type requestIDKey struct{} como chave separada — evite usar string diretamente pois é fácil colidir com chave de outro package.
  • WithRequestID e RequestIDFromContext encapsulam a operação set/get, outros packages não precisam saber qual é a chave real.
  • r.WithContext(ctx) cria uma nova requisição carregando o context com valor anexado, passando adiante através da cadeia de middleware.

Nota: Não use context.WithValue para passar *sql.DB, config ou qualquer coisa que a função precisa para funcionar corretamente. Essas coisas devem ser parâmetros explícitos ou campos de struct. Context value é apropriado apenas para dados “nice to have” como ID de requisição, ID de rastreamento, usuário autenticado.

6. context.TODO e context.Background

func main() {
	ctx := context.Background() // context raiz, nunca é cancelado
	run(ctx)
}

func legacyFunctionBeingRefactored() {
	ctx := context.TODO() // placeholder, será substituído pelo context real depois
	_ = ctx
}
  • context.Background() é o context raiz, usado em main, testes ou em inicialização top-level.
  • context.TODO() é usado quando você está refatorando e não tem certeza qual context deve passar — funcionalmente igual a Background(), mas sinaliza claramente a intenção para quem lê o código e para ferramentas linter.

7. Propagando context através de múltiplas camadas de chamadas de funções

Convenção: ctx context.Context é sempre o primeiro 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
}
  • ExecContext aceita context, se a requisição HTTP acima for cancelada (cliente fecha a conexão), o driver DB cancela imediatamente a query em execução — evita manter a conexão desnecessariamente.
  • Regra: qualquer função que chame I/O (HTTP, DB, gRPC) deve aceitar context e passá-lo para as APIs que suportam context (sufixo *Context).

8. Testando o comportamento com teste

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: adicione a biblioteca de assertion (não é XML, apenas uma nota) -->
go get github.com/stretchr/testify
  • O primeiro teste usa context.Background() porque não é necessário controlar timeout em um unit test normal.
  • O teste seguinte define propositalmente um timeout de 500ms menor que o tempo de processamento do servidor (3s) para confirmar que o erro retornado é realmente context.DeadlineExceeded.
  • httptest.NewServer constrói um servidor falso, sem necessidade de mock complexo.

9. Erros comuns

  • Não chamar cancel(): sempre defer cancel() logo após chamar WithCancel/WithTimeout/WithDeadline, mesmo que o context expire sozinho — Go vet avisará se você esquecer.
  • Armazenar context em campo de struct: context deve ser passado como parâmetro de função, não armazenado como campo de struct (exceto em alguns casos especiais como http.Request). Armazenar em campo facilmente leva a context antigo, stale.
  • Usar context para passar parâmetro obrigatório: se a função não funciona corretamente sem esse valor, passe-o como um parâmetro normal, não o esconda em context.
  • Criar novo context ao invés de usar context pai: se você chamar context.Background() dentro de uma função que está recebendo ctx do chamador, você perde a capacidade de cancelamento/timeout de cima — sempre derive do context recebido: context.WithTimeout(ctx, ...), não context.WithTimeout(context.Background(), ...).

Quando você não precisa de context: com funções puras (pure function), sem I/O, sem goroutine, você não precisa de context como parâmetro — adicioná-lo só causa confusão.

10. Conclusão

Context resolve três coisas centrais na concorrência Go: sinalizar cancelamento, aplicar limites de tempo e levar alguns valores pequenos através das camadas de chamadas de funções. A regra mais segura é sempre aceitar ctx como primeiro parâmetro, sempre fazer defer cancel() e usar WithValue apenas para dados request-scoped que são realmente auxiliares. Dominando esses princípios, você será capaz de escrever serviços Go sem vazamento de goroutine e responder adequadamente quando o cliente faz timeout ou cancela a requisição.

golangcontextconcurrencygoroutinetimeout