Contexto Go context.Context Feito Corretamente: Cancelamento, Timeouts e Valores

O contexto do Go é para controle de fluxo, não para armazenamento.

Conteúdo da página

O context.Context do Go é simples o suficiente para ser usado mal — e esse é o problema.

A maioria dos desenvolvedores de Go aprende as regras básicas rapidamente: passar o contexto como o primeiro argumento, verificar ctx.Done(), usar context.WithTimeout e nunca passar nil.

func DoSomething(ctx context.Context) error {
	// ...
}

Essas regras são úteis, mas cobrem apenas a parte fácil. Em serviços de produção, o contexto não é apenas uma convenção de parâmetro — é o plano de controle para o tempo de vida da requisição.

Contexto Go: cancelamento se propaga pela cadeia de chamadas

O contexto informa ao trabalho quando parar, quanto tempo resta, qual caminho de cancelamento foi tomado e quais valores com escopo de requisição precisam atravessar as fronteiras da API. Usado bem, ele impede vazamentos de goroutines, evita trabalho desperdiçado, propaga prazos e facilita o desligamento dos serviços. Usado mal, torna-se um saco de dependências ocultas, globais falsas, timeouts esquecidos, temporizadores vazados e comportamento de cancelamento confuso.

A versão ligeiramente opinativa é esta: use o contexto para cancelamento, prazos e metadados com escopo de requisição, e não o use como um contêiner de dependências.

Para que serve o contexto

O pacote context tem três funções principais — cancelamento, prazos e timeouts, e valores com escopo de requisição — e essas três funções cobrem tudo para o que ele foi projetado.

Um contexto deve responder a perguntas como:

Esta requisição foi cancelada?
Quanto tempo resta para esta operação?
Qual ID de requisição deve ser anexado aos logs?
Qual usuário autenticado está associado a esta requisição?

Um contexto não deve responder a perguntas como:

Onde está minha conexão com o banco de dados?
Onde está meu logger?
Onde está minha configuração?
Qual implementação de serviço devo usar?

Essas são dependências — passe-as explicitamente através de parâmetros de função (veja Injeção de Dependência em Go para padrões sobre como fazer isso de forma limpa). O contexto é para o tempo de vida da requisição e metadados da requisição, não para o encaminhamento de aplicações.

A forma básica do contexto

A interface principal é pequena:

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

As partes importantes são:

  • Done() é fechado quando o contexto é cancelado ou seu prazo expira.
  • Err() explica por que o contexto terminou.
  • Deadline() informa se o contexto tem um prazo.
  • Value() armazena dados com escopo de requisição.

A maioria do código não implementa esta interface. Ele recebe um contexto e o passa adiante.

A primeira regra: passe o contexto explicitamente

Para funções que realizam trabalho com escopo de requisição ou cancelável, passe o contexto como o primeiro parâmetro — esta é a convenção padrão do Go e o que todas as bibliotecas e ferramentas do ecossistema esperam:

func GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Faça isso para funções que possam:

  • Chamar um banco de dados
  • Chamar outro serviço
  • Aguardar em uma fila
  • Iniciar trabalho em segundo plano
  • Bloquear em E/S
  • Usar um timeout
  • Precisar de valores com escopo de requisição
  • Precisar de cancelamento

Não adicione contexto a funções puras minúsculas que não o precisam.

Isto está correto:

func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

Nem toda função precisa de um contexto. Adicionar contexto em todos os lugares torna o código barulhento.

Não armazene contexto em structs

Armazenar um contexto em uma struct é um dos erros mais comuns em bases de código Go, e vale a pena destacar explicitamente. Não faça isso:

type UserService struct {
	ctx context.Context
	db  *sql.DB
}

Faça isso em vez disso:

type UserService struct {
	db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Um contexto pertence a uma requisição, operação ou tarefa, enquanto uma struct de serviço geralmente vive muito mais tempo do que qualquer requisição única. Misturar esses tempos de vida torna o cancelamento claro e dificulta o raciocínio sobre qual operação um contexto pertence.

Existem exceções raras para tipos que genuinamente representam o tempo de vida de uma única operação, mas são raras o suficiente para que a regra padrão seja simples:

Passe o contexto. Não o armazene.

Não passe contexto nil

Nunca passe nil como contexto.

Ruim:

err := svc.DoWork(nil)

Use context.Background() quando não houver um contexto existente:

err := svc.DoWork(context.Background())

Em testes, use o contexto de teste quando possível:

func TestDoWork(t *testing.T) {
	err := svc.DoWork(t.Context())
	if err != nil {
		t.Fatal(err)
	}
}

Um contexto nil pode causar pânico quando o código chama métodos nele. Um contexto de fundo é explícito e seguro.

Contextos de fundo, TODO e de requisição

Existem três pontos de partida comuns.

context.Background

Use context.Background() no nível superior de um programa quando não existir um contexto pai — é o contexto raiz a partir do qual todos os contextos filho são derivados:

func main() {
	ctx := context.Background()
	_ = run(ctx)
}

ou:

func TestSomething(t *testing.T) {
	ctx := context.Background()
	_ = ctx
}

context.TODO

Use context.TODO() quando você sabe que um contexto deve ser usado, mas ainda não decidiu qual.

ctx := context.TODO()

Isso é útil durante a migração, mas não deve se tornar permanente se um contexto real existir.

Contexto de requisição

Em servidores HTTP, use o contexto da requisição:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	_ = ctx
}

O contexto da requisição é cancelado quando a conexão do cliente é fechada, a requisição é cancelada ou o servidor termina o tratamento da requisição.

Para serviços web, este é geralmente o contexto que você deve passar para o código da aplicação.

Cancelamento com context.WithCancel

Use context.WithCancel quando quiser parar o trabalho explicitamente.

ctx, cancel := context.WithCancel(parent)
defer cancel()

A função cancel retornada cancela o contexto filho e libera os recursos associados a ele. Sempre a chame quando terminar — mesmo que o contexto vá expirar eventualmente, chamar o cancelamento antecipadamente evita manter recursos vivos por mais tempo do que o necessário.

Exemplo:

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
	}
}

O padrão é simples:

  • Derive um contexto filho.
  • Adie o cancelamento.
  • Passe o contexto filho para o trabalho que deve parar junto.
  • Observe ctx.Done().

Timeouts com context.WithTimeout

Use context.WithTimeout quando uma operação tiver uma duração máxima.

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

Exemplo com um 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)
}

Isso torna o timeout parte da operação, não uma configuração global oculta.

Sempre chame cancel

Quando você chamar WithCancel, WithTimeout ou WithDeadline, sempre chame a função de cancelamento retornada — isso é importante para a correção.

Bom:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

Ruim:

ctx, _ := context.WithTimeout(parent, 5*time.Second)

Falhar em chamar cancel pode manter temporizadores e contextos filhos vivos por mais tempo do que o necessário.

Prazos vs timeouts

Um timeout é relativo:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

Um prazo é absoluto:

deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

A maioria do código da aplicação usa timeouts. Prazos são úteis quando uma requisição tem um tempo final fixo que deve ser compartilhado entre várias operações — por exemplo, se uma requisição tem 900 milissegundos restantes, não dê a cada chamada downstream um timeout fresco de 1 segundo; propague o orçamento restante em vez disso.

Orçamentos de timeout entre camadas de serviço

Um erro comum é empilhar timeouts cegamente.

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)
}

Isso parece inofensivo, mas esconde o orçamento real. A camada de serviço geralmente deve respeitar o prazo do chamador em vez de redefinir o temporizador para o mesmo valor.

Um melhor padrão é:

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 {
		// lidar com erro
		return
	}
}

Então, dentro do serviço:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Query(ctx)
}

Adicione um timeout filho apenas quando uma sub-operação precisar de um orçamento menor:

func (s *Service) DoWork(ctx context.Context) error {
	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	return s.repo.Query(queryCtx)
}

O modelo mental correto é direto: a requisição inteira tem um orçamento externo, sub-operações específicas podem ter orçamentos menores desdobrados desse orçamento, e nenhuma camada estende silenciosamente a requisição além do que o chamador pretendia.

Verifique ctx.Err() para distinguir cancelamento de timeout

