Pular para o conteúdo

Trilha de Go · parte 8 de 37

Cliente HTTP Profissional em Go: Resiliência, Pooling e Sockets

Como configurar o http.Client com timeouts, reaproveitar conexões com http.Transport e drenar respostas para não vazar recursos.

Publicada em · 6 min de leitura

Tamanho da fonte:
Largura da coluna:

Cliente HTTP Profissional em Go: Resiliência, Pooling e Sockets

Fazer requisições HTTP em Go parece trivial à primeira vista: basta invocar http.Get("https://api.empresa.com"). No entanto, esse código simples é uma bomba-relógio em ambientes de produção sob alta carga.

A biblioteca padrão do Go é extremamente poderosa e flexível, mas suas configurações default foram desenhadas para scripts utilitários rápidos, e não para microsserviços corporativos de missão crítica. Utilizar o http.DefaultClient sem timeouts configurados ou esquecer de drenar o corpo da resposta (resp.Body) resulta em vazamentos massivos de goroutines, esgotamento da tabela de arquivos do sistema operacional (file descriptor exhaustion) e tempestades de sockets em estado TIME_WAIT.


1. Por que http.DefaultClient é Proibido em Produção

O cliente padrão fornecido por http.DefaultClient (e os métodos de pacote http.Get, http.Post) possui uma configuração perigosa:

// Na biblioteca padrão:
var DefaultClient = &Client{}

Isso significa que Client.Timeout = 0 (tempo infinito). Se um servidor de terceiros travar, entrar em deadlock ou parar de responder pacotes TCP sem fechar a conexão:

  • A goroutine que disparou a requisição fica bloqueada para sempre.
  • O socket TCP permanece aberto indefinidamente.
  • Sob tráfego contínuo, milhares de goroutines acumulam na memória até a aplicação colapsar por exaustão de RAM.

2. Anatomia do http.Client e http.Transport

Um cliente HTTP em Go é composto por duas camadas fundamentais:

  1. http.Client: Camada de alto nível responsável por controle de redirecionamentos, cookies e o timeout global da requisição.
  2. http.Transport: Camada de baixo nível que gerencia a abertura de sockets TCP, handshake TLS, multiplexação HTTP/2 e o pool de conexões Keep-Alive.
Ciclo de Vida HTTP Client e Transport
figura 1 · Ciclo de Vida HTTP Client e Transport

O Gargalo Oculto: MaxIdleConnsPerHost

Por padrão, http.DefaultTransport define MaxIdleConnsPerHost: 2.

Em uma arquitetura de microsserviços onde seu serviço efetua centenas de requisições por segundo para um mesmo hostname (ex: api-pagamentos.internal):

  • O pool mantém apenas 2 conexões TCP ociosas abertas para esse host.
  • A 3ª requisição concorrente é forçada a abrir uma nova conexão TCP (com o custo de 3-way handshake + handshake TLS completo).
  • Ao terminar a requisição, o pool descarta a conexão excedente porque o limite de 2 já foi atingido!
  • Resultado: A aplicação sofre com alta latência por reabertura contínua de conexões e inunda o kernel com milhares de sockets descartados.

3. Construindo um Cliente Otimizado para Microsserviços

Abaixo está o padrão ouro para inicialização de um cliente HTTP corporativo com pool expandido e timeouts granulares:

package httpclient

import (
	"net"
	"net/http"
	"time"
)

// NewProductionClient retorna um http.Client thread-safe e otimizado
func NewProductionClient() *http.Client {
	transport := &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: (&net.Dialer{
			Timeout:   5 * time.Second,  // Limite para estabelecer o socket TCP
			KeepAlive: 30 * time.Second, // Intervalo de probes para manter socket ativo
		}).DialContext,

		// Configuração de Connection Pooling
		MaxIdleConns:        100,              // Total de conexões ociosas em todo o pool
		MaxIdleConnsPerHost: 25,               // CRÍTICO: Conexões ociosas mantidas por host
		IdleConnTimeout:     90 * time.Second, // Tempo máximo que uma conexão pode ficar ociosa
		
		// Timeouts de Protocolo e Handshake
		TLSHandshakeTimeout:   5 * time.Second,
		ResponseHeaderTimeout: 10 * time.Second, // Tempo máximo esperando os primeiros headers
		ExpectContinueTimeout: 1 * time.Second,
		
		// HTTP/2 ativado nativamente
		ForceAttemptHTTP2: true,
	}

	return &http.Client{
		Transport: transport,
		Timeout:   15 * time.Second, // Timeout GLOBAL (abertura + envio + leitura completa do body)
	}
}

