Richieste HTTP in Go

Go offre nel package standard net/http tutto il necessario per effettuare richieste HTTP sia semplici che avanzate. A differenza di molti altri linguaggi, non occorre installare librerie esterne per gestire GET, POST, autenticazione, timeout e molto altro. In questo articolo analizziamo in dettaglio come costruire client HTTP in Go, partendo dai casi base fino ad arrivare a pattern più sofisticati come middleware, pooling delle connessioni e gestione degli errori.

Il package net/http

Il package net/http è parte della libreria standard di Go e fornisce implementazioni complete sia del lato client che del lato server del protocollo HTTP. Per effettuare richieste è sufficiente importarlo senza dipendenze esterne.

I tipi principali con cui si lavora lato client sono:

  • http.Client: il client HTTP configurabile.
  • http.Request: la rappresentazione di una richiesta HTTP.
  • http.Response: la rappresentazione di una risposta HTTP.
  • http.Transport: il meccanismo di basso livello che gestisce le connessioni.

Richiesta GET semplice

La forma più immediata per effettuare una richiesta GET è usare la funzione http.Get(), che utilizza il client predefinito del package.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
)

func main() {
    // Eseguiamo una richiesta GET verso un endpoint pubblico
    response, err := http.Get("https://jsonplaceholder.typicode.com/posts/1")
    if err != nil {
        log.Fatalf("errore nella richiesta: %v", err)
    }
    // Il body deve essere sempre chiuso per liberare la connessione
    defer response.Body.Close()

    // Leggiamo il corpo della risposta
    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("errore nella lettura del body: %v", err)
    }

    fmt.Printf("Status: %s\n", response.Status)
    fmt.Printf("Body: %s\n", body)
}

L'uso di defer response.Body.Close() è fondamentale: se il body non viene chiuso, le connessioni TCP sottostanti non vengono restituite al pool e si esauriscono rapidamente.

Richiesta POST con corpo JSON

Per inviare dati in formato JSON occorre costruire la richiesta manualmente usando http.NewRequest(), impostare l'header Content-Type e fornire un reader per il corpo.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
)

type Post struct {
    Title  string `json:"title"`
    Body   string `json:"body"`
    UserID int    `json:"userId"`
}

func main() {
    // Costruiamo il payload da inviare
    payload := Post{
        Title:  "Articolo di test",
        Body:   "Contenuto dell'articolo",
        UserID: 1,
    }

    // Serializziamo la struct in JSON
    data, err := json.Marshal(payload)
    if err != nil {
        log.Fatalf("errore nella serializzazione JSON: %v", err)
    }

    // Creiamo la richiesta POST con il body JSON
    request, err := http.NewRequest(
        http.MethodPost,
        "https://jsonplaceholder.typicode.com/posts",
        bytes.NewBuffer(data),
    )
    if err != nil {
        log.Fatalf("errore nella creazione della richiesta: %v", err)
    }

    // Impostiamo gli header necessari
    request.Header.Set("Content-Type", "application/json")
    request.Header.Set("Accept", "application/json")

    // Eseguiamo la richiesta con il client predefinito
    client := &http.Client{}
    response, err := client.Do(request)
    if err != nil {
        log.Fatalf("errore nell'esecuzione della richiesta: %v", err)
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("errore nella lettura del body: %v", err)
    }

    fmt.Printf("Status: %d\n", response.StatusCode)
    fmt.Printf("Risposta: %s\n", body)
}

Configurare un client HTTP personalizzato

Il client predefinito http.DefaultClient non ha timeout configurato, il che lo rende inadatto alla produzione. È sempre consigliabile creare un http.Client esplicito con timeout e transport personalizzati.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "time"
)

func buildClient() *http.Client {
    // Configuriamo il transport con parametri ottimizzati per la produzione
    transport := &http.Transport{
        MaxIdleConns:        100,              // Numero massimo di connessioni inattive nel pool
        MaxIdleConnsPerHost: 10,               // Connessioni inattive per singolo host
        IdleConnTimeout:     90 * time.Second, // Timeout per le connessioni inattive
        DisableCompression:  false,            // Lasciamo la compressione abilitata
    }

    // Costruiamo il client con timeout globale sulla richiesta
    client := &http.Client{
        Transport: transport,
        Timeout:   10 * time.Second, // Timeout totale della richiesta inclusa la lettura del body
    }

    return client
}

