Go oferece um cliente HTTP completo na biblioteca padrão. A tentação é usar http.Get diretamente — uma função de conveniência que funciona, mas esconde um problema sério: ela usa o cliente padrão global, que não tem timeout configurado. Em produção, uma API externa que nunca responde pode travar uma goroutine indefinidamente, acumulando conexões abertas até degradar o serviço inteiro.
A regra é simples: nunca use http.Get, http.Post ou http.DefaultClient em código de produção. Sempre crie um http.Client com timeout explícito.
Criando um cliente HTTP correto
package main
import (
"net/http"
"time"
)
func novoClienteHTTP() *http.Client {
return &http.Client{
Timeout: 30 * time.Second, // timeout total da requisição
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
DisableCompression: false,
},
}
}
O Timeout cobre toda a requisição — desde o dial TCP até a leitura completa do corpo. O Transport controla o pool de conexões, que é reutilizado entre requisições para evitar o custo de criar novas conexões TCP a cada chamada. Um cuidado: um &http.Transport{...} escrito do zero começa com todos os campos zerados, e perde padrões importantes do http.DefaultTransport — entre eles o Proxy lido das variáveis HTTPS_PROXY e NO_PROXY, os timeouts de conexão e o ForceAttemptHTTP2. A forma segura é partir de uma cópia: t := http.DefaultTransport.(*http.Transport).Clone() e então ajustar t.MaxIdleConnsPerHost e o que mais for preciso.
GET simples com tratamento completo
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type ViaCEP struct {
CEP string `json:"cep"`
Logradouro string `json:"logradouro"`
Bairro string `json:"bairro"`
Localidade string `json:"localidade"`
UF string `json:"uf"`
Erro bool `json:"erro,string"` // o ViaCEP envia "erro": "true", como texto
}
var cliente = &http.Client{Timeout: 10 * time.Second}
// baseViaCEP é variável para que os testes possam apontar para um servidor falso
var baseViaCEP = "https://viacep.com.br"
func buscarCEP(cep string) (*ViaCEP, error) {
url := fmt.Sprintf("%s/ws/%s/json/", baseViaCEP, cep)
resp, err := cliente.Get(url)
if err != nil {
return nil, fmt.Errorf("buscarCEP: requisição falhou: %w", err)
}
defer resp.Body.Close()
// Sempre verificar o status HTTP
if resp.StatusCode != http.StatusOK {
corpo, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("buscarCEP: status %d: %s",
resp.StatusCode, string(corpo))
}
var endereco ViaCEP
if err := json.NewDecoder(resp.Body).Decode(&endereco); err != nil {
return nil, fmt.Errorf("buscarCEP: decodificar resposta: %w", err)
}
if endereco.Erro {
return nil, fmt.Errorf("buscarCEP: CEP %s não encontrado", cep)
}
return &endereco, nil
}
func main() {
endereco, err := buscarCEP("01310100")
if err != nil {
fmt.Println("Erro:", err)
return
}
fmt.Printf("%s, %s — %s/%s\n",
endereco.Logradouro,
endereco.Bairro,
endereco.Localidade,
endereco.UF,
)
}
Construindo requisições com http.NewRequest
Para controle total sobre a requisição — headers, body, context — use http.NewRequest:
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type NovoPedido struct {
ProdutoID int `json:"produto_id"`
Quantidade int `json:"quantidade"`
Observacao string `json:"observacao,omitempty"`
}
type RespostaPedido struct {
ID int `json:"id"`
Status string `json:"status"`
}
func criarPedido(ctx context.Context, baseURL, token string, pedido NovoPedido) (*RespostaPedido, error) {
// Serializa o corpo
corpo, err := json.Marshal(pedido)
if err != nil {
return nil, fmt.Errorf("serializar pedido: %w", err)
}
// Cria a requisição com context para cancelamento
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
baseURL+"/pedidos",
bytes.NewReader(corpo),
)
if err != nil {
return nil, fmt.Errorf("criar requisição: %w", err)
}
// Headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Request-ID", "req-abc-123")
cliente := &http.Client{Timeout: 15 * time.Second}
resp, err := cliente.Do(req)
if err != nil {
return nil, fmt.Errorf("executar requisição: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("status inesperado: %d", resp.StatusCode)
}
var resposta RespostaPedido
if err := json.NewDecoder(resp.Body).Decode(&resposta); err != nil {
return nil, fmt.Errorf("decodificar resposta: %w", err)
}
return &resposta, nil
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 20*time.Second)
defer cancelar()
pedido := NovoPedido{ProdutoID: 42, Quantidade: 3}
resp, err := criarPedido(ctx, "https://api.exemplo.com", "meu-token", pedido)
if err != nil {
fmt.Println("Erro:", err)
return
}
fmt.Printf("Pedido criado: ID=%d Status=%s\n", resp.ID, resp.Status)
}
Cliente HTTP reutilizável com configuração
Para projetos reais, encapsule o cliente em uma struct com configurações e métodos auxiliares:
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type ClienteAPI struct {
baseURL string
token string
httpClient *http.Client
}
func NovoClienteAPI(baseURL, token string) *ClienteAPI {
return &ClienteAPI{
baseURL: baseURL,
token: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
func (c *ClienteAPI) fazer(ctx context.Context, metodo, caminho string, corpo any) (*http.Response, error) {
var bodyReader io.Reader
if corpo != nil {
dados, err := json.Marshal(corpo)
if err != nil {
return nil, fmt.Errorf("serializar corpo: %w", err)
}
bodyReader = bytes.NewReader(dados)
}
req, err := http.NewRequestWithContext(ctx, metodo, c.baseURL+caminho, bodyReader)
if err != nil {
return nil, fmt.Errorf("criar requisição: %w", err)
}
if corpo != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executar %s %s: %w", metodo, caminho, err)
}
return resp, nil
}
func (c *ClienteAPI) decodificar(resp *http.Response, destino any) error {
defer resp.Body.Close()
if resp.StatusCode >= 400 {
corpo, _ := io.ReadAll(resp.Body)
return fmt.Errorf("erro HTTP %d: %s", resp.StatusCode, string(corpo))
}
if destino != nil && resp.StatusCode != http.StatusNoContent {
if err := json.NewDecoder(resp.Body).Decode(destino); err != nil {
return fmt.Errorf("decodificar resposta: %w", err)
}
}
return nil
}
// Métodos de alto nível
func (c *ClienteAPI) Get(ctx context.Context, caminho string, resultado any) error {
resp, err := c.fazer(ctx, http.MethodGet, caminho, nil)
if err != nil {
return err
}
return c.decodificar(resp, resultado)
}
func (c *ClienteAPI) Post(ctx context.Context, caminho string, corpo, resultado any) error {
resp, err := c.fazer(ctx, http.MethodPost, caminho, corpo)
if err != nil {
return err
}
return c.decodificar(resp, resultado)
}
func (c *ClienteAPI) Put(ctx context.Context, caminho string, corpo, resultado any) error {
resp, err := c.fazer(ctx, http.MethodPut, caminho, corpo)
if err != nil {
return err
}
return c.decodificar(resp, resultado)
}
func (c *ClienteAPI) Delete(ctx context.Context, caminho string) error {
resp, err := c.fazer(ctx, http.MethodDelete, caminho, nil)
if err != nil {
return err
}
return c.decodificar(resp, nil)
}
// Uso do cliente
type Usuario struct {
ID int `json:"id"`
Nome string `json:"nome"`
Email string `json:"email"`
}
func main() {
cliente := NovoClienteAPI("https://jsonplaceholder.typicode.com", "token-exemplo")
ctx := context.Background()
// GET com query parameters
params := url.Values{}
params.Set("_limit", "3")
var usuarios []Usuario
if err := cliente.Get(ctx, "/users?"+params.Encode(), &usuarios); err != nil {
fmt.Println("Erro:", err)
return
}
for _, u := range usuarios {
fmt.Printf("[%d] %s <%s>\n", u.ID, u.Nome, u.Email)
}
}
Requisições concorrentes com controle de rate
Para buscar múltiplos recursos em paralelo com limite de concorrência:
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
type Post struct {
ID int `json:"id"`
Titulo string `json:"title"`
Corpo string `json:"body"`
}
type ResultadoBusca struct {
ID int
Post *Post
Erro error
}
func buscarPostsConcorrente(ctx context.Context, ids []int, maxConcorrente int) []ResultadoBusca {
semaforo := make(chan struct{}, maxConcorrente)
resultados := make([]ResultadoBusca, len(ids))
cliente := &http.Client{Timeout: 10 * time.Second}
var wg sync.WaitGroup
for i, id := range ids {
wg.Add(1)
i, id := i, id
go func() {
defer wg.Done()
// Adquire slot no semáforo
select {
case semaforo <- struct{}{}:
defer func() { <-semaforo }()
case <-ctx.Done():
resultados[i] = ResultadoBusca{ID: id, Erro: ctx.Err()}
return
}
url := fmt.Sprintf("https://jsonplaceholder.typicode.com/posts/%d", id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
resultados[i] = ResultadoBusca{ID: id, Erro: err}
return
}
resp, err := cliente.Do(req)
if err != nil {
resultados[i] = ResultadoBusca{ID: id, Erro: err}
return
}
defer resp.Body.Close()
var post Post
if err := json.NewDecoder(resp.Body).Decode(&post); err != nil {
resultados[i] = ResultadoBusca{ID: id, Erro: err}
return
}
resultados[i] = ResultadoBusca{ID: id, Post: &post}
}()
}
wg.Wait()
return resultados
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 15*time.Second)
defer cancelar()
ids := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
inicio := time.Now()
resultados := buscarPostsConcorrente(ctx, ids, 3) // máximo 3 simultâneas
for _, r := range resultados {
if r.Erro != nil {
fmt.Printf("[%d] ERRO: %v\n", r.ID, r.Erro)
} else {
titulo := r.Post.Titulo
if len(titulo) > 40 {
titulo = titulo[:40] + "..."
}
fmt.Printf("[%d] %s\n", r.ID, titulo)
}
}
fmt.Printf("\n%d posts em %v\n", len(ids), time.Since(inicio))
}
Retry com backoff exponencial
APIs externas falham. Um cliente robusto deve tentar novamente com intervalos crescentes:
package main
import (
"context"
"fmt"
"math/rand/v2"
"net/http"
"time"
)
type ConfigRetry struct {
MaxTentativas int
EsperaInicial time.Duration
EsperaMaxima time.Duration
Multiplicador float64
}
var ConfigRetryPadrao = ConfigRetry{
MaxTentativas: 3,
EsperaInicial: 100 * time.Millisecond,
EsperaMaxima: 5 * time.Second,
Multiplicador: 2.0,
}
func comRetry(ctx context.Context, config ConfigRetry, fn func() (*http.Response, error)) (*http.Response, error) {
espera := config.EsperaInicial
for tentativa := 1; tentativa <= config.MaxTentativas; tentativa++ {
resp, err := fn()
// Sucesso
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
// Última tentativa — retorna o erro
if tentativa == config.MaxTentativas {
if err != nil {
return nil, fmt.Errorf("após %d tentativas: %w", tentativa, err)
}
return resp, nil
}
// Fecha o corpo antes de tentar novamente
if resp != nil {
resp.Body.Close()
}
// Calcula espera com jitter para evitar thundering herd
jitter := time.Duration(rand.Float64() * float64(espera) * 0.1)
esperaComJitter := espera + jitter
fmt.Printf("tentativa %d falhou — aguardando %v\n", tentativa, esperaComJitter)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(esperaComJitter):
}
// Backoff exponencial
espera = time.Duration(float64(espera) * config.Multiplicador)
if espera > config.EsperaMaxima {
espera = config.EsperaMaxima
}
}
return nil, fmt.Errorf("todas as tentativas esgotadas")
}
func main() {
ctx, cancelar := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelar()
cliente := &http.Client{Timeout: 5 * time.Second}
resp, err := comRetry(ctx, ConfigRetryPadrao, func() (*http.Response, error) {
return cliente.Get("https://httpbin.org/status/200")
})
if err != nil {
fmt.Println("Erro:", err)
return
}
defer resp.Body.Close()
fmt.Println("Sucesso! Status:", resp.Status)
}
Testando código que consome APIs externas
Para testar sem depender de APIs reais, use httptest.NewServer e faça o código apontar para ele — por isso a URL base do ViaCEP virou a variável baseViaCEP no exemplo acima. Numa aplicação maior, ela seria um campo do cliente, recebido no construtor:
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestBuscarCEP(t *testing.T) {
// Servidor falso que imita as duas respostas do ViaCEP
servidor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ws/01310100/json/" {
json.NewEncoder(w).Encode(map[string]string{
"cep": "01310-100", "logradouro": "Avenida Paulista",
"bairro": "Bela Vista", "localidade": "São Paulo", "uf": "SP",
})
return
}
w.Write([]byte(`{"erro": "true"}`)) // formato real para CEP inexistente
}))
defer servidor.Close()
// Aponta o código real para o servidor falso
baseViaCEP = servidor.URL
endereco, err := buscarCEP("01310100")
if err != nil {
t.Fatalf("erro inesperado: %v", err)
}
if endereco.Localidade != "São Paulo" {
t.Errorf("cidade esperada São Paulo, obtida %q", endereco.Localidade)
}
if _, err := buscarCEP("99999999"); err == nil {
t.Error("esperava erro para CEP inexistente")
}
}
Consumir uma API em Go é pouco código e muitos detalhes: um http.Client com timeout, um transporte copiado do padrão em vez de escrito do zero, o corpo da resposta lido até o fim para a conexão voltar ao pool, o status conferido antes de decodificar e o contexto repassado a cada requisição. Por cima disso vem o que depende da API do outro lado — o formato real das respostas de erro, que só uma chamada de verdade revela, e a decisão de quais operações podem ser repetidas com segurança. Testes com httptest.NewServer só valem quando o código real é apontado para ele.
Fontes e leituras recomendadas
-
Documentação do pacote net/http — Client — Referência completa do cliente HTTP. https://pkg.go.dev/net/http#Client
-
Documentação do pacote net/http — Transport — Configuração do pool de conexões. https://pkg.go.dev/net/http#Transport
-
Go by Example: HTTP Clients — Exemplos práticos de requisições HTTP. https://gobyexample.com/http-clients
-
API ViaCEP — API pública brasileira de CEPs usada nos exemplos. https://viacep.com.br
-
httpbin.org — Serviço para testar clientes HTTP com respostas configuráveis. https://httpbin.org
-
resty — Biblioteca popular para clientes HTTP com interface fluente. https://github.com/go-resty/resty
Exercícios
Exercício 1
A primeira versão da função buscarCEP deste artigo declarava o campo Erro bool `json:"erro"`. Com um CEP válido, tudo funcionava; com o CEP 99999999, que não existe, a função devolvia um erro de decodificação em vez de "CEP não encontrado". O que o ViaCEP responde nesse caso, e como o código deveria se proteger desse tipo de surpresa?
Ver resposta
✓ Resposta: O ViaCEP responde {"erro": "true"}, com o valor entre aspas, ou seja, como texto, e não como booleano. Consultado em setembro de 2026, foi exatamente isso que ele devolveu para 99999999, com status 200. Para um CEP com formato inválido, como "123", a resposta é 400. O encoding/json não converte texto em bool sozinho e falha com cannot unmarshal string into Go struct field ViaCEP.erro of type bool. O caminho de "não encontrado" nunca era alcançado. A correção aplicada foi a opção ,string na tag, `json:"erro,string"`, que aceita o booleano escrito como texto. A lição geral é que a documentação de uma API diz o que ela deveria responder, e só uma chamada real mostra o que ela responde de fato. Antes de escrever a struct, vale fazer as chamadas dos casos de erro com curl e guardar essas respostas como dados de teste, para que o servidor falso dos testes imite o formato real, e não o imaginado.
Exercício 2
Um serviço cria o cliente HTTP com &http.Transport{MaxIdleConnsPerHost: 10}, como no artigo. Em produção, atrás do proxy corporativo definido em HTTPS_PROXY, todas as chamadas externas falham por tempo esgotado, embora o curl na mesma máquina funcione. Explique a diferença.
Ver resposta
✓ Resposta: Um http.Transport criado como literal começa com todos os campos zerados, e o campo Proxy zerado significa "sem proxy". Quem lê HTTPS_PROXY e NO_PROXY é o http.ProxyFromEnvironment, que está configurado no http.DefaultTransport, e não num transporte novo. Num teste, o literal tinha Proxy nulo, e o DefaultTransport tinha o campo preenchido. O curl respeita as variáveis de ambiente; o serviço, com o transporte literal, tenta a conexão direta, que o firewall bloqueia, e as chamadas esperam até o timeout. A forma segura de personalizar é partir de uma cópia: t := http.DefaultTransport.(*http.Transport).Clone(), seguida de t.MaxIdleConnsPerHost = 10. No mesmo teste, o clone manteve o Proxy e o ForceAttemptHTTP2. Os timeouts de conexão e de keep-alive do transporte padrão também vêm junto, e se perdem no literal. É um defeito típico de só aparecer num ambiente específico: na máquina do desenvolvedor, sem proxy, o literal funciona perfeitamente.
Exercício 3
Um cliente de API verifica o status e, quando ele não é 200, retorna um erro chamando só resp.Body.Close(), sem ler o corpo. A função de retry do artigo faz o mesmo antes de cada nova tentativa. Sob carga, o serviço abre milhares de conexões TCP e esgota as portas locais. Por quê?
Ver resposta
✓ Resposta: O transporte só devolve uma conexão ao pool, para ser reaproveitada, quando o corpo da resposta foi lido até o fim e fechado. Se o corpo é fechado com dados ainda por ler, a conexão não tem como ser reaproveitada com segurança, e o transporte a descarta. A próxima requisição abre uma conexão nova, com handshake TCP e, em HTTPS, TLS. Num teste com cinco requisições a um servidor que responde 1 MB, fechar o corpo sem ler abriu 5 conexões novas; ler até o fim com io.Copy(io.Discard, resp.Body) antes de fechar abriu 1 e reaproveitou as outras quatro vezes. Sob carga, milhares de conexões abertas e fechadas deixam portas presas no estado TIME_WAIT, até que o sistema operacional não tenha mais portas locais para oferecer. A correção é esvaziar o corpo antes de fechá-lo nos caminhos de erro e antes de cada retry, de preferência com um limite, como io.CopyN(io.Discard, resp.Body, 64<<10), para não ler um corpo de erro gigante inteiro.
Exercício 4
A função comRetry do artigo considera sucesso qualquer resposta com status abaixo de 500. Na última tentativa, se a resposta ainda é um 503, ela devolve resp, nil. Aponte três problemas desse desenho para um cliente que cria pedidos com POST.
Ver resposta
✓ Resposta: O primeiro é o erro escondido: depois de três tentativas com 503, a função devolve a resposta sem erro, e o chamador que só confere err != nil segue adiante como se o pedido tivesse sido criado. Uma falha esgotada deveria virar erro explícito, com o último status na mensagem. O segundo é a idempotência: um POST que estourou o tempo ou recebeu 502 pode ter sido processado pelo servidor antes da falha, e repeti-lo pode criar o pedido duas vezes. Só se repete sem cuidado o que é idempotente, como GET, PUT e DELETE; para POST, a saída é uma chave de idempotência, um cabeçalho Idempotency-Key com um valor único por operação, que o servidor usa para reconhecer a repetição. O terceiro é o jitter fraco: somar só até 10% da espera deixa as instâncias quase sincronizadas, e o efeito manada visto no artigo sobre select continua. Sortear a espera inteira entre zero e o valor calculado espalha muito melhor. Vale ainda tratar o 429 como passível de repetição, respeitando o cabeçalho Retry-After, e passar o ctx para a requisição, para que o cancelamento interrompa também a tentativa em andamento.
Exercício 5
A versão original do teste deste artigo criava um httptest.NewServer e, em seguida, chamava servidor.Config.Handler.ServeHTTP diretamente, conferindo a resposta. O teste passava. Por que ele não testava nada do código de produção, e o que a versão atual faz de diferente?
Ver resposta
✓ Resposta: Porque ele chamava o handler do próprio servidor falso e conferia a resposta que esse handler tinha acabado de fabricar. A função buscarCEP nunca era executada: o teste verificava que o falso devolvia o que foi escrito para devolver, e continuaria passando mesmo se buscarCEP fosse apagada ou estivesse quebrada. As variáveis urlOriginal e ctx, declaradas e descartadas com _, eram o vestígio de uma intenção que não foi concluída: apontar o código real para o servidor falso. A versão atual faz exatamente isso. A URL base do ViaCEP virou a variável baseViaCEP, o teste a substitui por servidor.URL e chama buscarCEP, que faz uma requisição HTTP de verdade até o servidor local. Assim são exercitados a montagem da URL, o tratamento de status, a decodificação e, com a segunda chamada, o caminho do CEP inexistente no formato real da API. Numa aplicação maior, a URL seria um campo do cliente, recebido no construtor, o que permite rodar os testes em paralelo sem disputar uma variável global.