15 de mar. de 2025·7 min de leitura

Timeouts de context no Go para APIs: dos handlers HTTP ao SQL

Timeouts de context no Go ajudam a propagar deadlines do handler HTTP até chamadas SQL, evitar requisições presas e manter serviços estáveis sob carga.

Timeouts de context no Go para APIs: dos handlers HTTP ao SQL

Por que requisições ficam presas (e por que isso agrava sob carga)

Uma requisição fica “presa” quando espera algo que não retorna: uma consulta ao banco lenta, uma conexão do pool bloqueada, um problema de DNS ou um serviço upstream que aceita a chamada mas nunca responde.

O sintoma é simples: algumas requisições demoram uma eternidade, e outras se acumulam atrás delas. Frequentemente você verá memória subindo, número crescente de goroutines e uma fila de conexões abertas que não drena.

Sob carga, requisições presas prejudicam em dobro. Elas mantêm workers ocupados e retêm recursos escassos como conexões ao banco e locks. Isso deixa requisições normalmente rápidas lentas, o que gera mais sobreposição e ainda mais espera.

Retries e picos de tráfego pioram a espiral. Um cliente dá timeout e tenta novamente enquanto a requisição original ainda está rodando, então você paga por duas requisições. Multiplique isso por muitos clientes durante uma breve lentidão e você pode sobrecarregar o banco ou atingir limites de conexão mesmo que o tráfego médio esteja ok.

Um timeout é simplesmente uma promessa: “não vamos esperar mais do que X”. Ele ajuda a falhar rápido e liberar recursos, mas não faz o trabalho terminar mais rápido.

Também não garante que o trabalho pare instantaneamente. Por exemplo, o banco pode continuar executando, um serviço upstream pode ignorar seu cancelamento ou seu próprio código pode não ser seguro para cancelamento.

O que um timeout garante é que seu handler pode parar de esperar, retornar um erro claro e liberar o que estava segurando. Essa espera limitada é o que impede que algumas chamadas lentas virem uma indisponibilidade total.

O objetivo com timeouts de context em Go é um único deadline compartilhado da borda até a chamada mais profunda. Defina-o uma vez na borda HTTP, passe o mesmo contexto pelo código do serviço e use-o nas chamadas database/sql para que o banco também saiba quando parar de esperar.

Context em Go em termos simples

Um context.Context é um pequeno objeto que você passa pelo código para descrever o que está acontecendo agora. Ele responde perguntas como: “essa requisição ainda é válida?”, “quando devemos desistir?” e “quais valores escopados à requisição devem acompanhar este trabalho?”.

A grande vantagem é que uma decisão na borda do sistema (seu handler HTTP) pode proteger cada passo a jusante, desde que você continue passando o mesmo contexto.

O que o contexto carrega

Context não é lugar para dados de negócio. Serve para sinais de controle e uma pequena quantidade de escopo por requisição: cancelamento, um deadline/timeout e metadados pequenos como um request ID para logs.

Timeout vs cancelamento é simples: um timeout é uma causa de cancelamento. Se você define um timeout de 2 segundos, o contexto será cancelado após 2 segundos. Mas um contexto também pode ser cancelado mais cedo se o usuário fechar a aba, o balanceador de carga cair a conexão ou seu código decidir interromper a requisição.

Context flui por chamadas de função como um parâmetro explícito, normalmente o primeiro: func DoThing(ctx context.Context, ...). Esse é o objetivo. Fica difícil “esquecer” quando ele aparece em todo ponto de chamada.

Quando o deadline expira, qualquer coisa observando aquele contexto deve parar rapidamente. Por exemplo, uma consulta ao banco usando QueryContext deve retornar cedo com um erro como context deadline exceeded, e seu handler pode responder com um timeout em vez de ficar pendurado até o servidor ficar sem workers.

Um bom modelo mental: uma requisição, um contexto, passado em toda parte. Se a requisição morrer, o trabalho também deve morrer.

Definindo um deadline claro na borda HTTP