func main() {
    client := buildClient()

    response, err := client.Get("https://jsonplaceholder.typicode.com/users")
    if err != nil {
        log.Fatalf("errore nella richiesta: %v", err)
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("errore nella lettura del body: %v", err)
    }

    fmt.Printf("Ricevuti %d byte\n", len(body))
}

Il campo Timeout di http.Client copre l'intera operazione: connessione, invio della richiesta, ricezione degli header e lettura del body. Il Transport offre invece un controllo più granulare sui singoli passi della connessione TCP/TLS.

Timeout granulari con http.Transport

Per scenari avanzati dove occorre differenziare il timeout di connessione da quello di lettura, si usa net.Dialer insieme a http.Transport.

package main

import (
    "context"
    "fmt"
    "io"
    "log"
    "net"
    "net/http"
    "time"
)

func buildAdvancedClient() *http.Client {
    dialer := &net.Dialer{
        Timeout:   5 * time.Second,  // Timeout per la fase di connessione TCP
        KeepAlive: 30 * time.Second, // Intervallo dei pacchetti keep-alive
    }

    transport := &http.Transport{
        DialContext:           dialer.DialContext,
        TLSHandshakeTimeout:   5 * time.Second,  // Timeout per la handshake TLS
        ResponseHeaderTimeout: 10 * time.Second, // Tempo massimo per ricevere gli header
        ExpectContinueTimeout: 1 * time.Second,  // Timeout per il meccanismo Expect: 100-continue
        MaxIdleConns:          50,
        MaxIdleConnsPerHost:   5,
        IdleConnTimeout:       60 * time.Second,
    }

    return &http.Client{
        Transport: transport,
    }
}

func main() {
    client := buildAdvancedClient()

    // Usiamo un context per un timeout ulteriore a livello di chiamata
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    request, err := http.NewRequestWithContext(
        ctx,
        http.MethodGet,
        "https://jsonplaceholder.typicode.com/comments?postId=1",
        nil,
    )
    if err != nil {
        log.Fatalf("errore nella creazione della richiesta: %v", err)
    }

    response, err := client.Do(request)
    if err != nil {
        log.Fatalf("errore nella richiesta: %v", err)
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("errore nella lettura del body: %v", err)
    }

    fmt.Printf("Ricevuti %d byte con status %s\n", len(body), response.Status)
}

Gestione degli errori e dei codici di stato

Una richiesta HTTP completata senza errori di rete non significa necessariamente che sia andata a buon fine: occorre sempre verificare il codice di stato HTTP. In Go, err è nil anche se il server restituisce un 404 o un 500.

package main

import (
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
)

// HTTPError rappresenta un errore HTTP con il relativo codice di stato
type HTTPError struct {
    StatusCode int
    Status     string
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("errore HTTP %d: %s", e.StatusCode, e.Status)
}

// fetchURL esegue una GET e restituisce il body o un errore tipizzato
func fetchURL(client *http.Client, url string) ([]byte, error) {
    response, err := client.Get(url)
    if err != nil {
        // Errore di rete, DNS, timeout ecc.
        return nil, fmt.Errorf("errore di rete: %w", err)
    }
    defer response.Body.Close()

    // Verifichiamo il codice di stato HTTP
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        return nil, &HTTPError{
            StatusCode: response.StatusCode,
            Status:     response.Status,
        }
    }

    body, err := io.ReadAll(response.Body)
    if err != nil {
        return nil, fmt.Errorf("errore nella lettura del body: %w", err)
    }

    return body, nil
}

func main() {
    client := &http.Client{}

    // Proviamo un URL inesistente per vedere la gestione dell'errore
    body, err := fetchURL(client, "https://jsonplaceholder.typicode.com/posts/9999")
    if err != nil {
        var httpErr *HTTPError
        // Distinguiamo tra errore HTTP ed errore di rete usando errors.As
        if errors.As(err, &httpErr) {
            fmt.Printf("Il server ha risposto con errore: %v\n", httpErr)
        } else {
            log.Fatalf("errore imprevisto: %v", err)
        }
        return
    }

    fmt.Printf("Risposta: %s\n", body)
}

Deserializzare la risposta JSON

In Go è buona pratica decodificare il JSON direttamente dal reader del body usando json.NewDecoder, senza leggere prima tutti i byte in memoria. Questo approccio è più efficiente con risposte di grandi dimensioni.

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
)

type User struct {
    ID       int    `json:"id"`
    Name     string `json:"name"`
    Username string `json:"username"`
    Email    string `json:"email"`
}

func fetchUsers(client *http.Client) ([]User, error) {
    response, err := client.Get("https://jsonplaceholder.typicode.com/users")
    if err != nil {
        return nil, fmt.Errorf("errore nella richiesta: %w", err)
    }
    defer response.Body.Close()

    if response.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("status inatteso: %s", response.Status)
    }

    var users []User
    // Decodifichiamo il JSON direttamente dal body senza bufferizzarlo completamente
    decoder := json.NewDecoder(response.Body)
    if err := decoder.Decode(&users); err != nil {
        return nil, fmt.Errorf("errore nella decodifica JSON: %w", err)
    }

    return users, nil
}

func main() {
    client := &http.Client{}

    users, err := fetchUsers(client)
    if err != nil {
        log.Fatalf("impossibile recuperare gli utenti: %v", err)
    }

    for _, user := range users {
        fmt.Printf("ID: %d | Nome: %s | Email: %s\n", user.ID, user.Name, user.Email)
    }
}

Autenticazione: Bearer Token e Basic Auth

Le API moderne richiedono quasi sempre un qualche meccanismo di autenticazione. I due schemi più diffusi sono Basic Auth e Bearer Token, entrambi trasportati nell'header Authorization.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
)

// withBearerToken aggiunge il token Bearer alla richiesta
func withBearerToken(request *http.Request, token string) *http.Request {
    request.Header.Set("Authorization", "Bearer "+token)
    return request
}

// withBasicAuth aggiunge le credenziali Basic Auth alla richiesta
func withBasicAuth(request *http.Request, username, password string) *http.Request {
    // Il metodo SetBasicAuth gestisce internamente la codifica Base64
    request.SetBasicAuth(username, password)
    return request
}

func main() {
    client := &http.Client{}

    // Esempio con Bearer Token
    bearerRequest, err := http.NewRequest(
        http.MethodGet,
        "https://jsonplaceholder.typicode.com/posts/1",
        nil,
    )
    if err != nil {
        log.Fatalf("errore nella creazione della richiesta: %v", err)
    }

    // Aggiungiamo il token Bearer (in produzione viene letto da configurazione)
    withBearerToken(bearerRequest, "il-mio-token-segreto")

    response, err := client.Do(bearerRequest)
    if err != nil {
        log.Fatalf("errore nella richiesta: %v", err)
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("errore nella lettura: %v", err)
    }

    fmt.Printf("Status: %s\nBody: %s\n", response.Status, body)
}

Query string e parametri URL

Costruire URL con parametri di query in modo sicuro richiede l'uso di url.Values per evitare problemi di encoding. Non è mai consigliabile concatenare le stringhe manualmente.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
)

func buildURLWithParams(baseURL string, params map[string]string) (string, error) {
    parsedURL, err := url.Parse(baseURL)
    if err != nil {
        return "", fmt.Errorf("URL non valido: %w", err)
    }

    // url.Values gestisce automaticamente l'encoding dei caratteri speciali
    queryParams := url.Values{}
    for key, value := range params {
        queryParams.Set(key, value)
    }

    parsedURL.RawQuery = queryParams.Encode()
    return parsedURL.String(), nil
}