Quando um contexto termina, ctx.Err() retorna o motivo.

Geralmente é um dos:

context.Canceled
context.DeadlineExceeded

Exemplo:

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return handle(result)
}

Isso permite que os chamadores distingam o cancelamento do timeout, e essa distinção importa na prática. Uma requisição cancelada geralmente significa que o cliente desconectou, enquanto um erro de prazo excedido geralmente significa que seu serviço foi muito lento — eles não devem ser sempre registrados, retratados ou relatados da mesma forma.

Use context.Cause para melhores motivos de cancelamento

O Go moderno também suporta cancelamento ciente da causa.

As funções úteis incluem:

  • context.WithCancelCause
  • context.WithTimeoutCause
  • context.WithDeadlineCause
  • context.Cause

O ctx.Err() simples diz a você o motivo amplo: cancelado ou prazo excedido.

context.Cause(ctx) pode dizer a você a causa mais específica.

Exemplo:

var ErrShutdown = errors.New("server shutting down")

func Run(ctx context.Context) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	go func() {
		// Algum sinal de desligamento chegou.
		cancel(ErrShutdown)
	}()

	<-ctx.Done()

	return context.Cause(ctx)
}

Use o cancelamento ciente da causa quando o motivo importar para chamadores, logs ou comportamento de limpeza, e evite-o onde um ctx.Err() simples seja suficiente — o detalhe extra só vale a pena quando o diagnóstico genuinamente o requer.

Exemplo de servidor HTTP

Um manipulador HTTP normal deve começar com r.Context(). Para um tutorial completo sobre estruturação de serviços HTTP em Go, veja Construindo APIs REST em 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)
	}
}

O serviço deve aceitar e propagar o contexto:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

O repositório deve usar métodos de banco de dados cientes do 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
}

A coisa importante é a cadeia — cada camada passa o mesmo contexto para a próxima:

flowchart TD A[Contexto da requisição HTTP] --> B[Manipulador] B --> C[Serviço] C --> D[Repositório] D --> E[Consulta ao banco de dados]

Não quebre a cadeia criando context.Background() no meio.

O erro do context.Background(): quebrando a cadeia de cancelamento

Este é um bug comum:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(context.Background(), id)
}

Isso descarta todas as informações de cancelamento e prazo do chamador. Se o cliente desconectar, a consulta ao banco de dados continua rodando. Se a requisição expirar, o trabalho downstream ainda pode estar em andamento. Se o servidor estiver desligando, este código ignora completamente. Substituir o contexto recebido por context.Background() dentro da lógica de negócios quase sempre está errado.

Use o contexto que foi dado a você:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

Use context.Background() apenas na borda onde nenhum contexto pai existe.

Exemplo de cliente HTTP

Para requisições HTTP de saída, anexe o contexto à requisição.

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)
}

Não faça isso:

req, err := http.NewRequest(http.MethodGet, endpoint, nil)

Isso cria uma requisição sem o contexto da operação.

Também evite confiar apenas em http.Client.Timeout. Pode ser útil como um limite de segurança, mas os contextos de requisição dão a você uma melhor propagação pela cadeia de chamadas.

Um padrão comum é:

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 isso quando a chamada de API downstream tiver um orçamento específico dentro de uma requisição maior.

Exemplo de banco de dados

A maioria das APIs de banco de dados em Go tem métodos cientes do contexto. Para uma visão mais ampla de como as bibliotecas de acesso a dados do Go lidam com o contexto — incluindo GORM, Ent, Bun e sqlc — veja Comparando ORMs Go para PostgreSQL.

Use-os.

Bom:

rows, err := db.QueryContext(ctx, query, args...)

Bom:

err := db.QueryRowContext(ctx, query, id).Scan(&name)

Bom:

result, err := db.ExecContext(ctx, query, args...)

Ruim:

rows, err := db.Query(query, args...)

As formas cientes do contexto permitem que operações de banco de dados parem quando a requisição é cancelada ou expira, o que é especialmente importante para consultas lentas, bancos de dados sobrecarregados e APIs voltadas ao usuário onde a latência afeta diretamente a experiência do usuário.

Transações e contexto

Transações precisam de cuidado no manuseio do contexto.