Se você quer que timeouts ponta a ponta funcionem, decida onde o relógio começa. O lugar mais seguro é bem na borda HTTP, assim cada chamada a jusante (lógica de negócio, SQL, outros serviços) herda o mesmo deadline.

Você pode definir esse deadline em alguns pontos. Timeouts ao nível do servidor são uma boa base e protegem de clientes lentos. Middleware é ótimo para consistência entre grupos de rotas. Definir dentro do handler também funciona quando você quer algo explícito e local.

Para a maioria das APIs, timeouts por requisição em middleware ou no handler são os mais fáceis de raciocinar. Mantenha-os realistas: usuários preferem uma falha rápida e clara a uma requisição que fica pendurada. Muitas equipes usam orçamentos menores para leituras (1–2s) e um pouco mais para gravações (3–10s), dependendo do que o endpoint faz.

Aqui está um padrão simples de handler:

func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    report, err := s.reports.Generate(ctx, r.URL.Query().Get("id"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusGatewayTimeout)
        return
    }

    json.NewEncoder(w).Encode(report)
}

Duas regras mantêm isso efetivo:

  • Sempre chame cancel() para que timers e recursos sejam liberados rapidamente.
  • Nunca substitua o request context por context.Background() ou context.TODO() dentro do handler. Isso quebra a cadeia, e suas chamadas ao banco e requisições externas podem rodar para sempre mesmo depois que o cliente se foi.

Propagando context pelo seu código

Depois de definir um deadline na borda HTTP, o trabalho real é garantir que esse mesmo deadline alcance cada camada que pode bloquear. A ideia é um relógio único, compartilhado pelo handler, código de serviço e qualquer coisa que toque a rede ou o disco.

Uma regra simples mantém as coisas consistentes: toda função que possa esperar deve aceitar um context.Context e ele deve ser o primeiro parâmetro. Isso fica óbvio nos pontos de chamada e vira hábito.

Um padrão prático de assinatura

Prefira assinaturas como DoThing(ctx context.Context, ...) para services e repositórios. Evite esconder context dentro de structs ou recriá-lo com context.Background() em camadas inferiores, porque isso silenciosamente descarta o deadline do chamador.

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if err := h.svc.CreateOrder(ctx, r.Body); err != nil {
        // mapear erros de context para uma resposta clara ao cliente em outro lugar
        http.Error(w, err.Error(), http.StatusRequestTimeout)
        return
    }
}

func (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {
    // parsing ou validação ainda pode respeitar cancelamento
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }

    return s.repo.InsertOrder(ctx, /* data */)
}

Lidando com saídas antecipadas de forma limpa

Trate ctx.Done() como um fluxo normal de controle. Dois hábitos ajudam:

  • Verifique ctx.Err() antes de começar trabalho custoso e após loops longos.
  • Retorne ctx.Err() para cima sem alteração, assim o handler pode responder rápido e parar de desperdiçar recursos.

Quando cada camada passa o mesmo ctx, um único timeout pode cortar parsing, lógica de negócio e esperas no banco de uma só vez.

Aplicando deadlines às queries do database/sql

Construa APIs com deadlines claros
Crie um backend Go no AppMaster e mantenha deadlines consistentes do handler ao SQL.
Experimente o AppMaster

Quando seu handler HTTP tem um deadline, garanta que o trabalho no banco realmente o escute. Com database/sql, isso significa usar os métodos com contexto sempre. Se você chamar Query() ou Exec() sem contexto, sua API pode continuar esperando por uma query lenta mesmo depois que o cliente desistiu.

Use consistentemente: db.QueryContext, db.QueryRowContext, db.ExecContext e db.PrepareContext (e então QueryContext/ExecContext na statement retornada).

func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
	row := s.db.QueryRowContext(ctx,
		`SELECT id, email FROM users WHERE id = $1`, id,
	)
	var u User
	if err := row.Scan(&u.ID, &u.Email); err != nil {
		return nil, err
	}
	return &u, nil
}

