← Voltar para o blog

httptrace em Go: Debug de Cliente HTTP sem Sair da Standard Library

Aprenda a usar net/http/httptrace em Go para investigar DNS, conexão TCP, TLS, reutilização de conexões e latência em chamadas HTTP externas.

httptrace é uma das ferramentas mais subestimadas da standard library de Go. Quando uma chamada HTTP externa fica lenta, muita gente começa olhando apenas para o tempo total da requisição. Isso responde “demorou quanto”, mas não responde “onde demorou”: DNS, abertura de conexão, TLS, espera por conexão reutilizada, envio do request ou espera pelo primeiro byte da resposta.

Em APIs brasileiras que dependem de gateways de pagamento, antifraude, ERP, correios, provedores de nota fiscal, serviços bancários, CRMs ou integrações B2B, essa diferença importa. Um endpoint pode parecer lento por culpa do seu código, quando na verdade o gargalo está em resolução DNS, handshake TLS, pool de conexões mal configurado ou timeout ausente no cliente HTTP.

Este guia mostra como usar net/http/httptrace para instrumentar uma chamada sem instalar biblioteca externa. Se você ainda está montando a base, leia também Go para back-end, context.Context em Go e OpenTelemetry em Go.

Quando usar httptrace

Use httptrace quando você precisa enxergar o ciclo de uma requisição HTTP do ponto de vista do cliente. Ele ajuda especialmente em perguntas como:

  • O DNS está demorando?
  • A conexão TCP está sendo aberta a cada request?
  • O handshake TLS está caro demais?
  • O cliente está reutilizando conexões ou perdendo keep-alive?
  • O servidor remoto demora para enviar o primeiro byte?
  • Um timeout está estourando antes ou depois da conexão?

Isso é diferente de medir apenas time.Since(start) ao redor de client.Do(req). O tempo total continua útil, mas httptrace quebra o caminho em etapas. Em incidentes, essa quebra evita chute.

Exemplo mínimo

O pacote funciona anexando callbacks ao contexto do request. Cada callback é chamado em uma fase específica da requisição.

package main

import (
    "context"
    "crypto/tls"
    "fmt"
    "net/http"
    "net/http/httptrace"
    "time"
)

func main() {
    req, err := http.NewRequest("GET", "https://go.dev/", nil)
    if err != nil {
        panic(err)
    }

    var start time.Time

    trace := &httptrace.ClientTrace{
        DNSStart: func(info httptrace.DNSStartInfo) {
            fmt.Println("dns start:", info.Host)
        },
        DNSDone: func(info httptrace.DNSDoneInfo) {
            fmt.Println("dns done:", info.Addrs, info.Err)
        },
        ConnectStart: func(network, addr string) {
            fmt.Println("connect start:", network, addr)
        },
        ConnectDone: func(network, addr string, err error) {
            fmt.Println("connect done:", network, addr, err)
        },
        TLSHandshakeStart: func() {
            fmt.Println("tls start")
        },
        TLSHandshakeDone: func(state tls.ConnectionState, err error) {
            fmt.Println("tls done:", state.ServerName, err)
        },
        GotConn: func(info httptrace.GotConnInfo) {
            fmt.Println("got conn reused:", info.Reused)
        },
        GotFirstResponseByte: func() {
            fmt.Println("first byte after:", time.Since(start))
        },
    }

    ctx := httptrace.WithClientTrace(context.Background(), trace)
    req = req.WithContext(ctx)

    client := &http.Client{Timeout: 5 * time.Second}

    start = time.Now()
    res, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer res.Body.Close()

    fmt.Println("status:", res.Status)
    fmt.Println("total:", time.Since(start))
}

Na prática, você normalmente não imprime direto no callback. Em produção, prefira registrar métricas, spans ou logs estruturados. O valor do exemplo é mostrar a ordem dos eventos.

O que cada fase revela

DNSStart e DNSDone mostram resolução de nome. Se aparecem em toda requisição, talvez o cliente esteja criando conexões novas demais ou o ambiente tenha cache DNS ruim. Para serviços internos, também vale verificar se o hostname resolve para a rede esperada.

ConnectStart e ConnectDone mostram abertura de conexão TCP. Se essa fase aparece sempre, o problema pode ser baixo reuso de conexão, Transport recriado por request, MaxIdleConnsPerHost pequeno ou servidor remoto fechando keep-alive.

TLSHandshakeStart e TLSHandshakeDone mostram o custo de TLS. Handshake caro em todas as chamadas pode indicar falta de conexão persistente, rota de rede instável ou endpoint remoto com TLS pesado.

