Timeouts de contexto em Go impedem que chamadas lentas ao banco e requisições externas se acumulem. Aprenda propagação de deadline, cancelamento e padrões seguros.

Uma única requisição lenta raramente é "apenas lenta." Enquanto espera, ela mantém uma goroutine viva, ocupa memória para buffers e objetos de resposta e frequentemente prende uma conexão de banco de dados ou uma vaga numa pool. Quando requisições lentas se acumulam, sua API para de fazer trabalho útil porque os recursos limitados ficam presos esperando.
Normalmente você sente isso em três lugares. Goroutines se acumulam e o overhead de agendamento aumenta, então a latência piora para todo mundo. Pools de banco de dados ficam sem conexões livres, então consultas rápidas começam a enfileirar atrás das lentas. A memória sobe por conta de dados em trânsito e respostas parcialmente construídas, o que aumenta o trabalho do GC.
Adicionar mais servidores muitas vezes não resolve. Se cada instância enfrenta o mesmo gargalo (uma pool DB pequena, um upstream lento, limites de taxa compartilhados), você só move a fila e paga mais enquanto os erros ainda disparam.
Imagine um handler que faz um fan-out: carrega um usuário do PostgreSQL, chama um serviço de pagamentos e depois uma API de recomendações. Se a chamada de recomendações travar e nada cancelar, a requisição nunca termina. A conexão ao DB pode ser devolvida, mas a goroutine e os recursos do cliente HTTP permanecem ocupados. Multiplique isso por centenas de requisições e você tem um colapso lento.
O objetivo é simples: estabelecer um limite de tempo claro, parar o trabalho quando o tempo acabar, liberar recursos e retornar um erro previsível. Os timeouts de contexto em Go dão a cada etapa um deadline para que o trabalho pare quando o usuário não está mais esperando.
Um context.Context é um pequeno objeto que você passa pela cadeia de chamadas para que todas as camadas concordem em uma coisa: quando esta requisição deve parar. Timeouts são a forma comum de evitar que uma dependência lenta prenda seu servidor.
Um contexto pode carregar três tipos de informação: um deadline (quando o trabalho deve parar), um sinal de cancelamento (alguém decidiu parar mais cedo) e alguns valores com escopo de requisição (use com parcimônia e nunca para dados grandes).
Cancelamento não é mágica. Um contexto expõe um canal Done(). Quando ele fecha, a requisição foi cancelada ou o tempo acabou. Código que respeita contexto verifica Done() (frequentemente com um select) e retorna cedo. Você também pode checar ctx.Err() para saber por que terminou, normalmente context.Canceled ou context.DeadlineExceeded.
Use context.WithTimeout para "parar após X segundos." Use context.WithDeadline quando você já conhece o horário exato de corte. Use context.WithCancel quando uma condição pai deve interromper o trabalho (cliente desconectou, usuário navegou embora, você já tem a resposta).
Quando um contexto é cancelado, o comportamento correto é chato, mas importante: pare de fazer trabalho, pare de esperar I/O lento e retorne um erro claro. Se um handler está esperando por uma query ao banco e o contexto termina, retorne rápido e deixe a chamada ao banco abortar se ela suportar contexto.
O lugar mais seguro para interromper requisições lentas é a borda onde o tráfego entra no seu serviço. Se uma requisição vai expirar, você quer que isso aconteça de forma previsível e cedo, não depois de já ter prendido goroutines, conexões de DB e memória.
Comece na borda (load balancer, API gateway, reverse proxy) e defina um limite rígido para quanto tempo qualquer requisição pode existir. Isso protege seu serviço Go mesmo que um handler esqueça de definir um timeout.
Dentro do seu servidor Go, configure timeouts HTTP para que o servidor não espere para sempre por um cliente lento ou por uma resposta travada. No mínimo, configure timeouts para leitura de headers, leitura do corpo da requisição, escrita da resposta e conexões ociosas.
Escolha um orçamento padrão de requisição que combine com seu produto. Para muitas APIs, 1 a 3 segundos é um ponto de partida razoável para requisições típicas, com limite maior para operações sabidamente lentas, como exportações. O número exato importa menos do que ser consistente, medir e ter uma regra clara para exceções.
Respostas em streaming precisam de cuidado extra. É fácil criar um stream infinito acidental onde o servidor mantém a conexão aberta e escreve pequenos pedaços para sempre, ou espera para sempre antes do primeiro byte. Decida desde o início se um endpoint é realmente um stream. Se não for, imponha um tempo máximo total e um tempo máximo até o primeiro byte.
Uma vez que a borda tenha um deadline claro, é muito mais fácil propagar esse deadline por toda a requisição.
O lugar mais simples para começar é o handler HTTP. É onde uma requisição entra no seu sistema, então é um ponto natural para definir um limite rígido.
Crie um novo contexto com deadline e certifique-se de cancelá-lo. Depois passe esse contexto para qualquer coisa que possa bloquear: trabalho de banco, chamadas HTTP ou computações lentas.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
Uma boa regra: se uma função pode esperar por I/O, ela deve aceitar um context.Context. Mantenha handlers legíveis empurrando detalhes para pequenas helpers como loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
Se o deadline for alcançado (ou o cliente desconectar), pare o trabalho e retorne uma resposta amigável. Um mapeamento comum é context.DeadlineExceeded para 504 Gateway Timeout, e context.Canceled para "cliente foi embora" (muitas vezes sem corpo de resposta).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Client went away. Avoid doing more work.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Esse padrão evita acúmulos. Uma vez que o timer expira, todas as funções sensíveis a contexto na cadeia recebem o mesmo sinal de parada e podem sair rapidamente.
Uma vez que seu handler tem um contexto com deadline, a regra mais importante é simples: use esse mesmo ctx até a chamada do banco. É assim que timeouts param o trabalho em vez de apenas impedir que o handler espere.
Com database/sql, prefira os métodos que aceitam contexto:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
Se o orçamento do handler é 2 segundos, o banco deve receber apenas uma fatia disso. Deixe tempo para encoding JSON, outras dependências e tratamento de erros. Um ponto de partida simples é dar ao Postgres 30% a 60% do orçamento total. Com um deadline de handler de 2 segundos, isso pode ser 800ms a 1.2s.
Quando o contexto é cancelado, o driver pede ao Postgres para interromper a query. Normalmente a conexão volta para a pool e pode ser reutilizada. Se o cancelamento ocorrer durante um problema de rede, o driver pode descartar a conexão e abrir uma nova depois. De qualquer forma, você evita uma goroutine esperando para sempre.
Ao checar erros, trate timeouts de forma diferente de falhas reais do BD. Se errors.Is(err, context.DeadlineExceeded), você ficou sem tempo e deve retornar um timeout. Se errors.Is(err, context.Canceled), o cliente foi embora e você deve parar silenciosamente. Outros erros são problemas normais de query (SQL inválido, linha ausente, permissões).
Se seu handler tem um deadline, suas chamadas HTTP de saída também devem respeitá-lo. Caso contrário o cliente desiste, mas seu servidor continua esperando um upstream lento e prende goroutines, sockets e memória.
Construa requests de saída com o contexto pai para que o cancelamento viaje automaticamente:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
Esse timeout por chamada é uma rede de segurança. O deadline pai continua sendo o chefe. Um relógio para toda a requisição, mais limites menores para etapas arriscadas.
Também configure timeouts no nível do transport. O contexto cancela a requisição, mas timeouts do transport protegem contra handshakes lentos e servidores que nunca enviam headers.
Um detalhe que pega equipes: corpos de resposta devem ser fechados em todo caminho. Se você retornar cedo (cheque de status, erro de decode JSON, timeout de contexto), ainda feche o body. Vazamento de bodies pode exaurir silenciosamente conexões do pool e virar picos de latência "aleatórios".
Um cenário concreto: sua API chama um provedor de pagamentos. O cliente dá timeout após 2 segundos, mas o upstream trava por 30 segundos. Sem cancelamento de requisição e timeouts de transport, você passa a pagar por essa espera de 30 segundos para cada requisição abandonada.
Uma única requisição geralmente toca mais de uma coisa lenta: trabalho do handler, uma query de banco e uma ou mais APIs externas. Se você der a cada etapa um timeout generoso, o tempo total cresce silenciosamente até que os usuários sintam e seu servidor se acumule.
Orçamentar é a correção mais simples. Defina um deadline pai para toda a requisição e depois dê a cada dependência uma fatia menor. Deadlines filhos devem ser anteriores ao pai para falhar rápido e ainda ter tempo de retornar um erro limpo.
Regras práticas que funcionam em serviços reais:
Evite empilhar timeouts que se confrontam. Se o contexto do handler tem deadline de 2 segundos e seu cliente HTTP tem timeout de 10 segundos, você está seguro, mas é confuso. Se for o contrário, o cliente pode cortar cedo por razões não relacionadas.
Para trabalho em background (logs de auditoria, métricas, emails), não reutilize o contexto da requisição. Use um contexto separado com seu próprio timeout curto para que cancelamentos do cliente não matem limpezas importantes.
A maioria dos bugs de timeout não está no handler. Acontecem uma ou duas camadas abaixo, onde o deadline some silenciosamente. Se você coloca timeouts na borda mas ignora no meio, ainda pode acabar com goroutines, queries ou chamadas HTTP rodando mesmo depois do cliente ter ido embora.
Os padrões que aparecem com mais frequência são simples:
context.Background() (ou TODO). Isso desconecta o trabalho do cancelamento do cliente e do deadline do handler.ctx.Done(). A requisição é cancelada, mas seu código continua esperando.context.WithTimeout. Você acaba com muitos timers e deadlines confusos.ctx a chamadas bloqueantes (queries DB, HTTP de saída, publish de mensagens). Um timeout no handler não faz nada se a chamada dependente o ignora.Uma falha clássica: você adiciona um timeout de 2 segundos no handler, então seu repositório usa context.Background() para a query ao banco. Sob carga, uma query lenta continua rodando mesmo depois do cliente ter desistido, e o acúmulo cresce.
Conserte o básico: passe ctx como primeiro argumento pela sua stack de chamadas. Dentro de trabalhos longos, adicione checagens rápidas como select { case <-ctx.Done(): return ctx.Err() default: }. Mapear context.DeadlineExceeded para uma resposta de timeout (frequentemente 504) e context.Canceled para resposta de cancelamento do cliente (frequentemente 408 ou 499, dependendo da convenção) ajuda a manter consistência.
Timeouts só ajudam se você conseguir vê-los acontecer e confirmar que o sistema se recupera bem. Quando algo fica lento, a requisição deve parar, recursos devem ser liberados e a API deve se manter responsiva.
Para cada requisição, registre o mesmo pequeno conjunto de campos para poder comparar requisições normais vs timeouts. Inclua o deadline do contexto (se existir) e o que encerrou o trabalho.
Campos úteis incluem o deadline (ou "none"), tempo total decorrido, motivo de cancelamento (timeout vs cliente cancelou), um rótulo curto da operação ("db.query users", "http.call billing") e um request ID.
Um padrão mínimo fica assim:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
Logs ajudam a debugar uma requisição. Métricas mostram tendências.
Monitore alguns sinais que normalmente disparam cedo quando os timeouts estão errados: contagem de timeouts por rota e dependência, requisições em andamento (in-flight) — que devem se estabilizar sob carga —, tempo de espera na pool do DB e percentis de latência (p95/p99) separados por sucesso vs timeout.
Torne a lentidão previsível. Adicione um delay só em debug para um handler, torne uma query do DB propositalmente lenta com um wait deliberado ou coloque um servidor de teste que dorme para simular uma chamada externa. Então verifique duas coisas: veja o erro de timeout, e que o trabalho para logo após o cancelamento.
Um pequeno teste de carga também ajuda. Rode 20 a 50 requisições concorrentes por 30 a 60 segundos com uma dependência forçada a ser lenta. A contagem de goroutines e as requisições em andamento devem subir e então se estabilizar. Se continuarem subindo, algo está ignorando o cancelamento do contexto.
Timeouts só ajudam se forem aplicados em todo lugar onde uma requisição pode esperar. Antes de enviar, faça uma passada no código e confirme as mesmas regras em todos os handlers.
context.DeadlineExceeded e context.Canceled.http.NewRequestWithContext (ou req = req.WithContext(ctx)) e o cliente tem timeouts no transport (dial, TLS, header). Evite depender do http.DefaultClient em caminhos de produção.Um pequeno exercício de "dependência lenta" antes do release vale a pena. Adicione um delay artificial de 2 segundos em uma query SQL e confirme três coisas: o handler retorna no tempo, a chamada ao DB realmente para (não só o handler) e seus logs indicam claramente que foi um timeout no DB.
Imagine um endpoint como GET /v1/account/summary. Uma ação do usuário dispara três coisas: uma query PostgreSQL (conta + atividades recentes) e duas chamadas HTTP externas (por exemplo, cheque de status de billing e lookup de enriquecimento de perfil).
Dê à requisição inteira um orçamento rígido de 2 segundos. Sem um orçamento, uma dependência lenta pode manter goroutines, conexões de DB e memória presas até sua API começar a expirar em massa.
Uma divisão simples pode ser 800ms para a query DB, 600ms para a chamada externa A e 600ms para a chamada externa B.
Quando você conhece o deadline geral, passe-o para baixo. Cada dependência recebe seu próprio timeout menor, mas ainda herda o cancelamento do pai.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
Se a chamada externa B atrasar e levar 2.5 segundos, seu handler deve parar de esperar aos 600ms, cancelar o trabalho em andamento e retornar uma resposta clara de timeout ao cliente. O cliente vê uma falha rápida em vez de um spinner que não termina.
Seus logs devem deixar óbvio o que consumiu o orçamento, por exemplo: DB terminou rápido, externa A teve sucesso, externa B atingiu seu limite e retornou context deadline exceeded.
Quando um endpoint real estiver funcionando bem com timeouts e cancelamento, transforme isso em um padrão repetível. Aplique de ponta a ponta: deadline no handler, chamadas ao DB e HTTP de saída. Depois copie a mesma estrutura para o próximo endpoint.
Você vai avançar mais rápido se centralizar as partes chatas: um helper de timeout na borda, wrappers que garantem que ctx seja passado para DB e chamadas HTTP, e um mapeamento consistente de erros e formato de logs.
Se quiser prototipar esse padrão rapidamente, Koder.ai (koder.ai) pode gerar handlers Go e chamadas de serviço a partir de um prompt de chat, e você pode exportar o código-fonte para aplicar seus próprios helpers de timeout e orçamentos. O objetivo é consistência: chamadas lentas param cedo, erros têm aparência uniforme e o debug não depende de quem escreveu o endpoint.
Uma requisição lenta prende recursos limitados enquanto espera: uma goroutine, memória para buffers e objetos de resposta e, frequentemente, uma conexão de banco de dados ou conexão HTTP. Quando várias requisições ficam esperando ao mesmo tempo, formam-se filas, a latência sobe para todo o tráfego e o serviço pode falhar mesmo que cada requisição acabaria por concluir.
Defina um deadline claro na borda da requisição (proxy/gateway e no servidor Go), derive um contexto com timeout no handler e passe esse ctx para toda chamada bloqueante (banco de dados e HTTP externo). Quando o prazo for alcançado, retorne rapidamente com uma resposta de timeout consistente e pare qualquer trabalho em andamento que suporte cancelamento.
Use context.WithTimeout(parent, d) quando quiser “parar após essa duração” — o mais comum em handlers. Use context.WithDeadline(parent, t) quando você já tem um horário exato de corte. Use context.WithCancel(parent) quando alguma condição interna deve interromper o trabalho cedo, por exemplo “já temos a resposta” ou “o cliente desconectou”.
Sempre chame a função cancel, normalmente com defer cancel() logo após criar o contexto derivado. Cancelar libera o timer e dá um sinal claro de parada para qualquer trabalho filho, especialmente em caminhos de código que retornam cedo antes do deadline ser alcançado.
Crie o contexto da requisição uma vez no handler e passe-o como primeiro argumento para funções que podem bloquear. Uma verificação rápida é procurar por context.Background() ou context.TODO() em caminhos de requisição; esses normalmente quebram a propagação de cancelamento ao desconectar o trabalho do deadline da requisição.
Use métodos do database/sql que aceitam contexto, como QueryContext, QueryRowContext e ExecContext (ou equivalentes no driver). Quando o contexto terminar, o driver pode pedir ao Postgres para cancelar a query para que você não continue consumindo tempo e conexões depois que a requisição acabou.
Anexe o contexto da requisição ao pedido de saída com http.NewRequestWithContext(ctx, ...) e também configure timeouts no cliente/transport para proteger o handshake, TLS e a espera pelos headers de resposta. Mesmo em erros ou respostas não-200, sempre feche resp.Body para devolver conexões ao pool.
Escolha um orçamento total para a requisição primeiro e depois dê a cada dependência uma fatia menor que caiba nesse total, reservando um pequeno buffer para overhead do handler e formatação da resposta. Se o contexto pai só tem pouco tempo restante, não inicie um trabalho caro que não termine antes do deadline.
Um padrão comum é mapear context.DeadlineExceeded para 504 Gateway Timeout com uma mensagem curta como “request timed out”. Para context.Canceled, geralmente significa que o cliente desconectou; muitas vezes a melhor ação é parar o trabalho e não escrever corpo de resposta, para não desperdiçar recursos.
Os erros mais frequentes são usar context.Background() no lugar do contexto da requisição, iniciar retries ou sleeps sem checar ctx.Done(), e esquecer de anexar ctx a chamadas bloqueantes. Outro problema sutil é empilhar vários timeouts desconexos, o que torna as falhas difíceis de entender e pode provocar cortes prematuros inesperados.