func (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {
	_, err := s.db.ExecContext(ctx,
		`UPDATE users SET email = $1 WHERE id = $2`, email, id,
	)
	return err
}

Duas coisas são fáceis de perder de vista.

Primeiro, seu driver SQL deve honrar o cancelamento do contexto. Muitos fazem, mas confirme na sua stack testando uma query deliberadamente lenta e checando se ela cancela rapidamente quando o deadline é excedido.

Segundo, considere um timeout no lado do banco como proteção adicional. Por exemplo, o Postgres pode impor um limite por declaração (statement timeout). Isso protege o banco mesmo se um bug na aplicação esquecer de passar o contexto.

Quando uma operação para por timeout, trate isso diferente de um erro SQL normal. Verifique errors.Is(err, context.DeadlineExceeded) e errors.Is(err, context.Canceled) e retorne uma resposta clara (como 504) em vez de tratar como “banco quebrado”. Se você gera backends Go (por exemplo com AppMaster), manter esses caminhos de erro distintos também facilita raciocinar sobre logs e retries.

Chamadas downstream: clientes HTTP, caches e outros serviços

Mesmo que seu handler e queries SQL respeitem o contexto, uma requisição ainda pode pendurar se uma chamada downstream esperar para sempre. Sob carga, algumas goroutines presas podem se acumular, esgotar pools de conexão e transformar uma pequena lentidão em uma indisponibilidade total. A solução é propagação consistente mais um backstop rígido.

HTTP outbound

Ao chamar outra API, construa a requisição com o mesmo contexto para que o deadline e o cancelamento fluam automaticamente.

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { /* handle */ }
resp, err := httpClient.Do(req)

Não confie só no contexto. Também configure o cliente HTTP e o transport para que você esteja protegido caso o código use acidentalmente um background context, ou se DNS/TLS/idle connections travarem. Defina http.Client.Timeout como um limite superior para toda a chamada, configure timeouts no transport (dial, TLS handshake, response header) e reutilize um cliente em vez de criar um por requisição.

Caches e filas

Caches, brokers de mensagens e clientes RPC frequentemente têm seus próprios pontos de espera: adquirir uma conexão, aguardar uma resposta, bloquear em uma fila cheia ou esperar por um lock. Garanta que essas operações aceitem ctx e também use timeouts de biblioteca quando disponíveis.

Uma regra prática: se o pedido do usuário tem 800ms sobrando, não inicie uma chamada downstream que pode levar 2s. Pule, degrade ou retorne uma resposta parcial.

Decida antecipadamente o que um timeout significa para sua API. Às vezes a resposta correta é um erro rápido. Às vezes é dados parciais para campos opcionais. Às vezes é dados em cache desatualizados, claramente marcados.

Se você constrói backends Go (incluindo gerados, como no AppMaster), essa é a diferença entre “existem timeouts” e “timeouts protegem consistentemente o sistema” quando há picos de tráfego.

Passo a passo: refatorar uma API para usar timeouts ponta a ponta

Torne retries mais seguros sob carga
Crie endpoints que falhem rápido e liberem recursos com deadlines consistentes.
Construir API

Refatorar para timeouts reduz-se a um hábito: passar o mesmo context.Context da borda HTTP até cada chamada que possa bloquear.

Uma maneira prática é trabalhar de cima para baixo:

  • Altere seu handler e métodos centrais para aceitar ctx context.Context.
  • Atualize todas as chamadas ao DB para usar QueryContext ou ExecContext.
  • Faça o mesmo para chamadas externas (HTTP, caches, filas). Se uma biblioteca não aceitar ctx, envolva-a ou substitua-a.
  • Decida quem é dono dos timeouts. Uma regra comum: o handler define o deadline geral; camadas inferiores só definem deadlines mais curtos quando necessário.
  • Torne erros previsíveis na borda: mapeie context.DeadlineExceeded e context.Canceled para respostas HTTP claras.

Aqui está a forma desejada entre as camadas:

func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    order, err := h.svc.GetOrder(ctx, r.PathValue("id"))
    if errors.Is(err, context.DeadlineExceeded) {
        http.Error(w, "request timed out", http.StatusGatewayTimeout)
        return
    }
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    _ = json.NewEncoder(w).Encode(order)
}