Uma transação geralmente deve começar com o contexto da operação:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

Então use o mesmo contexto para operações de transação:

if _, err := tx.ExecContext(ctx, query, args...); err != nil {
	return err
}

if err := tx.Commit(); err != nil {
	return err
}

Tenha cuidado com timeouts ao redor de transações. Se o contexto for cancelado antes do Commit, a transação pode ser revertida. Isso pode ser o que você quer, mas deve ser intencional.

Para transações longas, a melhor resposta geralmente não é um timeout maior — é uma transação menor que faz menos trabalho por unidade.

Trabalhadores em segundo plano e contexto

Trabalhadores em segundo plano devem receber um contexto que represente seu tempo de vida.

Exemplo:

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 trabalhador para limpa quando o contexto é cancelado, e seu ticker é limpo corretamente via defer ticker.Stop(). Em main, você criaria um contexto raiz vinculado a sinais do SO:

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 é o contexto usado corretamente: ele descreve o tempo de vida do trabalho do processo, e quando o SO envia um sinal, toda a árvore de goroutines que compartilham este contexto irá parar juntas.

Prevenindo vazamentos de goroutines com cancelamento de contexto

Um vazamento de goroutine ocorre quando uma goroutine permanece bloqueada para sempre após não ser mais útil.

O contexto ajuda a prevenir isso.

Ruim:

func StartWorker() {
	go func() {
		for {
			doWork()
			time.Sleep(time.Second)
		}
	}()
}

Esta goroutine não tem caminho de desligamento.

Melhor:

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()
			}
		}
	}()
}

Qualquer goroutine que faça loop deve quase sempre ter um caminho de cancelamento.

Isso não significa que toda goroutine deve receber contexto diretamente, mas o sistema deve ter uma maneira clara de pará-la.

context.AfterFunc

context.AfterFunc executa uma função após um contexto ser cancelado.

Pode ser útil para limpeza, desbloqueio de operações ou ponte para APIs que não suportam nativamente o contexto.

Exemplo:

func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
	stop := context.AfterFunc(ctx, func() {
		// Despertar ou limpar se necessário.
	})
	defer stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-ch:
		return nil
	}
}

Use AfterFunc com cuidado — ele inicia lógica quando o cancelamento acontece, o que pode tornar o fluxo de controle mais difícil de seguir. Para a maioria do código da aplicação, um select normal em ctx.Done() é mais claro e mais fácil de raciocinar. AfterFunc é mais valioso quando você precisa adaptar o cancelamento de contexto a uma API que já não aceita contexto.

context.WithoutCancel

context.WithoutCancel cria um contexto que não é cancelado quando o pai é cancelado.

Isso é útil, mas também é fácil de usar indevidamente.

Exemplo de caso de uso:

func Handler(audit *AuditLog) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// Tratar requisição...
		_ = ctx

		auditCtx := context.WithoutCancel(ctx)

		go func() {
			ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
			defer cancel()

			_ = audit.Write(ctx, "request completed")
		}()
	}
}

A ideia é que a escrita de auditoria pode precisar continuar brevemente mesmo após o contexto da requisição ser cancelado. Isso deve ser raro e deliberado — não use WithoutCancel como uma maneira de evitar lidar com cancelamento. Use-o apenas quando o trabalho filho genuinamente deve sobreviver ao cancelamento do pai, e sempre adicione um novo timeout: um contexto que ignora cancelamento mas não carrega prazo pode facilmente criar vazamentos de goroutines em segundo plano.

Valores de contexto feitos corretamente

Valores de contexto são para dados com escopo de requisição que atravessam fronteiras de API.

Boas exemplos:

  • ID de requisição
  • ID de rastreio
  • ID de usuário autenticado
  • ID de tenant
  • localidade
  • principal de segurança
  • metadados de correlação

Maus exemplos:

  • conexão de banco de dados
  • logger como dependência oculta
  • bandeiras de recurso para fluxo de controle ordinário
  • parâmetros de função opcionais
  • configuração
  • clientes de serviço

Uma regra útil: se o valor faz parte da identidade da requisição ou contexto de observabilidade, ele pode pertencer ao contexto. Se é uma dependência que seu código precisa para fazer seu trabalho, passe-a explicitamente.