GotConn é um dos callbacks mais úteis. O campo Reused mostra se o cliente reaproveitou uma conexão existente. Para chamadas frequentes ao mesmo host, muitas conexões não reutilizadas são sinal de configuração ruim.

GotFirstResponseByte marca o tempo até o primeiro byte da resposta. Se DNS, conexão e TLS são rápidos, mas o primeiro byte demora, o gargalo provavelmente está no servidor remoto, no processamento da API chamada ou em filas internas desse provedor.

Erro comum: recriar http.Client por request

Um anti-padrão frequente em projetos Go é criar cliente novo dentro de cada função:

func buscarPedido(ctx context.Context, id string) error {
    client := &http.Client{Timeout: 5 * time.Second}
    // ... monta request e chama client.Do
    return nil
}

Isso parece inofensivo, mas normalmente impede o reaproveitamento saudável do Transport e das conexões. Em sistemas com volume, vira latência extra, mais handshakes TLS, mais portas efêmeras e mais carga no provedor externo.

Prefira criar um cliente compartilhado por integração:

var paymentClient = &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 20,
        IdleConnTimeout:     90 * time.Second,
    },
}

Depois use httptrace em requests específicos para confirmar se GotConnInfo.Reused começa a aparecer como true.

Medindo duração por etapa

Para investigar um incidente, imprimir eventos já ajuda. Para análise mais útil, guarde timestamps:

type Timings struct {
    Start        time.Time
    DNSStart     time.Time
    DNSDone      time.Time
    ConnectStart time.Time
    ConnectDone  time.Time
    TLSStart     time.Time
    TLSDone      time.Time
    FirstByte    time.Time
}

Com isso, você consegue calcular durações e registrar algo como:

dns=8ms connect=21ms tls=42ms ttfb=310ms total=380ms reused=false

Esse tipo de log é muito mais acionável do que “gateway demorou 380ms”. Se reused=false aparece em quase tudo, mexa no cliente e no Transport. Se ttfb domina, leve a evidência para o provedor ou revise o endpoint chamado. Se DNS oscila, olhe infraestrutura, resolver e rede.

Como combinar com context e timeout

httptrace não substitui timeout. Ele mostra onde o tempo foi gasto, mas o request ainda precisa de limite claro. Em Go, combine três camadas:

  1. http.Client{Timeout: ...} para limite total simples.
  2. context.WithTimeout quando o request faz parte de uma operação maior.
  3. http.Transport ajustado quando você precisa controlar conexão, TLS e pool.

Evite request sem timeout em produção. Uma integração externa lenta não pode prender goroutine indefinidamente. O rastreamento ajuda a diagnosticar; o timeout protege o sistema.

Relação com OpenTelemetry

Se você já usa OpenTelemetry, talvez se pergunte por que usar httptrace. A resposta prática: os dois se complementam. OpenTelemetry é melhor para correlação distribuída, spans, traces entre serviços e visão agregada. httptrace é excelente para diagnosticar detalhes do cliente HTTP local, principalmente quando você suspeita de DNS, TLS ou pool de conexões.

Em um time maduro, o caminho costuma ser:

  • usar OpenTelemetry como camada padrão de observabilidade;
  • ativar logs ou métricas derivadas de httptrace em integrações críticas;
  • coletar evidência detalhada durante incidentes ou benchmarks;
  • transformar aprendizados em configuração de Transport, timeout e retry.

Para uma API pequena, httptrace pode ser o primeiro passo antes de uma pilha completa de tracing.

Checklist para APIs brasileiras

Antes de culpar uma integração externa, rode este checklist:

  • O cliente HTTP é compartilhado ou recriado por request?
  • Existe timeout total e timeout por operação?
  • GotConnInfo.Reused aparece como true em chamadas repetidas?
  • DNS e TLS aparecem em toda chamada ou só nas primeiras?
  • O tempo até primeiro byte domina a latência?
  • O provedor externo tem região, rota ou endpoint mais adequado para o Brasil?
  • Logs e métricas separam falha de rede, timeout e resposta HTTP válida com status ruim?

Esse recorte é especialmente útil para times que integram meios de pagamento, serviços fiscais, antifraude, logística, bancos, parceiros B2B e APIs internas legadas. Go já dá ferramentas boas na biblioteca padrão; o trabalho é instrumentar antes de adivinhar.

Próximos passos

Se o seu gargalo é chamada externa, comece com httptrace em uma rota controlada e colete alguns minutos de evidência. Depois ajuste o Transport, revise timeouts e só então pense em retry, circuit breaker ou cache.

Para aprofundar o tema, leia também: