Consumindo APIs externas com http.Client

Consumindo APIs externas com http.Client

Consumindo APIs em Go: http.Client com timeout, o Transport que perde o proxy quando escrito do zero, NewRequestWithContext, corpo lido até o fim para reaproveitar conexões, o ViaCEP que devolve erro como texto, retry com jitter e idempotência e testes que apontam o código real para o httptest.
Golang

• • 17 min de leitura

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

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.

Comentários

Mais em Golang

O ecossistema Go: comunidade, carreira e próximos passos
O ecossistema Go: comunidade, carreira e próximos passos

O ecossistema Go hoje: do Go 1.18 ao 1.27, com iteradores, omitzero…

Select, timeouts e cancelamento de operações
Select, timeouts e cancelamento de operações

Select, timeouts e cancelamento em Go: select como event loop, time.After e os…

Pacotes: visibilidade, nomenclatura e organização de projeto
Pacotes: visibilidade, nomenclatura e organização de projeto

Pacotes em Go: exportado e não exportado pela primeira letra, nomes curtos e…