func main() {
    client := &http.Client{}

    // Costruiamo l'URL con i parametri in modo sicuro
    targetURL, err := buildURLWithParams(
        "https://jsonplaceholder.typicode.com/posts",
        map[string]string{
            "userId": "1",
            "_limit": "5",
        },
    )
    if err != nil {
        log.Fatalf("errore nella costruzione dell'URL: %v", err)
    }

    fmt.Printf("URL costruito: %s\n", targetURL)

    response, err := client.Get(targetURL)
    if err != nil {
        log.Fatalf("errore nella richiesta: %v", err)
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("errore nella lettura: %v", err)
    }

    fmt.Printf("Risposta (%d byte): %s\n", len(body), body)
}

Middleware e RoundTripper

L'interfaccia http.RoundTripper è il meccanismo ufficiale di Go per intercettare e modificare le richieste HTTP a livello di transport. Implementandola si possono costruire middleware riutilizzabili per logging, retry, autenticazione automatica e molto altro.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "time"
)

// LoggingTransport è un middleware che logga ogni richiesta e la sua durata
type LoggingTransport struct {
    base http.RoundTripper
}

// RoundTrip implementa http.RoundTripper e intercetta ogni richiesta
func (t *LoggingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
    start := time.Now()

    log.Printf("[HTTP] %s %s", request.Method, request.URL)

    // Eseguiamo la richiesta reale usando il transport sottostante
    response, err := t.base.RoundTrip(request)
    if err != nil {
        log.Printf("[HTTP] errore dopo %v: %v", time.Since(start), err)
        return nil, err
    }

    log.Printf("[HTTP] %s %s -> %d (%v)", request.Method, request.URL, response.StatusCode, time.Since(start))
    return response, nil
}

// AuthTransport aggiunge automaticamente il Bearer Token a ogni richiesta
type AuthTransport struct {
    base  http.RoundTripper
    token string
}

func (t *AuthTransport) RoundTrip(request *http.Request) (*http.Response, error) {
    // Cloniamo la richiesta per evitare di modificare quella originale
    clonedRequest := request.Clone(request.Context())
    clonedRequest.Header.Set("Authorization", "Bearer "+t.token)
    return t.base.RoundTrip(clonedRequest)
}

func buildClientWithMiddleware(token string) *http.Client {
    // Componiamo i middleware in catena: AuthTransport -> LoggingTransport -> DefaultTransport
    transport := &AuthTransport{
        base: &LoggingTransport{
            base: http.DefaultTransport,
        },
        token: token,
    }

    return &http.Client{
        Transport: transport,
        Timeout:   10 * time.Second,
    }
}

func main() {
    client := buildClientWithMiddleware("token-di-esempio")

    response, err := client.Get("https://jsonplaceholder.typicode.com/todos/1")
    if err != nil {
        log.Fatalf("errore nella richiesta: %v", err)
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("errore nella lettura: %v", err)
    }

    fmt.Printf("Risposta: %s\n", body)
}

Retry automatico con backoff esponenziale

Un pattern fondamentale nei client HTTP resilienti è il retry con backoff esponenziale: in caso di errori temporanei o risposte 5xx, la richiesta viene ripetuta con un'attesa crescente tra un tentativo e l'altro.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "time"
)

// RetryConfig contiene i parametri per la politica di retry
type RetryConfig struct {
    MaxAttempts int
    BaseDelay   time.Duration
    MaxDelay    time.Duration
}

// shouldRetry determina se la risposta giustifica un nuovo tentativo
func shouldRetry(statusCode int, err error) bool {
    if err != nil {
        // Errori di rete sono sempre candidati al retry
        return true
    }
    // Ripetiamo solo per errori lato server (5xx) e per 429 Too Many Requests
    return statusCode == http.StatusTooManyRequests ||
        statusCode == http.StatusInternalServerError ||
        statusCode == http.StatusBadGateway ||
        statusCode == http.StatusServiceUnavailable ||
        statusCode == http.StatusGatewayTimeout
}

// doWithRetry esegue la richiesta con logica di retry e backoff esponenziale
func doWithRetry(client *http.Client, request *http.Request, config RetryConfig) (*http.Response, error) {
    var lastErr error
    var response *http.Response

    for attempt := 0; attempt < config.MaxAttempts; attempt++ {
        if attempt > 0 {
            // Calcoliamo il delay con backoff esponenziale: BaseDelay * 2^(attempt-1)
            delay := config.BaseDelay * time.Duration(1< config.MaxDelay {
                delay = config.MaxDelay
            }
            log.Printf("tentativo %d/%d tra %v...", attempt+1, config.MaxAttempts, delay)
            time.Sleep(delay)

            // Dobbiamo ricreare il body perché è già stato consumato al tentativo precedente
            if request.GetBody != nil {
                newBody, err := request.GetBody()
                if err != nil {
                    return nil, fmt.Errorf("impossibile rileggere il body: %w", err)
                }
                request.Body = newBody
            }
        }

        response, lastErr = client.Do(request)
        if lastErr != nil {
            if shouldRetry(0, lastErr) {
                continue
            }
            return nil, lastErr
        }

        if !shouldRetry(response.StatusCode, nil) {
            // La risposta è accettabile, usciamo dal loop
            return response, nil
        }

        // Chiudiamo il body della risposta non valida prima del prossimo tentativo
        response.Body.Close()
    }

    if lastErr != nil {
        return nil, fmt.Errorf("tutti i tentativi falliti: %w", lastErr)
    }

    return response, nil
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}

    request, err := http.NewRequest(
        http.MethodGet,
        "https://jsonplaceholder.typicode.com/posts/1",
        nil,
    )
    if err != nil {
        log.Fatalf("errore nella creazione della richiesta: %v", err)
    }

    config := RetryConfig{
        MaxAttempts: 3,
        BaseDelay:   500 * time.Millisecond,
        MaxDelay:    5 * time.Second,
    }

    response, err := doWithRetry(client, request, config)
    if err != nil {
        log.Fatalf("richiesta fallita: %v", err)
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Fatalf("errore nella lettura: %v", err)
    }

    fmt.Printf("Status: %s\nBody: %s\n", response.Status, body)
}

Richieste concorrenti con goroutine

Uno dei punti di forza di Go è la concorrenza nativa. Quando occorre effettuare molte richieste HTTP indipendenti, le goroutine permettono di parallelizzarle in modo semplice ed efficiente. L'http.Client è sicuro per l'uso concorrente.

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "sync"
    "time"
)

type Post struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
}

// FetchResult contiene il risultato o l'errore di una singola fetch
type FetchResult struct {
    PostID int
    Post   *Post
    Err    error
}

// fetchPost recupera un singolo post e invia il risultato sul canale
func fetchPost(client *http.Client, postID int, results chan<- FetchResult, wg *sync.WaitGroup) {
    defer wg.Done()

    url := fmt.Sprintf("https://jsonplaceholder.typicode.com/posts/%d", postID)
    response, err := client.Get(url)
    if err != nil {
        results <- FetchResult{PostID: postID, Err: err}
        return
    }
    defer response.Body.Close()

    if response.StatusCode != http.StatusOK {
        results <- FetchResult{
            PostID: postID,
            Err:    fmt.Errorf("status inatteso: %s", response.Status),
        }
        return
    }

    var post Post
    if err := json.NewDecoder(response.Body).Decode(&post); err != nil {
        results <- FetchResult{PostID: postID, Err: err}
        return
    }

    results <- FetchResult{PostID: postID, Post: &post}
}

func main() {
    // Un client condiviso tra tutte le goroutine garantisce il pool di connessioni
    client := &http.Client{Timeout: 10 * time.Second}

    postIDs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

    // Creiamo un canale per raccogliere i risultati
    results := make(chan FetchResult, len(postIDs))
    var wg sync.WaitGroup

    for _, id := range postIDs {
        wg.Add(1)
        go fetchPost(client, id, results, &wg)
    }

    // Chiudiamo il canale quando tutte le goroutine hanno terminato
    go func() {
        wg.Wait()
        close(results)
    }()

    // Raccogliamo i risultati dal canale
    for result := range results {
        if result.Err != nil {
            log.Printf("errore per post %d: %v", result.PostID, result.Err)
            continue
        }
        fmt.Printf("Post %d: %s\n", result.Post.ID, result.Post.Title)
    }
}

Limitare la concorrenza con un semaforo

