I timeout dei context in Go impediscono che chiamate lente a DB e richieste esterne si accumulino. Impara propagazione delle deadline, cancellazione e valori di default sicuri.

Una singola richiesta lenta raramente è "solo lenta." Mentre aspetta, mantiene viva una goroutine, occupa memoria per buffer e oggetti di risposta e spesso tiene occupata una connessione al database o uno slot in un pool. Quando abbastanza richieste lente si accumulano, la tua API smette di fare lavoro utile perché le risorse limitate sono bloccate in attesa.
Lo senti di solito in tre punti. Le goroutine si accumulano e l'overhead di scheduling cresce, quindi la latenza peggiora per tutti. I pool del database finiscono le connessioni libere, così anche le query veloci iniziano a fare coda dietro a quelle lente. La memoria cresce per i dati in volo e le risposte parzialmente costruite, aumentando il lavoro del GC.
Aggiungere più server spesso non risolve. Se ogni istanza colpisce lo stesso collo di bottiglia (un piccolo pool DB, un upstream lento, limiti di rate condivisi), sposti solo la coda e paghi di più mentre gli errori aumentano lo stesso.
Immagina un handler che fan-out: carica un utente da PostgreSQL, chiama un servizio di pagamenti, poi chiama un servizio di raccomandazioni. Se la chiamata di raccomandazioni si blocca e nulla la cancella, la richiesta non termina mai. La connessione al DB potrebbe tornare, ma la goroutine e le risorse del client HTTP restano occupate. Moltiplica per centinaia di richieste e ottieni un lento collasso.
L'obiettivo è semplice: imposta un limite di tempo chiaro, interrompi il lavoro quando il tempo è scaduto, libera le risorse e restituisci un errore prevedibile. I timeout di context in Go danno a ogni passaggio una scadenza in modo che il lavoro si fermi quando l'utente non sta più aspettando.
Un context.Context è un piccolo oggetto che passi lungo la catena di chiamate in modo che ogni livello sia d'accordo su una cosa: quando questa richiesta deve fermarsi. I timeout sono il modo comune per evitare che una dipendenza lenta blocchi il server.
Un context può trasportare tre tipi di informazione: una deadline (quando il lavoro deve fermarsi), un segnale di cancellazione (qualcuno ha deciso di fermarsi prima) e qualche valore a durata di richiesta (usalo con parsimonia e mai per dati grandi).
La cancellazione non è magia. Un context espone un canale Done(). Quando si chiude, la richiesta è cancellata o il tempo è scaduto. Il codice che rispetta il context controlla Done() (spesso con un select) e ritorna presto. Puoi anche controllare ctx.Err() per sapere perché è finito, di solito context.Canceled o context.DeadlineExceeded.
Usa context.WithTimeout per "fermati dopo X secondi." Usa context.WithDeadline quando conosci già l'orario esatto di cutoff. Usa context.WithCancel quando una condizione padre dovrebbe fermare il lavoro (client disconnesso, utente ha navigato via, hai già la risposta).
Quando un context viene cancellato, il comportamento corretto è noioso ma importante: smettere di lavorare, smettere di attendere I/O lento e restituire un errore chiaro. Se un handler sta aspettando una query al database e il context termina, ritorna rapidamente e lascia che la chiamata al database si abortisca se supporta il context.
Il posto più sicuro per bloccare le richieste lente è il confine dove il traffico entra nel tuo servizio. Se una richiesta deve scadere, vuoi che succeda in modo prevedibile e precoce, non dopo che ha occupato goroutine, connessioni DB e memoria.
Inizia al bordo (load balancer, API gateway, reverse proxy) e imposta un limite massimo per quanto a lungo una richiesta può vivere. Questo protegge il tuo servizio Go anche se un handler dimentica di impostare un timeout.
All'interno del tuo server Go, configura i timeout HTTP in modo che il server non aspetti all'infinito un client lento o una risposta bloccata. Al minimo, configura timeout per la lettura degli header, la lettura del body completo, la scrittura della risposta e le connessioni idle.
Scegli un budget di default che corrisponda al tuo prodotto. Per molte API, 1–3 secondi è un buon punto di partenza per richieste tipiche, con un limite più alto per operazioni notevolmente lente come esportazioni. Il numero preciso conta meno dell'essere coerenti, misurarlo e avere una regola chiara per le eccezioni.
Le risposte in streaming richiedono attenzione extra. È facile creare uno stream infinito accidentale dove il server mantiene la connessione aperta e scrive piccoli chunk per sempre, o attende infinitamente prima del primo byte. Decidi in anticipo se un endpoint è davvero uno stream. Se non lo è, applica un tempo massimo totale e un tempo massimo al primo byte.
Una volta che il confine ha una scadenza chiara, è molto più semplice propagare quella deadline attraverso tutta la richiesta.
Il posto più semplice per iniziare è l'handler HTTP. È dove una richiesta entra nel tuo sistema, quindi è naturale impostare un limite netto.
Crea un nuovo context con una deadline e assicurati di cancellarlo. Poi passa quel context a tutto ciò che potrebbe bloccarsi: lavoro su database, chiamate HTTP o calcoli lenti.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
Una buona regola: se una funzione può attendere I/O, dovrebbe accettare un context.Context. Mantieni gli handler leggibili spostando i dettagli in helper piccoli come loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo dovrebbe usare QueryRowContext/ExecContext
}
Se scade la deadline (o il client si disconnette), interrompi il lavoro e restituisci una risposta amichevole. Una mappatura comune è context.DeadlineExceeded a 504 Gateway Timeout, e context.Canceled a "client is gone" (spesso senza body di risposta).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Client è andato via. Evita di fare altro lavoro.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Questo pattern evita gli accumuli. Una volta che il timer scade, ogni funzione che rispetta il context lungo la catena riceve lo stesso segnale di stop e può uscire rapidamente.
Quando il tuo handler ha un context con una deadline, la regola più importante è semplice: usa lo stesso ctx fino alla chiamata al database. È così che i timeout fermano il lavoro invece di limitarsi a evitare che l'handler resti in attesa.
Con database/sql, preferisci i metodi che accettano context:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
Se il budget dell'handler è 2 secondi, il database dovrebbe ottenere solo una fetta di quello. Lascia tempo per l'encoding JSON, altre dipendenze e la gestione degli errori. Un punto di partenza semplice è dare a Postgres il 30%–60% del budget totale. Con 2 secondi di deadline dell'handler, potrebbe essere 800ms–1.2s.
Quando il context viene cancellato, il driver chiede a Postgres di fermare la query. Di solito la connessione torna al pool e può essere riutilizzata. Se la cancellazione avviene durante un momento di rete difettoso, il driver può scartare la connessione e aprirne una nuova più tardi. In ogni caso, eviti una goroutine che aspetta per sempre.
Quando controlli gli errori, tratta i timeout diversamente dai veri errori DB. Se errors.Is(err, context.DeadlineExceeded), hai esaurito il tempo e dovresti restituire un timeout. Se errors.Is(err, context.Canceled), il client è andato via e dovresti fermarti senza rumore. Altri errori sono problemi normali di query (SQL errato, riga mancante, permessi).
Se l'handler ha una deadline, anche le tue chiamate HTTP in uscita dovrebbero rispettarla. Altrimenti il client rinuncia ma il server continua ad aspettare un upstream lento e tiene occupate goroutine, socket e memoria.
Costruisci le richieste in uscita con il context padre così la cancellazione viaggia automaticamente:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
Quell'timeout per chiamata è una rete di sicurezza. La deadline padre è comunque il capo. Un orologio per l'intera richiesta, più cappelli più piccoli per passi rischiosi.
Configura anche i timeout a livello di transport. Il context cancella la richiesta, ma i timeout del transport ti proteggono da handshake lenti e server che non mandano mai gli header.
Un dettaglio che morde i team: i body delle risposte devono essere chiusi su ogni percorso. Se ritorni presto (controllo status code, errore di decoding JSON, timeout del context), chiudi comunque il body. Perdite di body possono silenziosamente esaurire le connessioni nel pool e trasformarsi in picchi di latenza "casuali".
Uno scenario concreto: la tua API chiama un provider di pagamenti. Il client scade dopo 2 secondi, ma l'upstream si blocca per 30 secondi. Senza cancellazione della richiesta e timeout del transport, paghi per quei 30 secondi di attesa per ogni richiesta abbandonata.
Una singola richiesta solitamente tocca più di una cosa lenta: lavoro dell'handler, una query DB e una o più API esterne. Se dai a ogni passo un timeout generoso, il tempo totale cresce silenziosamente finché gli utenti non lo notano e il server si accumula.
Il budgeting è la soluzione più semplice. Imposta una deadline padre per l'intera richiesta, poi dai a ogni dipendenza una fetta più piccola. Le deadline dei figli dovrebbero essere anteriori al parent in modo da fallire presto e avere ancora tempo per restituire un errore pulito.
Regole pratiche che funzionano nei servizi reali:
Evita di sovrapporre timeout che si combattono a vicenda. Se l'handler ha una deadline di 2s e il client HTTP ha un timeout di 10s, sei al sicuro ma è confuso. Se è il contrario, il client può interrompere prima per motivi non correlati.
Per lavori in background (audit log, metriche, email), non riutilizzare il context della richiesta. Usa un context separato con il proprio timeout corto così le cancellazioni del client non uccidano cleanup importanti.
La maggior parte dei bug sui timeout non sono nell'handler. Succedono uno o due livelli più in basso, dove la deadline viene silenziosamente persa. Se imposti timeout al bordo ma li ignori nel mezzo, puoi comunque ritrovarti con goroutine, query DB o chiamate HTTP che continuano anche dopo che il client se n'è andato.
I pattern che compaiono più spesso sono semplici:
context.Background() (o TODO). Questo scollega il lavoro dal cancel del client e dalla deadline dell'handler.ctx.Done(). La richiesta è cancellata, ma il tuo codice continua ad aspettare.context.WithTimeout. Finisci con molti timer e deadline confuse.ctx a chiamate bloccanti (query DB, HTTP in uscita, publish di messaggi). Un timeout dell'handler non fa nulla se la dipendenza lo ignora.Un fallimento classico: aggiungi un timeout di 2 secondi nell'handler, poi il repository usa context.Background() per la query al database. Sotto carico, una query lenta continua a girare anche dopo che il client ha rinunciato, e l'accumulo cresce.
Sistema le basi: passa ctx come primo argomento lungo lo stack delle chiamate. Dentro lavori lunghi, aggiungi controlli rapidi come select { case <-ctx.Done(): return ctx.Err() default: }. Mappa context.DeadlineExceeded a una risposta di timeout (spesso 504) e context.Canceled a una risposta di client-cancel (spesso 408 o 499 a seconda delle tue convenzioni).
I timeout aiutano solo se puoi vederli succedere e confermare che il sistema si riprende pulitamente. Quando qualcosa è lento, la richiesta dovrebbe fermarsi, le risorse dovrebbero essere rilasciate e l'API dovrebbe rimanere reattiva.
Per ogni richiesta, logga lo stesso piccolo set di campi così puoi confrontare richieste normali vs timeout. Includi la deadline del context (se esiste) e cosa ha chiuso il lavoro.
Campi utili includono la deadline (o "none"), il tempo totale trascorso, la ragione della cancellazione (timeout vs client canceled), una breve etichetta operazione ("db.query users", "http.call billing") e un request ID.
Un pattern minimo somiglia a questo:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
I log aiutano a capire una richiesta. Le metriche mostrano le tendenze.
Monitora pochi segnali che solitamente aumentano presto quando i timeout sono sbagliati: conteggio dei timeout per route e dipendenza, richieste in volo (dovrebbero stabilizzarsi sotto carico), tempo di attesa nel pool DB e percentili di latenza (p95/p99) divisi per successo vs timeout.
Rendi la lentezza prevedibile. Aggiungi un delay di debug a un handler, rallenta una query DB con un'attesa deliberata o avvolgi una chiamata esterna con un server di test che dorme. Poi verifica due cose: vedi l'errore di timeout e il lavoro si ferma poco dopo la cancellazione.
Un piccolo test di carico aiuta anche. Esegui 20–50 richieste concorrenti per 30–60 secondi con una dipendenza forzatamente lenta. Il conteggio delle goroutine e le richieste in volo dovrebbero salire e poi stabilizzarsi. Se continuano a crescere, qualcosa sta ignorando la cancellazione del context.
I timeout aiutano solo se applicati ovunque una richiesta può aspettare. Prima di deployare, fai una passata sul codice e conferma che le stesse regole siano seguite in ogni handler.
context.DeadlineExceeded e context.Canceled.http.NewRequestWithContext (o req = req.WithContext(ctx)) e il client ha timeout di transport (dial, TLS, header response). Evita di affidarti a http.DefaultClient nei percorsi di produzione.Un rapido drill di "dipendenza lenta" prima del rilascio vale il tempo. Aggiungi un delay artificiale di 2 secondi a una query SQL e conferma tre cose: l'handler ritorna in tempo, la chiamata DB si ferma davvero (non solo l'handler) e i log dicono chiaramente che è stato un timeout DB.
Immagina un endpoint come GET /v1/account/summary. Un'azione utente scatena tre cose: una query PostgreSQL (account più attività recenti) e due chiamate HTTP esterne (per esempio, controllo stato billing e lookup di arricchimento profilo).
Dai alla richiesta completa un budget netto di 2 secondi. Senza un budget, una dipendenza lenta può tener fermo goroutine, connessioni DB e memoria finché la tua API comincia a timouttare ovunque.
Una divisione semplice potrebbe essere 800ms per la query DB, 600ms per la chiamata esterna A e 600ms per la chiamata esterna B.
Una volta nota la deadline complessiva, passala in basso. Ogni dipendenza ottiene il proprio timeout più piccolo, ma eredita comunque la cancellazione dal parent.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
Se la chiamata esterna B rallenta e impiega 2.5 secondi, il tuo handler dovrebbe smettere di aspettare a 600ms, cancellare il lavoro in corso e restituire una chiara risposta di timeout al client. Il client vede un fallimento rapido invece di uno spinner che gira all'infinito.
I tuoi log dovrebbero rendere ovvio cosa ha consumato il budget, per esempio: DB ha finito velocemente, esterno A è riuscito, esterno B ha raggiunto il suo limite e ha restituito context deadline exceeded.
Una volta che un endpoint reale funziona bene con timeout e cancellazione, trasformalo in un pattern ripetibile. Applicalo end-to-end: deadline dell'handler, chiamate DB e HTTP in uscita. Poi copia la stessa struttura sul prossimo endpoint.
Andrai più veloce se centralizzi le parti noiose: un helper per il timeout al confine, wrapper che assicurano che ctx venga passato a DB e HTTP, e una mappatura errori e formato di log coerenti.
Se vuoi prototipare rapidamente questo pattern, Koder.ai (koder.ai) può generare handler Go e chiamate di servizio da un prompt in chat, e puoi esportare il sorgente per applicare i tuoi helper di timeout e i budget. L'obiettivo è la coerenza: le chiamate lente si fermano presto, gli errori hanno lo stesso aspetto e il debugging non dipende da chi ha scritto l'endpoint.
Una richiesta lenta trattiene risorse limitate mentre aspetta: una goroutine, memoria per buffer e oggetti di risposta, e spesso una connessione al database o una connessione HTTP. Quando molte richieste restano in attesa contemporaneamente, si formano code, la latenza aumenta per tutto il traffico e il servizio può fallire anche se ogni singola richiesta finirebbe eventualmente.
Imposta un chiaro limite di tempo al confine della richiesta (proxy/gateway e nel server Go), ricava un context temporizzato nell'handler e passa quel ctx in ogni chiamata bloccante (database e HTTP in uscita). Quando la scadenza scatta, ritorna rapidamente con una risposta di timeout coerente e interrompi qualsiasi lavoro in corso che supporti la cancellazione.
Usa context.WithTimeout(parent, d) quando vuoi "fermarsi dopo questa durata", che è la più comune negli handler. Usa context.WithDeadline(parent, t) quando hai già un cutoff temporale preciso da rispettare. Usa context.WithCancel(parent) quando una condizione interna deve fermare il lavoro prima (es. "abbiamo già una risposta" o "il client si è disconnesso").
Chiama sempre la funzione cancel, di solito con defer cancel() subito dopo aver creato il context derivato. Cancellare libera il timer e invia un segnale di stop chiaro a eventuali lavori figli, specialmente nei percorsi di codice che ritornano prima che la scadenza scatti.
Crea il context della richiesta una sola volta nell'handler e passalo come primo argomento alle funzioni che possono bloccarsi. Una verifica rapida è cercare context.Background() o context.TODO() nei percorsi di richiesta; questi spesso interrompono la propagazione della cancellazione scollegando il lavoro dalla scadenza della richiesta.
Usa metodi di database che accettano il context, come QueryContext, QueryRowContext e ExecContext (o gli equivalenti nel tuo driver). Quando il context termina, il driver può chiedere a PostgreSQL di annullare la query così non continui a consumare tempo e connessioni dopo che la richiesta è già finita.
Allega il context della richiesta padre alla richiesta in uscita usando http.NewRequestWithContext(ctx, ...), e configura anche i timeout del client/transport in modo da essere protetto durante connect, TLS e attesa degli header della risposta. Anche in caso di errori o risposte non 200, chiudi sempre il corpo della risposta così le connessioni tornano al pool.
Scegli prima un budget totale per la richiesta, poi assegna a ogni dipendenza una fetta minore che rientri in quel budget, lasciando un piccolo margine per l'overhead dell'handler e l'encoding della risposta. Se al parent context resta poco tempo, evita di avviare lavori costosi che non possono realisticamente terminare prima della scadenza.
Una mappatura comune è trattare context.DeadlineExceeded come 504 Gateway Timeout con un messaggio breve come "request timed out". Per context.Canceled normalmente significa che il client si è disconnesso; spesso la migliore azione è fermare il lavoro e non scrivere un corpo, così non sprechi risorse.
Gli errori più frequenti sono: perdere il context della richiesta usando context.Background(), avviare retry o sleep senza controllare ctx.Done(), e dimenticare di allegare ctx a chiamate bloccanti. Un altro problema sottile è impilare molti timeout indipendenti ovunque, che rendono i fallimenti difficili da ragionare e possono causare tagli anticipati sorprendenti.