Go context.Context Corretamente: Cancelamento, Timeouts e Valores
Contexto em Go é fluxo de controle, não armazenamento.
O context.Context da linguagem Go é simples o suficiente para ser usado de forma incorreta — e esse é o problema.
A maioria dos desenvolvedores Go aprende as regras básicas rapidamente: passar o context como 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 context não é apenas uma convenção de parâmetro — ele é o plano de controle para o tempo de vida da requisição.

O context informa ao trabalho quando parar, quanto tempo lhe resta, qual caminho de cancelamento foi tomado e quais valores com escopo de requisição precisam atravessar as fronteiras da API. Usado bem, ele previne vazamentos de goroutines, evita trabalho desperdiçado, propaga prazos (deadlines) e torna os serviços mais fáceis de desligar. Usado mal, ele se torna uma bolsa de dependências ocultas, variáveis globais falsas, timeouts esquecidos, timers vazados e comportamento de cancelamento confuso.
A versão levemente opinativa é esta: use o context 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 context
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 o que ele foi projetado para fazer.
Um context deve responder a perguntas como:
Esta requisição foi cancelada?
Quanto tempo esta operação tem restante?
Qual ID de requisição deve ser anexado aos logs?
Qual usuário autenticado está associado a esta requisição?
Um context 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?
Esses 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 de como fazer isso de forma limpa). O context é para o tempo de vida da requisição e metadados da requisição, não para o encaixamento (wiring) da aplicação.
A forma básica do context
A interface central é 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 context é cancelado ou seu prazo expira.Err()explica por que o context terminou.Deadline()informa se o context tem um prazo.Value()armazena dados com escopo de requisição.
A maioria do código não implementa esta interface. Ele recebe um context e o passa para baixo.
A primeira regra: passe o context explicitamente
Para funções que fazem trabalho com escopo de requisição ou cancelável, passe o context como primeiro parâmetro — esta é a convenção padrão de 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 podem:
- Chamar um banco de dados
- Chamar outro serviço
- Esperar em uma fila
- Iniciar trabalho em segundo plano
- Bloquear em I/O
- Usar um timeout
- Precisar de valores com escopo de requisição
- Precisar de cancelamento
Não adicione context a pequenas funções puras que não precisam dele.
Isto está bem:
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Nem toda função precisa de um context. Adicionar context em todos os lugares torna o código barulhento.
Não armazene context em structs
Armazenar um context em um struct é um dos erros mais comuns em bases de código Go, e vale a pena destacar explicitamente. Não faça isto:
type UserService struct {
ctx context.Context
db *sql.DB
}
Faça isto em vez disso:
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Um context pertence a uma requisição, operação ou tarefa, enquanto um struct de serviço geralmente vive muito mais tempo do que qualquer requisição individual. Misturar esses tempos de vida torna o cancelamento pouco claro e torna difícil raciocinar sobre a qual operação um context pertence.
Há exceções raras para tipos que realmente representam um único tempo de vida de operação, mas são raras o suficiente que a regra padrão deve ser simples:
Passe o context. Não o armazene.
Não passe context nil
Nunca passe nil como context.
Ruim:
err := svc.DoWork(nil)
Use context.Background() quando não houver um context existente:
err := svc.DoWork(context.Background())
Em testes, use o context de teste quando possível:
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Um context nil pode causar panic quando o código chama métodos nele. Um context de background é explícito e seguro.
Contexts de Background, 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 existe um context pai — é o context raiz a partir do qual todos os contextos filhos 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 context deve ser usado, mas ainda não decidiu qual.
ctx := context.TODO()
Isto é útil durante a migração, mas não deve se tornar permanente se um context real existir.
Context de requisição
Em servidores HTTP, use o context da requisição:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
O context da requisição é cancelado quando a conexão do cliente é fechada, a requisição é cancelada ou o servidor termina de processar a requisição.
Para serviços web, este geralmente é o context que você deve passar para o código da aplicação.
Cancelamento com context.WithCancel
Use context.WithCancel quando você quer parar o trabalho explicitamente.
ctx, cancel := context.WithCancel(parent)
defer cancel()
A função cancel retornada cancela o context filho e libera os recursos associados a ele. Sempre chame-a quando terminar — mesmo se o context eventualmente expirar, chamar cancel cedo 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 context filho.
- Defina o cancel com defer.
- Passe o context filho para o trabalho que deve parar junto.
- Observe
ctx.Done().
Timeouts com context.WithTimeout
Use context.WithTimeout quando uma operação tem 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)
}
Isto torna o timeout parte da operação, e não uma configuração global oculta.
Sempre chame cancel
Quando você chama WithCancel, WithTimeout ou WithDeadline, sempre chame a função cancel retornada — isso importa para a correção.
Bom:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Ruim:
ctx, _ := context.WithTimeout(parent, 5*time.Second)
Esquecer de chamar cancel pode manter timers e contextos filhos vivos por mais tempo do que o necessário.
Os próprios canais de timer também fazem parte do contrato: desde o Go 1.27, os canais por trás de time.After e time.NewTimer são sempre sem buffer, e o antigo opt-out asynctimerchan não mais compila com seu valor pré-1.23. O Guia de upgrade do Go 1.27 lista essa configuração junto com outros valores removidos de GODEBUG que falham a compilação.
Deadlines vs Timeouts
Um timeout é relativo:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Um deadline é absoluto:
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
A maioria do código de aplicativo usa timeouts. Deadlines são úteis quando uma requisição tem um tempo final fixo que deve ser compartilhado entre múltiplas operações — por exemplo, se uma requisição tem 900 milissegundos restantes, não dê a cada chamada downstream um novo timeout 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 deadline do chamador em vez de reiniciar o timer para o mesmo valor.
Um padrão melhor é:
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 {
// handle error
return
}
}
E 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 precisa 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 único, sub-operações específicas podem ter orçamentos menores retirados 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 context termina, ctx.Err() retorna o motivo.
Geralmente é um destes:
context.Canceled
context.DeadlineExceeded
Exemplo:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return handle(result)
}
Isso permite que os chamadores distinga cancelamento de timeout, e essa distinção importa na prática. Uma requisição cancelada muitas vezes significa que o cliente desconectou, enquanto um erro de deadline excedido geralmente significa que seu serviço foi lento demais — eles não devem sempre ser registrados, retomados ou reportados da mesma maneira.
Use context.Cause para melhores razões de cancelamento
O Go moderno também suporta cancelamento consciente de causa (cause-aware).
As funções úteis incluem:
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
O ctx.Err() simples informa a razão ampla: cancelado ou deadline excedido.
context.Cause(ctx) pode informar 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() {
// Some shutdown signal arrived.
cancel(ErrShutdown)
}()
<-ctx.Done()
return context.Cause(ctx)
}
Use o cancelamento consciente de causa quando a razão importa para chamadores, logs ou comportamento de limpeza, e evite-o onde um ctx.Err() simples é suficiente — o detalhe extra só vale a pena quando o diagnóstico genuinamente exige.
Exemplo de servidor HTTP
Um manipulador HTTP normal deve começar de r.Context(). Para uma explicação completa da estrutura de serviços HTTP em Go, veja Construindo APIs REST em Go. Gin, Echo e Fiber cada um envolvem context.Context dentro do seu próprio tipo de context de requisição, e as diferenças entre as pilhas são comparadas em Frameworks Web Go em 2026: net/http, chi, Gin, Echo, Fiber Comparados.
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 context:
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 conscientes de context:
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 context para baixo para a próxima:
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 toda a informação de cancelamento e deadline do chamador. Se o cliente desconectar, a consulta ao banco de dados continua executando. Se a requisição expirar, o trabalho downstream pode ainda estar em andamento. Se o servidor estiver desligando, este código ignora completamente. Substituir o context recebido por context.Background() dentro da lógica de negócio é quase sempre errado.
Use o context que lhe foi dado:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Use context.Background() apenas na borda onde não existe um context pai.
Exemplo de cliente HTTP
Para requisições HTTP de saída, anexe o context à 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 isto:
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
Isto cria uma requisição sem o context da operação.
Também evite depender apenas de http.Client.Timeout. Pode ser útil como um limite de segurança, mas contextos de requisição dão 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 isto quando a chamada de API downstream tem um orçamento específico dentro de uma requisição maior.
Exemplo de banco de dados
A maioria das APIs de banco de dados Go tem métodos conscientes de context. Para uma visão mais ampla de como as bibliotecas de acesso a dados Go lidam com context — 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 conscientes de context permitem que as 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 para o usuário onde a latência afeta diretamente a experiência do usuário.
Transações e context
Transações precisam de um tratamento cuidadoso de context.
Uma transação geralmente deve começar com o context da operação:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Então use o mesmo context 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 em torno de transações. Se o context é 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 mais longo — é uma transação mais curta que faz menos trabalho por unidade.
Trabalhadores em segundo plano e context
Trabalhadores em segundo plano devem receber um context 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 limamente quando o context é cancelado, e seu ticker é limpo adequadamente via defer ticker.Stop(). No main, você criaria um context raiz ligado 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 context 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 context para juntas.
Prevenindo vazamentos de goroutines com cancelamento de context
Um vazamento de goroutine acontece quando uma goroutine permanece bloqueada para sempre depois de não ser mais útil.
O context 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 entra em loop deve quase sempre ter um caminho de cancelamento.
Isso não significa que toda goroutine deve receber context diretamente, mas o sistema deve ter uma forma clara de pará-la.
context.AfterFunc
context.AfterFunc executa uma função após um context ser cancelado.
Pode ser útil para limpeza, desbloquear operações ou conectar APIs que não suportam nativamente context.
Exemplo:
func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
stop := context.AfterFunc(ctx, func() {
// Wake up or clean up if needed.
})
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 de aplicativo, um select normal em ctx.Done() é mais claro e mais fácil de raciocinar. AfterFunc é mais valioso quando você precisa adaptar o cancelamento de context para uma API que não já aceita context.
context.WithoutCancel
context.WithoutCancel cria um context que não é cancelado quando o pai é cancelado.
Isto é útil, mas também é fácil de usar indevidamente.
Caso de uso exemplo:
func Handler(audit *AuditLog) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Handle request...
_ = 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 depois que o context da requisição é cancelado. Isso deve ser raro e deliberado — não use WithoutCancel como uma forma 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 context que ignora o cancelamento, mas não carrega um prazo, pode facilmente criar vazamentos de goroutines em segundo plano.
Valores de context feitos corretamente
Valores de context são para dados com escopo de requisição que atravessam fronteiras de API.
Exemplos bons:
- ID de requisição
- ID de rastreamento (trace ID)
- ID de usuário autenticado
- ID de inquilino (tenant ID)
- Idioma (locale)
- Principal de segurança
- Metadados de correlação
Exemplos ruins:
- Conexão com banco de dados
- Logger como dependência oculta
- Bandeiras de recurso (feature flags) para fluxo de controle ordinário
- Parâmetros de função opcionais
- Configuração
- Clientes de serviço
Uma regra útil: se o valor é parte da identidade da requisição ou do context de observabilidade, ele pode pertencer ao context. Se é uma dependência que seu código precisa para fazer seu trabalho, passe-a explicitamente.
Use chaves tipadas para valores de context
Não use strings simples como chaves de context.
Ruim:
ctx = context.WithValue(ctx, "userID", "123")
Isto pode colidir com outros pacotes.
Use um tipo de chave customizado não exportado:
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 lhe dá segurança de tipo na fronteira do pacote, evita colisões de chaves com outros pacotes e mantém a superfície da API do context limpa com funções de acesso tipadas.
Não use valores de context para parâmetros opcionais
Isto é ruim:
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
Isto esconde o contrato da função.
Prefira parâmetros explícitos:
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
Valores de context não devem substituir argumentos de função. Entradas ocultas tornam o código mais difícil de entender, testar e revisar — e qualquer um lendo a assinatura da função não terá nenhuma ideia que o parâmetro exista.
Log e context
Há duas abordagens comuns para log com context. Os exemplos aqui usam o pacote log/slog do Go — para uma investigação mais profunda de log estruturado com slog em serviços de produção, veja Log Estruturado em Go com slog.
Abordagem 1: Extrair valores e anexá-los 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)
}
Isto mantém o logger explícito como uma dependência apropriada e usa context apenas para valores com escopo de requisição que legitimamente precisam atravessar fronteiras de API.
Abordagem 2: Armazenar logger no context
Algumas bases de código armazenam um logger no context.
Isto pode ser conveniente, mas eu não recomendo como padrão. Ele transforma o context em um contêiner de dependências.
Minha preferência:
- Passe dependências de logger explicitamente.
- Armazene IDs de trace e IDs de requisição no context.
- Adicione esses valores aos logs em fronteiras ou middleware.
Isto mantém as dependências visíveis.
Context e rastreamento (tracing)
Rastreamento é um dos casos de uso mais fortes para valores de context, e é um encaixe genuinamente bom. OpenTelemetry e sistemas similares usam context para propagar spans de trace através de chamadas de função e fronteiras de processo, porque dados de trace são exatamente o tipo de metadados com escopo de requisição para o qual o context foi projetado para carregar.
Um padrão típico parece com:
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 context carrega o span de trace 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 tracer — o context faz esse trabalho transparentemente pela árvore inteira de chamadas.
Tratamento de erros com context
Quando uma operação para devido ao cancelamento de context, preserve essa informação. Os padrões aqui complementam as estratégias mais amplas de design de erros cobertas em Arquitetura de Tratamento de Erros em Go.
Exemplo:
err := svc.DoWork(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Client canceled or caller stopped the work.
return err
}
if errors.Is(err, context.DeadlineExceeded) {
// Timeout.
return err
}
return err
}
Não embrulhe (wrap) erros de context cegamente de uma forma que os esconda.
Wrapping com %w preserva errors.Is, então os chamadores ainda podem detectar cancelamento ou timeout:
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Substituindo o erro completamente descarta essa informação e quebra qualquer chamador que verifica por tipos específicos de erro de context:
if err != nil {
return errors.New("query user failed")
}
Mapeando erros de context para respostas HTTP
Erros de context muitas vezes mapeiam para resultados HTTP diferentes.
Exemplo:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled):
// The client likely went away.
// Some systems log this as a client closed request.
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.
Context 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 context. O ID de requisição pertence à requisição, ele deve viajar pela cadeia completa de chamadas, e anexá-lo a logs e traces em cada camada é exatamente o tipo de preocupação de observabilidade transversal que valores de context são projetados para suportar.
Context em testes
Em testes, evite usar context.Background() cegamente.
Prefira t.Context() quando o trabalho pertence 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/synctest — Testando Código Concorrente Go 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())
}
})
}
Isto lhe permite testar valores reais de timeout sem esperar pelo tempo real.
Context e errgroup
Para grupos de goroutines que devem cancelar juntas, errgroup é muitas vezes um bom encaixe.
errgroup coordena o cancelamento, mas não limita nada por si só: sem um limite, um loop de mil itens lança mil goroutines. Para o lado da capacidade — SetLimit, canais de semáforo, pools de trabalhadores, e a forma de vazamento que sobrevive ao Wait — veja Limitando Concorrência em Go.
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 retorna um erro, o context 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 context” — errgroup não pode parar trabalho que ignora ctx.Done().
Desligamento gracioso (Graceful shutdown)
Context é central para o desligamento gracioso.
Um setup típico de servidor tem:
- um context 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 context de desligamento não é o mesmo que o context raiz — o raiz já está cancelado quando o sinal do SO chega. Um context de timeout separado dá ao processo de desligamento uma quantidade limitada de tempo para drenar as requisições em andamento antes de forçar a saída, que é a distinção sutil, mas importante, que faz o desligamento gracioso realmente funcionar.
Anti-padrões comuns
Anti-padrao 1: Usando context 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-padrao 2: Criando context.Background dentro da lógica de negócio
Ruim:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Save(context.Background())
}
Isto quebra a propagação de cancelamento.
Anti-padrao 3: Esquecendo cancel
Ruim:
ctx, _ := context.WithTimeout(parent, time.Second)
Bom:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Anti-padrao 4: Colocando parâmetros opcionais no context
Ruim:
ctx = context.WithValue(ctx, "includeDeleted", true)
Use structs de opções explícitos.
Anti-padrao 5: Passando context muito fundo para código puro
Ruim:
func Add(ctx context.Context, a, b int) int {
return a + b
}
Cálculo puro não precisa de context a menos que seja de longa duração ou cancelável.
Anti-padrao 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-padrao 7: Engolindo erros de context
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 deadline.
Uma lista de verificação prática de context
Use esta lista de verificação para código backend em Go.
Assinaturas de função
- Context é o primeiro parâmetro.
- Context não é armazenado em structs de longa duração.
- Context não é passado para funções auxiliares puras a menos que necessário.
- Context nil nunca é usado.
Cancelamento
- Loops de longa duração verificam
ctx.Done(). - Goroutines têm um caminho de desligamento.
- Tempos de vida de trabalhadores estão ligados a um context pai.
- O cancelamento de context é propagado para chamadas downstream.
Timeouts
- Timeouts de requisição externos são definidos na fronteira.
- Timeouts de sub-operações são menores que o orçamento externo.
- Funções cancel são sempre chamadas.
- Timeouts não são empilhados cegamente em cada camada.
Valores
- Valores de context são com escopo de requisição.
- Chaves usam tipos customizados, não strings simples.
- Dependências não são armazenadas no context.
- Parâmetros opcionais não são armazenados no context.
Erros
context.Canceledecontext.DeadlineExceededsão preservados.- Erros de context são mapeados corretamente nas fronteiras da API.
- O cancelamento consciente de causa é usado apenas quando a razão importa.
Testes
- Testes usam
t.Context()onde apropriado. - Testes de timeout evitam esperas reais lentas.
- O comportamento de timeout concorrente é testado com
testing/synctestquando útil. - Vazamentos de goroutines são verificados garantindo que caminhos de desligamento existam.
Como auditar o uso de context em uma base de código Go
Pesquise 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?- As funções cancel são sempre chamadas?
- Os timeouts são colocados em fronteiras sensatas?
- Os valores de context são realmente com escopo de requisição?
- As dependências estão ocultas nos valores de context?
- As goroutines podem ser paradas?
- Os erros de context são preservados?
Este é um bom hábito de revisão de código, porque muitos bugs de context não são bugs de sintaxe — eles são bugs de tempo de vida que só aparecem sob condições de cancelamento, carga ou desligamento.
Minhas regras opinativas
Estas regras são chatas, mas funcionam.
Regra 1: Context é fluxo de controle
Use context 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 context que recebe.
Crie apenas um timeout filho mais curto quando a sub-operação precisa de um orçamento específico menor.
Regra 3: Background pertence na 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: Os valores devem ser chatos
ID de requisição, ID de trace, ID de usuário e ID de inquilino pertencem ao context. 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: Cada goroutine precisa de um tempo de vida
Se uma goroutine inicia, você deve saber exatamente como ela para. Context é muitas vezes a resposta certa, e se não for context, 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 ele representa tempo de vida, e tempo de vida é arquitetura. Toda decisão sobre onde o context flui, onde ele é derivado e onde ele para é uma decisão sobre como seu serviço lida com falha, carga e desligamento.
Um context bem usado torna os 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 context mal usado esconde dependências, descarta prazos e torna o código mais difícil de raciocinar sob pressão.
O resumo prático é simples:
Passe o context para baixo.
Não o armazene.
Não substitua parâmetros explícitos por valores.
Respeite o cancelamento.
Use timeouts nas fronteiras.
Sempre chame cancel.
É assim que o context 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.