func (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {
    row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)
    // scan...
}

Valores de timeout devem ser entediantes e consistentes. Se o handler tem 2 segundos no total, mantenha queries DB abaixo de 1 segundo para deixar espaço para JSON encoding e outros trabalhos.

Para provar que funciona, adicione um teste que force um timeout. Uma abordagem simples é um método de repositório fake que bloqueia até ctx.Done() e então retorna ctx.Err(). Seu teste deve afirmar que o handler retorna um 504 rapidamente, não após o atraso fake.

Se você gera backends Go com um gerador (por exemplo, AppMaster), a regra é a mesma: um context por requisição, encaixado em todo lugar, com propriedade clara do deadline.

Observabilidade: provar que os timeouts estão funcionando

Gere código-fonte Go real
Prototipe no-code e depois exporte serviços Go prontos para produção quando precisar de controle total.
Construir Backend

Timeouts só ajudam se você conseguir vê-los acontecendo. O objetivo é simples: cada requisição tem um deadline e, quando ela falha, você consegue dizer onde o tempo foi gasto.

Comece com logs que sejam seguros e úteis. Em vez de despejar bodies inteiros, registre o suficiente para conectar os pontos e detectar caminhos lentos: request ID (ou trace ID), se um deadline está definido e quanto tempo resta em pontos-chave, o nome da operação (handler, nome da query SQL, chamada externa) e a categoria do resultado (ok, timeout, canceled, outro erro).

Adicione algumas métricas focadas para que o comportamento sob carga fique óbvio:

  • Contagem de timeouts por endpoint e dependência
  • Latência de requisições (p50/p95/p99)
  • Requisições em voo
  • Latência de queries no banco (p95/p99)
  • Taxa de erro segmentada por tipo

Ao tratar erros, marque-os corretamente. context.DeadlineExceeded normalmente significa que você estourou seu orçamento. context.Canceled frequentemente significa que o cliente se foi ou um timeout upstream disparou primeiro. Mantenha-os separados porque as correções são diferentes.

Tracing: encontre o gargalo de tempo

Spans de tracing devem seguir o mesmo context do handler até chamadas database/sql como QueryContext. Por exemplo, uma requisição estoura o timeout em 2s e o trace mostra 1.8s esperando por uma conexão do DB. Isso aponta para tamanho do pool ou transações lentas, não para o texto da query.

Se você construir um dashboard interno para isso (timeouts por rota, queries lentas), uma ferramenta no-code como AppMaster pode ajudar a entregar isso rápido sem transformar observabilidade em um projeto separado de engenharia.

Erros comuns que anulam seus timeouts

A maioria dos bugs “ainda fica pendurado às vezes” vem de alguns erros pequenos.

  • Resetar o relógio no meio do voo. Um handler define 2s, mas o repositório cria um contexto novo com outro timeout (ou sem timeout). Agora o banco pode continuar rodando depois que o cliente foi embora. Passe o ctx recebido e só aperte o tempo quando houver motivo claro.
  • Iniciar goroutines que nunca param. Spawnar trabalho com context.Background() (ou descartar o ctx) faz com que ele continue rodando mesmo após o cancelamento. Passe o ctx da requisição para goroutines e select em ctx.Done().
  • Deadlines curtos demais para tráfego real. Um timeout de 50ms pode funcionar na sua máquina e falhar em produção durante um pequeno pico, causando retries, mais carga e um mini-outage autoinduzido. Escolha timeouts com base na latência normal mais uma margem.
  • Esconder o erro real. Tratar context.DeadlineExceeded como um 500 genérico piora debug e comportamento do cliente. Mapeie para uma resposta de timeout clara e logue a diferença entre “cancelado pelo cliente” e “estourou o timeout”.
  • Deixar recursos abertos em saídas antecipadas. Se você retornar cedo, assegure que ainda faça defer rows.Close() e chame o cancel da context.WithTimeout. Rows vazadas ou trabalho pendente podem esgotar conexões sob carga.

