Timeout con context in Go per API: dagli handler HTTP al SQL
I timeout con context in Go consentono di propagare deadline dall'handler HTTP alle chiamate SQL, prevenire richieste bloccate e mantenere i servizi stabili sotto carico.

Perché le richieste si bloccano (e perché è dannoso sotto carico)
Una richiesta si “blocca” quando aspetta qualcosa che non ritorna: una query database lenta, una connessione del pool bloccata, un problema DNS o un servizio upstream che accetta la chiamata ma non risponde.
Il sintomo è semplice: alcune richieste impiegano un tempo infinito, e sempre più richieste si accumulano dietro di loro. Spesso vedrai la memoria salire, il numero di goroutine aumentare e una coda di connessioni aperte che non si svuota mai.
Sotto carico, le richieste bloccate fanno doppi danni. Occupano worker e trattengono risorse scarse come connessioni al database e lock. Questo rallenta anche le richieste normalmente veloci, creando più sovrapposizione e ancora più attese.
I retry e i picchi di traffico peggiorano la spirale. Un client scade e ritenta mentre la richiesta originale è ancora in esecuzione, quindi ora paghi per due richieste. Moltiplica questo per molti client durante un breve rallentamento e puoi sovraccaricare il database o raggiungere i limiti di connessioni anche se il traffico medio è ok.
Un timeout è semplicemente una promessa: “non aspetteremo più di X”. Ti aiuta a fallire rapidamente e liberare risorse, ma non fa finire il lavoro prima.
Non garantisce nemmeno che il lavoro si fermi istantaneamente. Per esempio, il database potrebbe continuare a eseguire, un servizio upstream potrebbe ignorare la cancellazione, o il tuo codice potrebbe non essere sicuro quando arriva la cancellazione.
Quel che un timeout garantisce è che il tuo handler può smettere di aspettare, ritornare un errore chiaro e rilasciare ciò che tiene. Questo intervallo limitato di attesa è ciò che impedisce a poche chiamate lente di trasformarsi in un'outage totale.
L'obiettivo con i timeout di context in Go è avere un unico deadline condiviso dal bordo fino alla chiamata più profonda. Impostalo una volta al confine HTTP, passa lo stesso context nel codice del servizio e usalo nelle chiamate database/sql così anche il database sa quando smettere di aspettare.
Il context in Go in parole semplici
Un context.Context è un piccolo oggetto che passi attraverso il codice per descrivere cosa sta succedendo ora. Risponde a domande come: “questa richiesta è ancora valida?”, “quando dobbiamo mollare?” e “quali valori legati alla richiesta devono viaggiare con questo lavoro?”.
Il grande vantaggio è che una sola decisione al bordo del sistema (il tuo handler HTTP) può proteggere ogni passo a valle, purché tu continui a passare lo stesso context.
Cosa trasporta il context
Il context non è un posto per dati di business. Serve per segnali di controllo e una piccola quantità di scope della richiesta: cancellazione, un deadline/timeout e piccola metadata come un request ID per i log.
Timeout vs cancellazione è semplice: un timeout è una delle cause di cancellazione. Se imposti un timeout di 2 secondi, il context verrà cancellato quando passano 2 secondi. Ma un context può anche essere cancellato prima se l'utente chiude la scheda, il bilanciatore di carico interrompe la connessione o il tuo codice decide di fermare la richiesta.
Il context scorre attraverso le chiamate di funzione come parametro esplicito, di solito il primo: func DoThing(ctx context.Context, ...). Questo è il punto. È difficile “dimenticarlo” quando appare in ogni punto di chiamata.
Quando il deadline scade, qualsiasi cosa che osserva quel context dovrebbe fermarsi in fretta. Per esempio, una query al database che usa QueryContext dovrebbe ritornare presto con un errore come context deadline exceeded, e il tuo handler può rispondere con un timeout invece di restare appeso finché il server non esaurisce i worker.
Un buon modello mentale: una richiesta, un context, passato ovunque. Se la richiesta muore, il lavoro dovrebbe morire pure.
Impostare un deadline chiaro al confine HTTP
Se vuoi che i timeout end-to-end funzionino, decidi dove parte il conto. Il posto più sicuro è proprio al bordo HTTP, così ogni chiamata a valle (logica business, SQL, altri servizi) eredita lo stesso deadline.
Puoi impostare quel deadline in diversi posti. I timeout a livello di server sono una buona base e ti proteggono da client lenti. Il middleware è ottimo per coerenza tra gruppi di route. Impostarlo dentro l'handler va bene quando vuoi qualcosa di esplicito e locale.
Per la maggior parte delle API, i timeout per richiesta in middleware o nell'handler sono i più facili da ragionare. Tienili realistici: gli utenti preferiscono un fallimento veloce e chiaro a una richiesta che resta appesa. Molti team usano budget più corti per le letture (1–2s) e un po' più lunghi per le scritture (3–10s), a seconda di cosa fa l'endpoint.
Ecco un pattern semplice per l'handler:
func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
report, err := s.reports.Generate(ctx, r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusGatewayTimeout)
return
}
json.NewEncoder(w).Encode(report)
}
Due regole rendono questo efficace:
- Chiama sempre
cancel()così timer e risorse vengono rilasciati velocemente. - Non sostituire mai il request context con
context.Background()ocontext.TODO()dentro l'handler. Questo rompe la catena e le chiamate al database e le richieste outbound possono continuare per sempre anche dopo che il client è andato via.
Propagare il context attraverso il codice
Una volta impostato il deadline al confine HTTP, il vero lavoro è assicurarsi che lo stesso deadline raggiunga ogni livello che può bloccare. L'idea è un orologio unico, condiviso da handler, codice di servizio e tutto ciò che tocca rete o disco.
Una regola semplice mantiene le cose coerenti: ogni funzione che potrebbe attendere dovrebbe accettare un context.Context, ed esso dovrebbe essere il primo parametro. Questo lo rende evidente ai punti di chiamata e diventa una buona abitudine.
Un pattern pratico per le signature
Preferisci signature come DoThing(ctx context.Context, ...) per servizi e repository. Evita di nascondere il context dentro struct o di ricrearlo con context.Background() nei layer bassi, perché così perdi silenziosamente il deadline del chiamante.
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := h.svc.CreateOrder(ctx, r.Body); err != nil {
// map context errors to a clear client response elsewhere
http.Error(w, err.Error(), http.StatusRequestTimeout)
return
}
}
func (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {
// parsing or validation can still respect cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return s.repo.InsertOrder(ctx, /* data */)
}
Gestire le uscite anticipate in modo pulito
Considera ctx.Done() come un percorso di controllo normale. Due abitudini aiutano:
- Controlla
ctx.Err()prima di iniziare lavori costosi e dopo loop lunghi. - Ritorna
ctx.Err()verso l'alto senza modificarlo, in modo che l'handler possa rispondere rapidamente e smettere di sprecare risorse.
Quando ogni livello passa lo stesso ctx, un singolo timeout può interrompere parsing, logica business e attese sul database in un colpo solo.
Applicare i deadline alle query database/sql
Una volta che l'handler HTTP ha un deadline, assicurati che il lavoro sul database lo ascolti davvero. Con database/sql questo significa usare i metodi context-aware ogni volta. Se chiami Query() o Exec() senza contesto, la tua API può continuare ad aspettare su una query lenta anche dopo che il client ha rinunciato.
Usa questi coerentemente: db.QueryContext, db.QueryRowContext, db.ExecContext e db.PrepareContext (poi QueryContext/ExecContext sulla statement ritornata).
func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, email FROM users WHERE id = $1`, id,
)
var u User
if err := row.Scan(&u.ID, &u.Email); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET email = $1 WHERE id = $2`, email, id,
)
return err
}
Ci sono due cose facili da dimenticare.
Prima, il tuo driver SQL deve rispettare la cancellazione del context. Molti lo fanno, ma verifica nello stack eseguendo una query lenta e controllando che venga cancellata rapidamente quando scade il deadline.
Seconda, considera un timeout lato database come rete di sicurezza. Per esempio, Postgres può imporre un limite per statement (spesso chiamato statement timeout). Questo protegge il database anche se un bug in app dimentica di passare il context da qualche parte.
Quando un'operazione si ferma per timeout, gestiscila differentemente da un errore SQL normale. Controlla errors.Is(err, context.DeadlineExceeded) e errors.Is(err, context.Canceled) e ritorna una risposta chiara (come un 504) invece di trattarla come “database rotto”. Se generi backend Go (per esempio con AppMaster), mantenere distinti questi percorsi d'errore aiuta anche a rendere più comprensibili log e retry.
Chiamate a valle: client HTTP, cache e altri servizi
Anche se il tuo handler e le query SQL rispettano il context, una richiesta può ancora restare appesa se una chiamata a valle attende per sempre. Sotto carico, poche goroutine bloccate possono accumularsi, consumare pool di connessioni e trasformare un piccolo rallentamento in un'outage completa. La soluzione è propagazione coerente più un limite netto.
HTTP in uscita
Quando chiami un'altra API, costruisci la richiesta con lo stesso context così deadline e cancellazione scorrono automaticamente.
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { /* handle */ }
resp, err := httpClient.Do(req)
Non fare affidamento solo sul context. Configura anche il client HTTP e il transport così sei protetto se il codice usa accidentalmente un background context, o se DNS/TLS/connessioni idle si bloccano. Imposta http.Client.Timeout come limite superiore per tutta la chiamata, imposta timeout nel transport (dial, TLS handshake, response header) e riusa un client singolo invece di crearne uno nuovo per ogni richiesta.
Cache e code
Cache, broker di messaggi e client RPC spesso hanno i loro punti di attesa: acquisire una connessione, aspettare una risposta, bloccarsi su una coda piena o attendere un lock. Assicurati che quelle operazioni accettino ctx, e usa anche timeout a livello di libreria quando disponibili.
Una regola pratica: se alla richiesta utente restano 800ms, non avviare una chiamata a valle che potrebbe prendere 2 secondi. Saltala, degrada o ritorna una risposta parziale.
Decidi in anticipo cosa significa un timeout per la tua API. A volte la risposta giusta è un errore veloce. A volte è dati parziali per campi opzionali. A volte è dati cached obsoleti, chiaramente marcati.
Se costruisci backend Go (inclusi quelli generati, come in AppMaster), questa è la differenza tra “i timeout esistono” e “i timeout proteggono coerentemente il sistema” durante i picchi di traffico.
Passo-passo: rifattorizzare un'API per usare timeout end-to-end
Rifattorizzare per i timeout si riduce a un'abitudine: passa lo stesso context.Context dal bordo HTTP fino a ogni chiamata che potrebbe bloccarsi.
Un modo pratico per farlo è lavorare dall'alto in basso:
- Cambia handler e metodi core del servizio per accettare
ctx context.Context. - Aggiorna ogni chiamata al DB per usare
QueryContextoExecContext. - Fai lo stesso per le chiamate esterne (HTTP client, cache, code). Se una libreria non accetta
ctx, avvolgila o sostituiscila. - Decidi chi possiede i timeout. Una regola comune: l'handler imposta il deadline complessivo; i layer inferiori impostano solo deadline più corti per operazioni specifiche quando serve.
- Rendi gli errori prevedibili al bordo: mappa
context.DeadlineExceededecontext.Canceledin risposte HTTP chiare.
Ecco la forma che vuoi vedere attraverso i layer:
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
order, err := h.svc.GetOrder(ctx, r.PathValue("id"))
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(order)
}
func (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {
row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)
// scan...
}
I valori di timeout dovrebbero essere noiosi e coerenti. Se l'handler ha 2 secondi totali, mantieni le query DB sotto 1 secondo per lasciare spazio per l'encoding JSON e altro lavoro.
Per dimostrare che funziona, aggiungi un test che forza un timeout. Un approccio semplice è un metodo di repository finto che si blocca fino a ctx.Done() e poi ritorna ctx.Err(). Il test dovrebbe asserire che l'handler ritorna rapidamente un 504, non dopo il ritardo finto.
Se generi backend Go con un generator (per esempio, AppMaster genera servizi Go), la regola è la stessa: un request context unico, passato ovunque, con chiara ownership del deadline.
Osservabilità: dimostrare che i timeout funzionano
I timeout aiutano solo se riesci a vederli accadere. L'obiettivo è semplice: ogni richiesta ha un deadline e quando fallisce puoi capire dove è stato speso il tempo.
Inizia con log utili e sicuri. Invece di dumpare corpi di richiesta interi, logga abbastanza informazioni per collegare i punti e individuare i percorsi lenti: request ID (o trace ID), se è impostato un deadline e quanto tempo resta in punti chiave, il nome dell'operazione (handler, nome query SQL, chiamata outbound), e la categoria del risultato (ok, timeout, canceled, altro errore).
Aggiungi alcune metriche focalizzate così il comportamento sotto carico è ovvio:
- Conteggio timeout per endpoint e dipendenza
- Latenza delle richieste (p50/p95/p99)
- Richieste in volo
- Latenza delle query DB (p95/p99)
- Tasso di errori suddiviso per tipo
Quando gestisci errori, taggali correttamente. context.DeadlineExceeded di solito significa che hai sforato il budget. context.Canceled spesso significa che il client è andato via o che un timeout upstream è scattato prima. Tienili separati perché le azioni correttive sono diverse.
Tracing: trova dove si perde tempo
Gli span di tracing dovrebbero seguire lo stesso context dall'handler HTTP fino alle chiamate database/sql come QueryContext. Per esempio, una richiesta va in timeout a 2 secondi e la trace mostra 1.8 secondi di attesa per una connessione DB: questo punta a dimensionamento del pool o transazioni lente, non al testo della query.
Se costruisci una dashboard interna per questo (timeout per route, query lente principali), uno strumento no-code come AppMaster può aiutarti a spedirla velocemente senza trasformare l'osservabilità in un progetto ingegneristico separato.
Errori comuni che rendono inutili i tuoi timeout
La maggior parte dei bug del tipo “ancora si blocca a volte” deriva da pochi errori piccoli.
- Reset del clock a metà volo. Un handler imposta 2s di deadline, ma il repository crea un context nuovo con il proprio timeout (o senza timeout). Ora il database può continuare a eseguire dopo che il client è andato. Passa il
ctxin ingresso e stringilo solo quando hai un motivo valido. - Avviare goroutine che non si fermano mai. Avviare lavoro con
context.Background()(o senza passare il ctx) significa che continuerà anche dopo che la richiesta è stata cancellata. Passa il request ctx alle goroutine eselectsuctx.Done(). - Deadline troppo corti per il traffico reale. Un timeout di 50ms può funzionare sulla tua macchina e fallire in produzione durante un piccolo picco, causando retry, più carico e un mini-outage auto-inflitto. Scegli timeout basati sulla latenza normale più margine.
- Nascondere l'errore reale. Trattare
context.DeadlineExceededcome un generico 500 peggiora debugging e comportamento del client. Mappalo a una risposta timeout chiara e logga la differenza tra “cancellato dal client” e “scaduto”. - Lasciare risorse aperte sulle uscite anticipate. Se ritorni prima, assicurati comunque di
defer rows.Close()e di chiamare la funzione cancel dacontext.WithTimeout. Righe perdute o lavoro lasciato in sospeso possono esaurire connessioni sotto carico.
Un esempio rapido: un endpoint avvia una query di report. Se l'utente chiude la scheda, l'ctx dell'handler viene cancellato. Se la tua chiamata SQL ha usato un nuovo background context, la query continua a girare, occupando una connessione e rallentando tutti. Quando propaghi lo stesso ctx in QueryContext, la chiamata al database viene interrotta e il sistema si riprende più velocemente.
Checklist rapida per timeout affidabili
I timeout aiutano solo se sono coerenti. Una singola chiamata mancata può tenere occupata una goroutine, bloccare una connessione DB e rallentare le richieste successive.
- Imposta un deadline chiaro al bordo (di solito l'handler HTTP). Tutto dentro la richiesta dovrebbe ereditarlo.
- Passa lo stesso
ctxattraverso service e repository. Evitacontext.Background()nel codice legato alla richiesta. - Usa i metodi DB che accettano contesto ovunque:
QueryContext,QueryRowContexteExecContext. - Allegare lo stesso
ctxalle chiamate outbound (HTTP client, cache, code). Se crei un child context, fallo più corto, non più lungo. - Gestisci cancellazioni e timeout coerentemente: ritorna un errore pulito, ferma il lavoro e evita loop di retry dentro una richiesta cancellata.
Dopo questo, verifica il comportamento sotto pressione. Un timeout che scatta ma non libera risorse abbastanza in fretta fa comunque danno alla affidabilità.
Le dashboard dovrebbero rendere i timeout evidenti, non nascosti nelle medie. Monitora segnali che rispondono a “i deadline sono davvero applicati?”: timeout delle richieste e timeout DB (separatamente), percentili di latenza (p95, p99), statistiche del pool DB (connessioni in uso, conteggi di attesa, durata dell'attesa) e una ripartizione delle cause d'errore (context deadline exceeded vs altri fallimenti).
Se costruisci strumenti interni su una piattaforma come AppMaster, la stessa checklist si applica a qualsiasi servizio Go che connetti: definisci deadline al bordo, propagali e conferma con le metriche che le richieste bloccate diventano fallimenti rapidi invece di accumuli lenti.
Scenario d'esempio e prossimi passi
Un caso comune dove questo ripaga è un endpoint di ricerca. Immagina GET /search?q=printer che rallenta quando il database è occupato da una grande query di report. Senza un deadline, ogni richiesta in ingresso può rimanere in attesa di una query SQL lunga. Sotto carico, quelle richieste bloccate si accumulano, occupano goroutine e connessioni, e tutta l'API sembra congelata.
Con un deadline chiaro nell'handler HTTP e lo stesso ctx passato al repository, il sistema smette di aspettare quando il budget è esaurito. Quando il deadline scatta, il driver DB cancella la query (se supportato), l'handler ritorna e il server può continuare a servire nuove richieste invece di aspettare per sempre.
Il comportamento visibile all'utente migliora anche quando qualcosa va storto. Invece di girare per 30–120 secondi e poi fallire in modo confuso, il client ottiene un errore rapido e prevedibile (spesso un 504 o 503 con un messaggio breve come "request timed out"). Più importante, il sistema si riprende velocemente perché le nuove richieste non restano bloccate dietro a quelle vecchie.
Prossimi passi per far sì che questo rimanga applicato tra endpoint e team:
- Scegli timeout standard per tipo di endpoint (search vs scritture vs export).
- Richiedi
QueryContexteExecContextnelle code review. - Rendi espliciti gli errori di timeout al bordo (codice di stato chiaro, messaggio semplice).
- Aggiungi metriche per timeout e cancellazioni così noti le regressioni presto.
- Scrivi un helper che incapsula la creazione del context e il logging così ogni handler si comporta allo stesso modo.
Se costruisci servizi e tool interni con AppMaster, puoi applicare queste regole di timeout in modo coerente su backend Go generati, integrazioni API e dashboard in un unico posto. AppMaster è disponibile come piattaforma no-code che genera codice Go reale e può essere una scelta pratica quando vuoi gestione coerente delle richieste e osservabilità senza costruire manualmente ogni tool amministrativo.
FAQ
Una richiesta è “bloccata” quando sta aspettando qualcosa che non risponde, ad esempio una query SQL lenta, una connessione del pool bloccata, problemi DNS o un servizio upstream che non risponde mai. Sotto carico, le richieste bloccate si accumulano, impegnano worker e connessioni, e possono trasformare una piccola rallentamento in un'interruzione più ampia.
Imposta il deadline complessivo al confine HTTP e passa lo stesso ctx a ogni livello che può bloccarsi. Quel deadline condiviso impedisce a poche operazioni lente di trattenere le risorse abbastanza a lungo da causare timeout a catena.
Usa ctx, cancel := context.WithTimeout(r.Context(), d) e fai sempre defer cancel() nell'handler (o nel middleware). La chiamata a cancel libera i timer e aiuta a fermare l'attesa subito quando la richiesta termina prima del previsto.
Non sostituire il contesto con context.Background() o context.TODO() nel codice della richiesta, perché così si spezzano cancellazioni e deadline. Se perdi il contesto della richiesta, lavoro downstream come SQL o HTTP in uscita può continuare anche dopo che il client è andato via.
Tratta context.DeadlineExceeded e context.Canceled come esiti normali di controllo e propagali verso l'alto senza modificarli. Al bordo, mappali su risposte chiare (spesso 504 per i timeout) così i client non ritentano automaticamente su ciò che appare come un 500 casuale.
Usa sempre i metodi che accettano contesto: QueryContext, QueryRowContext, ExecContext e PrepareContext. Se chiami Query() o Exec() senza contesto, il tuo handler può andare in timeout mentre la chiamata al database continua a bloccare la goroutine e a tenere occupata una connessione.
Molti driver lo fanno, ma verifica nello stack eseguendo una query deliberatamente lenta e confermando che venga cancellata rapidamente alla scadenza del deadline. È anche prudente usare un timeout lato database (per esempio statement_timeout in Postgres) come ulteriore protezione nel caso qualcosa dimentichi di passare il ctx.
Costruisci la richiesta esterna con http.NewRequestWithContext(ctx, ...) così lo stesso deadline e la cancellazione si propagano automaticamente. Inoltre configura il client e il transport (timeout globale, dial, TLS handshake, header timeout) come limite superiore, perché il contesto non ti protegge se qualcuno usa per sbaglio un background context o se si verifica un blocco a livello basso.
Evita di creare contesti nuovi che estendono il budget temporale nei livelli inferiori; i child timeout dovrebbero essere più corti, non più lunghi. Se alla richiesta resta poco tempo, salta chiamate opzionali, restituisci dati parziali quando appropriato o fallisci velocemente con un errore chiaro.
Monitora separatamente timeout e cancellazioni per endpoint e dipendenza, insieme a percentili di latenza e richieste in volo. Nelle trace, segui lo stesso contesto dall'handler fino alle chiamate outbound e a QueryContext così puoi vedere se il tempo è stato speso aspettando una connessione al DB, nell'esecuzione di una query o bloccato su un altro servizio.