Lanciare centinaia di goroutine simultanee può sovraccaricare il server remoto o esaurire le connessioni disponibili. Il pattern del semaforo tramite canale bufferizzato permette di limitare il numero massimo di richieste concorrenti.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "sync"
    "time"
)

// Semaphore è un semaforo implementato come canale bufferizzato
type Semaphore chan struct{}

func NewSemaphore(maxConcurrent int) Semaphore {
    return make(Semaphore, maxConcurrent)
}

// Acquire occupa uno slot del semaforo (blocca se il canale è pieno)
func (s Semaphore) Acquire() {
    s <- struct{}{}
}

// Release libera uno slot del semaforo
func (s Semaphore) Release() {
    <-s
}

func fetchWithSemaphore(
    client *http.Client,
    url string,
    sem Semaphore,
    wg *sync.WaitGroup,
) {
    defer wg.Done()

    // Acquistiamo il semaforo prima di procedere con la richiesta
    sem.Acquire()
    defer sem.Release()

    response, err := client.Get(url)
    if err != nil {
        log.Printf("errore per %s: %v", url, err)
        return
    }
    defer response.Body.Close()

    body, err := io.ReadAll(response.Body)
    if err != nil {
        log.Printf("errore nella lettura per %s: %v", url, err)
        return
    }

    fmt.Printf("URL: %s | Byte ricevuti: %d\n", url, len(body))
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}

    // Permettiamo al massimo 3 richieste simultanee
    sem := NewSemaphore(3)

    urls := []string{
        "https://jsonplaceholder.typicode.com/posts/1",
        "https://jsonplaceholder.typicode.com/posts/2",
        "https://jsonplaceholder.typicode.com/posts/3",
        "https://jsonplaceholder.typicode.com/posts/4",
        "https://jsonplaceholder.typicode.com/posts/5",
    }

    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go fetchWithSemaphore(client, url, sem, &wg)
    }

    wg.Wait()
    fmt.Println("Tutte le richieste completate.")
}

Caricare un file con richiesta multipart

Per caricare file su un server tramite HTTP occorre costruire un body multipart. Il package mime/multipart e il tipo bytes.Buffer sono gli strumenti standard per questo scopo.

package main

import (
    "bytes"
    "fmt"
    "io"
    "log"
    "mime/multipart"
    "net/http"
    "os"
    "path/filepath"
)

// uploadFile carica un file verso un endpoint multipart
func uploadFile(client *http.Client, targetURL, filePath string) error {
    // Apriamo il file da caricare
    file, err := os.Open(filePath)
    if err != nil {
        return fmt.Errorf("impossibile aprire il file: %w", err)
    }
    defer file.Close()

    // Creiamo un buffer e un writer multipart
    var buffer bytes.Buffer
    writer := multipart.NewWriter(&buffer)

    // Creiamo il campo file nel form multipart
    part, err := writer.CreateFormFile("file", filepath.Base(filePath))
    if err != nil {
        return fmt.Errorf("errore nella creazione del campo form: %w", err)
    }

    // Copiamo il contenuto del file nel campo multipart
    if _, err := io.Copy(part, file); err != nil {
        return fmt.Errorf("errore nella copia del file: %w", err)
    }

    // Aggiungiamo eventuali campi testuali aggiuntivi
    if err := writer.WriteField("description", "File di esempio"); err != nil {
        return fmt.Errorf("errore nella scrittura del campo: %w", err)
    }

    // Chiudiamo il writer per finalizzare il body multipart
    writer.Close()

    request, err := http.NewRequest(http.MethodPost, targetURL, &buffer)
    if err != nil {
        return fmt.Errorf("errore nella creazione della richiesta: %w", err)
    }

    // L'header Content-Type deve includere il boundary generato dal writer
    request.Header.Set("Content-Type", writer.FormDataContentType())

    response, err := client.Do(request)
    if err != nil {
        return fmt.Errorf("errore nella richiesta: %w", err)
    }
    defer response.Body.Close()

    fmt.Printf("Upload completato con status: %s\n", response.Status)
    return nil
}