4. O Ciclo de Vida do Socket: A Arte da Drenagem

Um dos mitos mais comuns em Go é acreditar que defer resp.Body.Close() é tudo o que você precisa para liberar recursos.

Mecânica de Reuso de Sockets
figura 2 · Mecânica de Reuso de Sockets

O Problema do Fechamento sem Drenagem

Se a sua aplicação lê apenas parte da resposta (por exemplo, apenas os primeiros bytes de erro ou um cabeçalho) e fecha o corpo sem consumir o restante:

  1. O socket TCP ainda possui bytes pendentes enviados pelo servidor remoto.
  2. Como o cliente Go não consumiu esses bytes até o io.EOF, o runtime não pode garantir a integridade do stream para a próxima requisição HTTP.
  3. O runtime Go é obrigado a matar a conexão TCP, enviando um pacote RST ou FIN.
  4. O socket entra no estado TIME_WAIT do kernel por 60 a 120 segundos. Sob tráfego pesado, a porta efêmera fica presa até o limite do sistema (cannot assign requested address).

A Solução Canônica: Drenagem Segura

Para devolver o socket TCP intacto ao pool de conexões reutilizáveis, você deve consumir os bytes remanescentes antes de fechar:

package httpclient

import (
	"io"
	"net/http"
)

// DrainAndClose esvazia os bytes residuais do stream e fecha o corpo
func DrainAndClose(resp *http.Response) {
	if resp == nil || resp.Body == nil {
		return
	}
	// Consome até 512KB residuais descartando-os sem alocação
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 512*1024))
	_ = resp.Body.Close()
}

Uso no código:

resp, err := client.Do(req)
if err != nil {
    return err
}
defer DrainAndClose(resp) // Garante devolução do socket ao pool!

5. Middlewares de Cliente com http.RoundTripper

Assim como servidores HTTP usam middlewares para interceptar requisições de entrada, clientes HTTP podem usar a interface http.RoundTripper para interceptar requisições de saída.

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

Esse padrão é perfeito para:

  • Injeção automática de tokens Bearer / JWT
  • Propagação de contexto de observabilidade W3C TraceContext (traceparent)
  • Métricas Prometheus de latência de saída
  • Logging estruturado com Logging Estruturado com slog
package middleware

import (
	"log/slog"
	"net/http"
	"time"
)

type ObservabilityTransport struct {
	Base http.RoundTripper
}

func (t *ObservabilityTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	start := time.Now()
	
	// Adiciona cabeçalhos corporativos de rastreio
	req.Header.Set("X-Client-Version", "v2.4.0")

	base := t.Base
	if base == nil {
		base = http.DefaultTransport
	}

	resp, err := base.RoundTrip(req)
	duration := time.Since(start)

	status := 0
	if resp != nil {
		status = resp.StatusCode
	}

	slog.Info("HTTP Client Request Executada",
		slog.String("method", req.Method),
		slog.String("url", req.URL.String()),
		slog.Int("status", status),
		slog.Duration("duration_ms", duration),
		slog.Any("error", err),
	)

	return resp, err
}

6. Cancelamento Cooperativo com context.Context

Nunca utilize http.NewRequest legado. Sempre utilize http.NewRequestWithContext. Se a requisição HTTP original do cliente for cancelada (usuário fechou o navegador ou o gateway disparou timeout), a chamada externa feita pelo seu microsserviço será imediatamente cancelada no socket:

ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.pagamentos.com/v1/status", nil)
if err != nil {
    return err
}

resp, err := client.Do(req)
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        // Trata timeout específico da operação
        return fmt.Errorf("timeout ao consultar gateway de pagamentos: %w", err)
    }
    return err
}
defer DrainAndClose(resp)

7. Checklist do Desenvolvedor Sênior

  • Nunca usar http.DefaultClient, http.Get ou http.Post em código de produção.
  • Definir sempre MaxIdleConnsPerHost compatível com a concorrência esperada (20 a 100).
  • Sempre instanciar o http.Client uma única vez e reutilizá-lo em toda a aplicação.
  • Utilizar http.NewRequestWithContext propagando o contexto da requisição pai.
  • Implementar função helper para drenar io.Copy(io.Discard, ...) antes de fechar resp.Body.
  • Centralizar autenticação e métricas de saída em implementações de http.RoundTripper.
  • Implementar retries apenas para métodos idempotentes (GET, PUT, DELETE), evitando duplicar transações em POST.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?