Use chaves tipadas para valores de contexto

Não use strings simples como chaves de contexto.

Ruim:

ctx = context.WithValue(ctx, "userID", "123")

Isso pode colidir com outros pacotes.

Use um tipo de chave personalizada não exportada:

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 padrão dá a você segurança de tipo na fronteira do pacote, evita colisões de chaves com outros pacotes e mantém a superfície da API de contexto limpa com funções acessoras tipadas.

Não use valores de contexto para parâmetros opcionais

Isto é ruim:

ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)

Isso esconde o contrato da função.

Prefira parâmetros explícitos:

users, err := repo.ListUsers(ctx, ListUsersOptions{
	PageSize: 100,
})

Valores de contexto não devem substituir argumentos de função. Entrada oculta torna o código mais difícil de entender, testar e revisar — e qualquer um lendo a assinatura da função não terá ideia que o parâmetro existe.

Log e contexto

Existem duas abordagens comuns para log com contexto. Os exemplos aqui usam o pacote log/slog do Go — para um mergulho mais profundo em log estruturado com slog em serviços de produção, veja Log Estruturado em Go com slog.

Abordagem 1: Extraia valores e anexe-os aos logs

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)
}

Isso mantém o logger explícito como uma dependência adequada e usa o contexto apenas para valores com escopo de requisição que legitimamente precisam atravessar fronteiras de API.

Abordagem 2: Armazene logger no contexto

Algumas bases de código armazenam um logger no contexto.

Isso pode ser conveniente, mas não o recomendo como padrão. Isso transforma o contexto em um contêiner de dependências.

Minha preferência:

  • Passe dependências de logger explicitamente.
  • Armazene IDs de rastreio e IDs de requisição no contexto.
  • Adicione esses valores aos logs nas fronteiras ou middleware.

Isso mantém as dependências visíveis.

Contexto e rastreio

Rastreio é um dos casos de uso mais fortes para valores de contexto, e é um ajuste genuinamente bom. OpenTelemetry e sistemas similares usam contexto para propagar spans de rastreio através de chamadas de função e fronteiras de processo, porque dados de rastreio são exatamente o tipo de metadados com escopo de requisição para o qual o contexto foi projetado para carregar.

Um padrão típico parece assim:

func (s *Service) DoWork(ctx context.Context) error {
	ctx, span := s.tracer.Start(ctx, "Service.DoWork")
	defer span.End()

	return s.repo.Query(ctx)
}

O contexto carrega o span de rastreio ativo, e o repositório pode criar um span filho a partir dele. Cada camada adiciona seu próprio span sem qualquer passagem explícita de objetos de rastreador — o contexto faz esse trabalho transparentemente através de toda a árvore de chamadas.

Tratamento de erros com contexto

Quando uma operação para devido ao cancelamento de contexto, preserve essa informação. Os padrões aqui complementam as estratégias de design de erro mais amplas cobertas em Arquitetura de Tratamento de Erros em Go.

Exemplo:

err := svc.DoWork(ctx)
if err != nil {
	if errors.Is(err, context.Canceled) {
		// Cliente cancelou ou chamador parou o trabalho.
		return err
	}

	if errors.Is(err, context.DeadlineExceeded) {
		// Timeout.
		return err
	}

	return err
}

Não envolva erros de contexto cegamente de uma maneira que os esconda.

Envolver com %w preserva errors.Is, então chamadores ainda podem detectar cancelamento ou timeout:

if err != nil {
	return fmt.Errorf("query user: %w", err)
}

Substituir o erro completamente descarta essa informação e quebra qualquer chamador que verifique tipos específicos de erro de contexto:

if err != nil {
	return errors.New("query user failed")
}

Mapeando erros de contexto para respostas HTTP

Erros de contexto frequentemente mapeiam para diferentes resultados HTTP.

Exemplo:

func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, context.Canceled):
		// O cliente provavelmente foi embora.
		// Alguns sistemas registram isso como requisição fechada pelo cliente.
		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
	}
}

Não trate o cancelamento do cliente como uma falha da aplicação — se o usuário fechou a aba do navegador, isso não é seu serviço se comportando mal, e registrá-lo como erro adiciona ruído sem sinal.

Contexto em middleware

Middleware HTTP é um lugar comum para adicionar valores com escopo de requisição.

Exemplo de middleware de ID de requisição:

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 é um bom uso de contexto. O ID de requisição pertence à requisição, deve viajar pela cadeia de chamada completa, e anexá-lo a logs e rastreios em cada camada é exatamente o tipo de preocupação de observabilidade transversal que valores de contexto são projetados para suportar.

Contexto em testes

Em testes, evite usar context.Background() cegamente.

Prefira t.Context() quando o trabalho pertencer ao tempo de vida do teste:

func TestService(t *testing.T) {
	ctx := t.Context()

	err := service.DoWork(ctx)
	if err != nil {
		t.Fatal(err)
	}
}

Para comportamento de timeout, teste com um timeout real apenas se o timeout for pequeno e significativo.

Para código concorrente e dependente de tempo, considere usar testing/synctestTestando Código Go Concorrente com synctest cobre esta ferramenta em profundidade:

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())
		}
	})
}

Isso permite que você teste valores reais de timeout sem esperar pelo tempo real.

Contexto e errgroup

Para grupos de goroutines que devem cancelar juntas, errgroup é frequentemente uma boa opção.

Exemplo:

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()
}

Se uma goroutine retornar um erro, o contexto do grupo é cancelado e outras goroutines que respeitam ctx.Done() podem parar cedo. Isso é muito mais limpo do que gerenciar manualmente múltiplas goroutines, canais e caminhos de cancelamento. A frase-chave aqui é “respeitar o contexto” — errgroup não pode parar trabalho que ignora ctx.Done().

Desligamento gracioso

Contexto é central para desligamento gracioso.

Uma configuração típica de servidor tem:

  • um contexto raiz cancelado por sinais do SO
  • um servidor HTTP
  • trabalhadores em segundo plano
  • um timeout de desligamento
  • lógica de limpeza

Exemplo:

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 o contexto de desligamento não é o mesmo que o contexto raiz — o raiz já está cancelado quando o sinal do SO chega. Um contexto de timeout separado dá ao processo de desligamento uma quantidade limitada de tempo para drenar requisições em andamento antes de forçar o encerramento, o que é a distinção sutil, mas importante, que faz o desligamento gracioso funcionar realmente.

Anti-padrões comuns

Anti-padrão 1: Usando contexto como contêiner de dependências

Ruim:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)

Passe dependências explicitamente.

Anti-padrão 2: Criando context.Background dentro da lógica de negócios

Ruim:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Save(context.Background())
}

Isso quebra a propagação de cancelamento.

Anti-padrão 3: Esquecendo cancel

Ruim:

ctx, _ := context.WithTimeout(parent, time.Second)

Bom:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

Anti-padrão 4: Colocando parâmetros opcionais no contexto

Ruim:

ctx = context.WithValue(ctx, "includeDeleted", true)

Use structs de opções explícitas.

Anti-padrão 5: Passando contexto muito profundo em código puro

Ruim:

func Add(ctx context.Context, a, b int) int {
	return a + b
}

Computação pura não precisa de contexto a menos que seja de longa execução ou cancelável.

Anti-padrão 6: Ignorando cancelamento em loops

Ruim:

for item := range items {
	process(item)
}

Melhor:

for item := range items {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err := process(ctx, item); err != nil {
		return err
	}
}

Anti-padrão 7: Engolindo erros de contexto

Ruim:

if err != nil {
	return errors.New("operation failed")
}

Bom:

if err != nil {
	return fmt.Errorf("operation failed: %w", err)
}

Preserve erros de cancelamento e prazo.

Uma lista de verificação prática de contexto

Use esta lista de verificação para código backend em Go.

Assinaturas de função

  • Contexto é o primeiro parâmetro.
  • Contexto não é armazenado em structs de longa duração.
  • Contexto não é passado para funções auxiliares puras a menos que necessário.
  • Contexto nil nunca é usado.

Cancelamento

  • Loops de longa execução verificam ctx.Done().
  • Goroutines têm um caminho de desligamento.
  • Tempos de vida de trabalhadores estão vinculados a um contexto pai.
  • Cancelamento de contexto é propagado para chamadas downstream.

Timeouts

  • Timeouts de requisição externa são definidos na fronteira.
  • Timeouts de sub-operação são menores que o orçamento externo.
  • Funções de cancelamento são sempre chamadas.
  • Timeouts não são empilhados cegamente em cada camada.

Valores

  • Valores de contexto são com escopo de requisição.
  • Chaves usam tipos personalizados, não strings simples.
  • Dependências não são armazenadas no contexto.
  • Parâmetros opcionais não são armazenados no contexto.

Erros

  • context.Canceled e context.DeadlineExceeded são preservados.
  • Erros de contexto são mapeados corretamente nas fronteiras de API.
  • Cancelamento ciente da causa é usado apenas quando o motivo importa.

Testes

  • Testes usam t.Context() onde apropriado.
  • Testes de timeout evitam sleeps reais lentos.
  • Comportamento de timeout concorrente é testado com testing/synctest quando útil.
  • Vazamentos de goroutine são verificados garantindo que caminhos de desligamento existam.

Como auditar o uso de contexto em uma base de código Go

Busque por estes padrões:

grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .

Então pergunte:

  • context.Background() é usado apenas em fronteiras de nível superior?
  • Funções de cancelamento são sempre chamadas?
  • Timeouts são colocados em fronteiras sensatas?
  • Valores de contexto são realmente com escopo de requisição?
  • Dependências estão escondidas em valores de contexto?
  • Goroutines são param?
  • Erros de contexto são preservados?

Este é um bom hábito de revisão de código, porque muitos bugs de contexto não são bugs de sintaxe — são bugs de tempo de vida que só surgem sob condições de cancelamento, carga ou desligamento.

Minhas regras opinativas

Essas regras são chatas, mas funcionam.

Regra 1: Contexto é fluxo de controle

Use contexto para controlar cancelamento, prazos e metadados de requisição.

Não o use para contrabandear dependências.

Regra 2: O chamador possui o orçamento

Uma função geralmente deve respeitar o contexto que recebe.

Crie apenas um timeout filho mais curto quando a sub-operação precisar de um orçamento menor específico.

Regra 3: Background pertence à borda

Use context.Background() em main, testes e configuração de nível superior.

Não o use dentro de métodos de serviço e repositório para escapar do cancelamento.

Regra 4: Valores devem ser chatos

ID de requisição, ID de rastreio, ID de usuário e ID de tenant pertencem ao contexto. Conexões de banco de dados, loggers, structs de configuração e clientes de serviço não — eles são dependências e devem ser passados explicitamente.

Regra 5: Toda goroutine precisa de um tempo de vida

Se uma goroutine inicia, você deve saber exatamente como ela para. Contexto é frequentemente a resposta certa, e se não for contexto, deve haver algum outro mecanismo claro — um canal, um primitivo de sincronização ou um sinal explícito.

Pensamentos finais

context.Context não é complicado porque a API é grande — a API é pequena. É complicado porque representa tempo de vida, e tempo de vida é arquitetura. Cada decisão sobre onde o contexto flui, onde é derivado e onde para é uma decisão sobre como seu serviço lida com falha, carga e desligamento.

Um contexto bem usado torna serviços Go mais fáceis de cancelar, mais fáceis de desligar, mais fáceis de observar e menos propensos a vazar goroutines. Um contexto mal usado esconde dependências, descarta prazos e torna o código mais difícil de raciocinar sob pressão.

A conclusão prática é simples:

Passe contexto para baixo.
Não o armazene.
Não substitua parâmetros explícitos com valores.
Respeite o cancelamento.
Use timeouts nas fronteiras.
Sempre chame cancel.

Isso é contexto Go feito corretamente.

Este artigo faz parte do cluster Arquitetura de Aplicação em Produção, que cobre estrutura de código, acesso a dados, padrões de integração e arquitetura de teste para sistemas Go e Python de produção.

Fontes

Assinar

Receba novos artigos sobre sistemas, infraestrutura e engenharia de IA.