func main() {
    client := &http.Client{}

    // Creiamo un file temporaneo per la dimostrazione
    tmpFile, err := os.CreateTemp("", "upload-*.txt")
    if err != nil {
        log.Fatalf("impossibile creare il file temporaneo: %v", err)
    }
    defer os.Remove(tmpFile.Name())

    tmpFile.WriteString("Contenuto di esempio per il caricamento.")
    tmpFile.Close()

    // In un caso reale useremmo un endpoint che accetta upload
    err = uploadFile(client, "https://httpbin.org/post", tmpFile.Name())
    if err != nil {
        log.Fatalf("upload fallito: %v", err)
    }
}

Leggere gli header della risposta

Gli header HTTP della risposta contengono informazioni preziose: rate limit, tipo di contenuto, caching, tracciamento. In Go si accede tramite la mappa response.Header, con metodi dedicati per i casi comuni.

package main

import (
    "fmt"
    "log"
    "net/http"
)

func inspectResponseHeaders(response *http.Response) {
    // Leggiamo il Content-Type con il metodo Get (case-insensitive)
    contentType := response.Header.Get("Content-Type")
    fmt.Printf("Content-Type: %s\n", contentType)

    // Leggiamo tutti i valori per un header che può averne multipli
    setCookies := response.Header["Set-Cookie"]
    for _, cookie := range setCookies {
        fmt.Printf("Set-Cookie: %s\n", cookie)
    }

    // Stampiamo tutti gli header ricevuti per scopi di debug
    fmt.Println("\nTutti gli header:")
    for name, values := range response.Header {
        for _, value := range values {
            fmt.Printf("  %s: %s\n", name, value)
        }
    }
}

func main() {
    client := &http.Client{}

    response, err := client.Get("https://jsonplaceholder.typicode.com/posts/1")
    if err != nil {
        log.Fatalf("errore nella richiesta: %v", err)
    }
    defer response.Body.Close()

    fmt.Printf("Status: %s\n", response.Status)
    fmt.Printf("Protocollo: %s\n", response.Proto)
    inspectResponseHeaders(response)
}

Usare un context per la cancellazione

Il context.Context è il meccanismo idiomatico in Go per propagare la cancellazione attraverso la call stack. Usato con http.NewRequestWithContext permette di annullare una richiesta HTTP in corso quando, ad esempio, l'utente chiude la connessione o un timeout superiore scade.

package main

import (
    "context"
    "fmt"
    "io"
    "log"
    "net/http"
    "time"
)

func fetchWithContext(ctx context.Context, client *http.Client, url string) ([]byte, error) {
    // Leghiamo il context alla richiesta: se il context viene cancellato,
    // la richiesta HTTP viene interrotta immediatamente
    request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("errore nella creazione della richiesta: %w", err)
    }

    response, err := client.Do(request)
    if err != nil {
        return nil, fmt.Errorf("errore nella richiesta: %w", err)
    }
    defer response.Body.Close()

    return io.ReadAll(response.Body)
}

func main() {
    client := &http.Client{}

    // Creiamo un context con timeout di 2 secondi
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel() // Assicuriamoci di rilasciare le risorse del context

    body, err := fetchWithContext(ctx, client, "https://jsonplaceholder.typicode.com/posts")
    if err != nil {
        // Distinguiamo tra timeout del context ed errori di rete
        if ctx.Err() != nil {
            log.Fatalf("richiesta annullata o scaduta: %v", ctx.Err())
        }
        log.Fatalf("errore di rete: %v", err)
    }

    fmt.Printf("Ricevuti %d byte\n", len(body))
}

Considerazioni finali

Il package net/http di Go offre una base solida e performante per costruire qualsiasi tipo di client HTTP. I principi fondamentali da tenere sempre presenti sono: chiudere sempre il body della risposta con defer, non usare mai http.DefaultClient in produzione senza configurare un timeout, condividere un singolo http.Client tra le goroutine per beneficiare del connection pooling, e usare context.Context per propagare la cancellazione in modo corretto.

Per scenari più complessi come OAuth 2.0, circuit breaker o metriche Prometheus, esistono librerie dedicate che si integrano perfettamente con il pattern http.RoundTripper illustrato in questo articolo, mantenendo piena compatibilità con la libreria standard.