Um exemplo rápido: um endpoint dispara uma query de relatório. Se o usuário fecha a aba, o ctx do handler é cancelado. Se sua chamada SQL usou um background context, a query ainda roda, prendendo uma conexão e deixando tudo mais lento. Ao propagar o mesmo ctx para QueryContext, a chamada ao banco é interrompida e o sistema se recupera mais rápido.

Checklist rápido para comportamento confiável de timeout

Implemente onde sua stack roda
Lance apps no AppMaster Cloud ou na sua AWS, Azure ou Google Cloud.
Fazer Deploy

Timeouts só ajudam se forem consistentes. Uma única chamada perdida pode manter uma goroutine ocupada, segurar uma conexão e atrasar as próximas requisições.

  • Defina um deadline claro na borda (normalmente o handler HTTP). Tudo dentro da requisição deve herdar isso.
  • Passe o mesmo ctx pelas camadas de serviço e repositório. Evite context.Background() no código de pedido.
  • Use métodos do DB que aceitam contexto em todos os lugares: QueryContext, QueryRowContext e ExecContext.
  • Anexe o mesmo ctx a chamadas externas (HTTP, caches, filas). Se criar um contexto filho, mantenha-o mais curto, não mais longo.
  • Trate cancelamentos e timeouts de forma consistente: retorne um erro limpo, pare o trabalho e evite loops de retry dentro de uma requisição cancelada.

Depois disso, verifique o comportamento sob pressão. Um timeout que dispara mas não libera recursos rápido o suficiente ainda prejudica a confiabilidade.

Dashboards devem deixar timeouts óbvios, não escondidos em médias. Monitore sinais como: timeouts de requisição e do banco (separados), percentis de latência (p95, p99), estatísticas do pool de DB (conexões em uso, contagem de espera, duração de espera) e uma quebra das causas de erro (context deadline exceeded vs outros).

Se você cria ferramentas internas numa plataforma como AppMaster, o mesmo checklist se aplica a qualquer serviço Go que você conecte: defina deadlines na borda, propague-os e confirme nas métricas que requisições presas viram falhas rápidas em vez de filas lentas.

Cenário exemplo e próximos passos

Um lugar comum onde isso compensa é um endpoint de busca. Imagine GET /search?q=printer ficar mais lento quando o banco está ocupado com uma query de relatório grande. Sem deadline, cada requisição pode ficar esperando uma query longa. Sob carga, essas requisições presas se acumulam, ocupam goroutines e conexões, e a API inteira parece travada.

Com um deadline claro no handler HTTP e o mesmo ctx passado até o repositório, o sistema para de esperar quando o orçamento acaba. Quando o deadline chega, o driver do banco cancela a query (se suportado), o handler retorna e o servidor continua atendendo novas requisições em vez de esperar para sempre.

O comportamento visível ao usuário melhora mesmo quando algo dá errado. Em vez de ficar girando por 30–120 segundos e falhar de forma confusa, o cliente recebe um erro rápido e previsível (frequentemente 504 ou 503 com uma mensagem curta como “request timed out”). Mais importante, o sistema se recupera rápido porque novas requisições não ficam bloqueadas atrás das antigas.

Próximos passos para que isso seja adotado por endpoints e equipes:

  • Escolha timeouts padrão por tipo de endpoint (busca vs gravações vs exports).
  • Exija QueryContext e ExecContext em code review.
  • Torne erros de timeout explícitos na borda (código de status claro, mensagem simples).
  • Adicione métricas de timeouts e cancelamentos para perceber regressões cedo.
  • Escreva um helper que encapsule criação de context e logging para que todos os handlers se comportem igual.

Se você constrói serviços e ferramentas internas com AppMaster, pode aplicar essas regras de timeout de forma consistente em backends Go gerados, integrações de API e dashboards em um só lugar. AppMaster está disponível em appmaster.io (no-code, com geração real de código Go), então pode ser uma opção prática quando quiser tratamento consistente de requisições e observabilidade sem construir cada ferramenta administrativa na mão.

FAQ

O que significa quando uma requisição fica “presa” em uma API Go?

Uma requisição fica “presa” quando está esperando algo que não retorna, como uma consulta SQL lenta, uma conexão do pool bloqueada, problemas de DNS ou um serviço upstream que aceita a chamada mas não responde. Sob carga, requisições presas se acumulam, ocupam workers e conexões, e podem transformar uma pequena lentidão em uma falha generalizada.

Onde devo definir o timeout: middleware, handler ou mais fundo no código?

Defina o deadline geral na borda HTTP e passe o mesmo ctx para todas as camadas que podem bloquear. Esse deadline compartilhado evita que algumas operações lentas mantenham recursos tempo suficiente para provocar um efeito cascata.

Por que preciso chamar `cancel()` se o timeout vai ocorrer de qualquer forma?

Use ctx, cancel := context.WithTimeout(r.Context(), d) e sempre defer cancel() no handler (ou no middleware). Chamar cancel() libera timers e ajuda a interromper esperas prontamente quando a requisição terminar antes do tempo limite.

Qual é o maior erro que torna timeouts inúteis?

Não substitua o contexto por context.Background() ou context.TODO() no código de requisição, porque isso quebra cancelamento e deadlines. Se você descartar o context do pedido, trabalho downstream como SQL ou HTTP externo pode continuar rodando mesmo depois que o cliente se foi.

Como devo tratar `context deadline exceeded` vs `context canceled`?

Trate context.DeadlineExceeded e context.Canceled como resultados normais de controle e propague-os para cima sem alteração. Na borda, mapeie-os para respostas claras (frequentemente 504 para timeouts) para que os clientes não tentem retry cegamente por causa de um 500 genérico.

Quais chamadas de `database/sql` devem usar contexto?

Use sempre os métodos que aceitam contexto: QueryContext, QueryRowContext, ExecContext, e PrepareContext. Se você chamar Query() ou Exec() sem contexto, seu handler pode expirar enquanto a chamada ao banco ainda mantém a goroutine e a conexão bloqueadas.

Cancelar um context realmente interrompe uma query PostgreSQL em execução?

Muitos drivers cancelam a query no PostgreSQL quando o contexto é cancelado, mas verifique no seu stack testando uma consulta deliberadamente lenta e confirmando que ela retorna rápido após o deadline. Também é sensato configurar um statement timeout no banco como proteção extra caso algum caminho de código esqueça de passar ctx.

Como aplico o mesmo deadline para chamadas HTTP outbound?

Crie a requisição de saída com http.NewRequestWithContext(ctx, ...) para que o mesmo deadline e cancelamento se propaguem. Além disso, configure timeouts no http.Client e no transport (dial, TLS handshake, response header) como limite superior, já que o contexto não protege se alguém usar acidentalmente um background context ou ocorrer um bloqueio em baixo nível.

As camadas inferiores (repo/services) devem criar seus próprios timeouts?

Evite criar contextos novos que estendam o orçamento de tempo nas camadas mais baixas; contextos filhos devem ser mais curtos, não mais longos. Se a requisição tiver pouco tempo restante, pule chamadas downstream opcionais, devolva dados parciais quando apropriado ou falhe rápido com um erro claro.

O que devo monitorar para provar que timeouts ponta a ponta estão funcionando?

Monitore timeouts e cancelamentos separadamente por endpoint e dependência, junto com percentis de latência e requisições em voo. Em traces, propague o mesmo context pelo handler, chamadas externas e QueryContext para identificar se o tempo foi gasto esperando conexão do DB, executando a query ou bloqueado em outro serviço.

Fácil de começar
Criar algo espantoso

Experimente o AppMaster com plano gratuito.
Quando estiver pronto, você poderá escolher a assinatura adequada.

Comece