Go context.Context fatto bene: Cancellazione, Timeouts e Valori
Il contesto di Go riguarda il controllo del flusso, non lo storage.
Il context.Context di Go è sufficientemente semplice da essere usato male — e questo è il problema.
La maggior parte degli sviluppatori Go impara rapidamente le regole di superficie: passare il contesto come primo argomento, controllare ctx.Done(), usare context.WithTimeout e non passare mai nil.
func DoSomething(ctx context.Context) error {
// ...
}
Queste regole sono utili, ma coprono solo la parte facile. Nei servizi in produzione, il contesto non è solo una convenzione parametrica — è il piano di controllo per la durata della vita della richiesta.

Il contesto dice al lavoro quando fermarsi, quanto tempo gli rimane, quale percorso di cancellazione è stato seguito e quali valori con ambito di richiesta devono viaggiare attraverso i confini dell’API. Se usato bene, previene le perdite di goroutine, evita lavoro sprecato, propaga le scadenze e rende i servizi più facili da arrestare. Se usato male, diventa un sacco di dipendenze nascoste, globali finte, timeout dimenticati, timer persi e comportamenti di cancellazione confusi.
La versione leggermente opinata è questa: usa il contesto per la cancellazione, le scadenze e i metadati con ambito di richiesta, e non usarlo come contenitore di dipendenze.
A cosa serve il contesto
Il pacchetto context ha tre compiti principali — cancellazione, scadenze e timeout, e valori con ambito di richiesta — e questi tre compiti coprono tutto ciò per cui è progettato.
Un contesto dovrebbe rispondere a domande come:
Questa richiesta è stata cancellata?
Quanto tempo ha ancora questa operazione?
Quale ID richiesta deve essere allegato ai log?
Quale utente autenticato è associato a questa richiesta?
Un contesto non dovrebbe rispondere a domande come:
Dove si trova la mia connessione al database?
Dove si trova il mio logger?
Dove si trova la mia configurazione?
Quale implementazione del servizio devo usare?
Quelle sono dipendenze — passale esplicitamente attraverso i parametri delle funzioni (vedi Dependency Injection in Go per modelli su come farlo in modo pulito). Il contesto è per la durata della vita della richiesta e i metadati della richiesta, non per il cablaggio dell’applicazione.
La forma base del contesto
L’interfaccia principale è piccola:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Le parti importanti sono:
Done()viene chiusa quando il contesto è cancellato o la sua scadenza scade.Err()spiega perché il contesto è terminato.Deadline()ti dice se il contesto ha una scadenza.Value()memorizza i dati con ambito di richiesta.
La maggior parte del codice non implementa questa interfaccia. Riceve un contesto e lo passa giù.
La prima regola: passare il contesto esplicitamente
Per le funzioni che eseguono lavoro con ambito di richiesta o cancellabile, passa il contesto come primo parametro — questa è la convenzione standard di Go e ciò che ogni libreria e strumento nell’ecosistema si aspetta:
func GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Fai questo per le funzioni che potrebbero:
- Chiamare un database
- Chiamare un altro servizio
- Attendere su una coda
- Avviare lavoro in background
- Bloccarsi su I/O
- Usare un timeout
- Aver bisogno di valori con ambito di richiesta
- Aver bisogno di cancellazione
Non aggiungere il contesto a piccole funzioni pure che non ne hanno bisogno.
Questo va bene:
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Non ogni funzione ha bisogno di un contesto. Aggiungere il contesto ovunque rende il codice rumoroso.
Non memorizzare il contesto nelle struct
Memorizzare un contesto in una struct è uno degli errori più comuni nei codebase Go, e vale la pena evidenziarlo esplicitamente. Non fare questo:
type UserService struct {
ctx context.Context
db *sql.DB
}
Fai invece questo:
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Un contesto appartiene a una richiesta, operazione o attività, mentre una struct di servizio solitamente vive molto più a lungo di qualsiasi singola richiesta. Mescolare quelle durate rende la cancellazione poco chiara e rende difficile ragionare su a quale operazione appartenga un contesto.
Ci sono rare eccezioni per tipi che rappresentano genuinamente la durata di vita di una singola operazione, ma sono così rare che la regola predefinita dovrebbe essere semplice:
Passa il contesto. Non memorizzarlo.
Non passare il contesto nil
Non passare mai nil come contesto.
Male:
err := svc.DoWork(nil)
Usa context.Background() quando non esiste un contesto esistente:
err := svc.DoWork(context.Background())
Nei test, usa il contesto del test quando possibile:
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Un contesto nil può causare un panic quando il codice chiama metodi su di esso. Un contesto di background è esplicito e sicuro.
Contesti Background, TODO e di richiesta
Ci sono tre punti di partenza comuni.
context.Background
Usa context.Background() al livello superiore di un programma quando non esiste un contesto genitore — è il contesto radice da cui derivano tutti i contesti figlio:
func main() {
ctx := context.Background()
_ = run(ctx)
}
oppure:
func TestSomething(t *testing.T) {
ctx := context.Background()
_ = ctx
}
context.TODO
Usa context.TODO() quando sai che dovrebbe essere usato un contesto ma non hai ancora deciso quale.
ctx := context.TODO()
Questo è utile durante la migrazione, ma non dovrebbe diventare permanente se esiste un contesto reale.
Contesto di richiesta
Nei server HTTP, usa il contesto della richiesta:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
Il contesto della richiesta viene cancellato quando la connessione del cliente si chiude, la richiesta viene cancellata o il server termina l’elaborazione della richiesta.
Per i servizi web, questo è solitamente il contesto che dovresti passare al codice dell’applicazione.
Cancellazione con context.WithCancel
Usa context.WithCancel quando vuoi fermare il lavoro esplicitamente.
ctx, cancel := context.WithCancel(parent)
defer cancel()
La funzione cancel restituita cancella il contesto figlio e rilascia le risorse ad esso associate. Chiamala sempre quando hai finito — anche se il contesto scadrà eventualmente, chiamare cancel in anticipo evita di mantenere le risorse vive più a lungo del necessario.
Esempio:
func RunWorker(parent context.Context) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
done := make(chan error, 1)
go func() {
done <- doBackgroundWork(ctx)
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-done:
return err
}
}
Il modello è semplice:
- Deriva un contesto figlio.
- Deferisci cancel.
- Passa il contesto figlio al lavoro che dovrebbe fermarsi insieme.
- Osserva
ctx.Done().
Timeout con context.WithTimeout
Usa context.WithTimeout quando un’operazione ha una durata massima.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Esempio con un client HTTP:
func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Questo rende il timeout parte dell’operazione, non un’impostazione globale nascosta.
Chiama sempre cancel
Quando chiami WithCancel, WithTimeout o WithDeadline, chiama sempre la funzione cancel restituita — questo è importante per la correttezza.
Buono:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Male:
ctx, _ := context.WithTimeout(parent, 5*time.Second)
Non chiamare cancel può mantenere timer e contesti figlio attivi più a lungo del necessario.
Scadenze vs timeout
Un timeout è relativo:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Una scadenza è assoluta:
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
La maggior parte del codice dell’applicazione usa i timeout. Le scadenze sono utili quando una richiesta ha un tempo di fine fisso che dovrebbe essere condiviso tra più operazioni — per esempio, se una richiesta ha 900 millisecondi rimanenti, non dare a ogni chiamata downstream un timeout fresco di 1 secondo; propaga invece il budget rimanente.
Budget di timeout attraverso i livelli del servizio
Un errore comune è impilare i timeout in modo cieco.
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
_ = service.DoWork(ctx)
}
func (s *Service) DoWork(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.repo.Query(ctx)
}
Questo sembra innocuo, ma nasconde il budget reale. Il livello del servizio dovrebbe solitamente rispettare la scadenza del chiamante invece di reimpostare il timer allo stesso valore.
Un modello migliore è:
func Handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := service.DoWork(ctx); err != nil {
// gestisci errore
return
}
}
Poi dentro il servizio:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Query(ctx)
}
Aggiungi un timeout figlio solo quando un’operazione subordinata ha bisogno di un budget più piccolo:
func (s *Service) DoWork(ctx context.Context) error {
queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return s.repo.Query(queryCtx)
}
Il modello mentale corretto è semplice: l’intera richiesta ha un budget esterno, le sotto-operazioni specifiche possono avere budget più piccoli ricavati da quel budget, e nessun livello estende silenziosamente la richiesta oltre ciò che il chiamante intendeva.
Controlla ctx.Err() per distinguere la cancellazione dal timeout
Quando un contesto termina, ctx.Err() restituisce il motivo.
Solitamente è uno di:
context.Canceled
context.DeadlineExceeded
Esempio:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return handle(result)
}
Questo permette ai chiamanti di distinguere la cancellazione dal timeout, e questa distinzione è importante nella pratica. Una richiesta cancellata spesso significa che il cliente si è disconnesso, mentre un errore di scadenza superata di solito significa che il tuo servizio era troppo lento — non dovrebbero sempre essere registrati, ritentati o riportati nello stesso modo.
Usa context.Cause per motivi di cancellazione migliori
Il Go moderno supporta anche la cancellazione consapevole della causa.
Le funzioni utili includono:
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
Il semplice ctx.Err() ti dice il motivo generale: cancellato o scadenza superata.
context.Cause(ctx) può dirti la causa più specifica.
Esempio:
var ErrShutdown = errors.New("server shutting down")
func Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
go func() {
// È arrivato un segnale di arresto.
cancel(ErrShutdown)
}()
<-ctx.Done()
return context.Cause(ctx)
}
Usa la cancellazione consapevole della causa quando il motivo è importante per i chiamanti, i log o il comportamento di pulizia, ed evitala dove un semplice ctx.Err() è sufficiente — il dettaglio extra vale solo quando la diagnosi lo richiede genuinamente.
Esempio server HTTP
Un handler HTTP normale dovrebbe partire da r.Context(). Per una panoramica completa sulla strutturazione dei servizi HTTP Go, vedi Building REST APIs in Go.
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := r.PathValue("id")
user, err := svc.GetUser(ctx, id)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
Il servizio dovrebbe accettare e propagare il contesto:
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Il repository dovrebbe usare metodi del database consapevoli del contesto:
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
return nil, err
}
return &user, nil
}
La cosa importante è la catena — ogni livello passa lo stesso contesto al successivo:
Non interrompere la catena creando context.Background() in mezzo.
L’errore di context.Background(): rompere la catena di cancellazione
Questo è un bug comune:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(context.Background(), id)
}
Questo scarta tutte le informazioni di cancellazione e scadenza dal chiamante. Se il cliente si disconnette, la query del database continua a girare. Se la richiesta scade, il lavoro downstream potrebbe ancora essere in corso. Se il server si sta arrestando, questo codice lo ignora completamente. Sostituire il contesto ricevuto con context.Background() all’interno della logica di business è quasi sempre sbagliato.
Usa il contesto che ti è stato dato:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Usa context.Background() solo al confine dove non esiste un contesto genitore.
Esempio client HTTP
Per le richieste HTTP uscenti, allega il contesto alla richiesta.
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Non fare questo:
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
Questo crea una richiesta senza il contesto dell’operazione.
Evita anche di affidarti solo a http.Client.Timeout. Può essere utile come limite di sicurezza, ma i contesti delle richieste ti danno una migliore propagazione attraverso la catena di chiamate.
Un modello comune è:
func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
return client.Do(req)
}
Usalo quando la chiamata API downstream ha un budget specifico all’interno di una richiesta più grande.
Esempio database
La maggior parte delle API dei database Go ha metodi consapevoli del contesto. Per una visione più ampia di come le librerie di accesso ai dati Go gestiscono il contesto — inclusi GORM, Ent, Bun e sqlc — vedi Comparing Go ORMs for PostgreSQL.
Usali.
Buono:
rows, err := db.QueryContext(ctx, query, args...)
Buono:
err := db.QueryRowContext(ctx, query, id).Scan(&name)
Buono:
result, err := db.ExecContext(ctx, query, args...)
Male:
rows, err := db.Query(query, args...)
Le forme consapevoli del contesto permettono alle operazioni del database di fermarsi quando la richiesta è cancellata o scade, il che è particolarmente importante per query lente, database sovraccarichi e API front-end dove la latenza influisce direttamente sull’esperienza utente.
Transazioni e contesto
Le transazioni necessitano di una gestione attenta del contesto.
Una transazione dovrebbe solitamente iniziare con il contesto dell’operazione:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Poi usa lo stesso contesto per le operazioni della transazione:
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
Fai attenzione ai timeout intorno alle transazioni. Se il contesto è cancellato prima di Commit, la transazione potrebbe essere rollata indietro. Potrebbe essere quello che vuoi, ma dovrebbe essere intenzionale.
Per transazioni lunghe, la risposta migliore di solito non è un timeout più lungo — è una transazione più corta che fa meno lavoro per unità.
Worker in background e contesto
I worker in background dovrebbero ricevere un contesto che rappresenti la loro durata di vita.
Esempio:
type Worker struct {
logger *slog.Logger
}
func (w *Worker) Run(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := w.doOnce(ctx); err != nil {
w.logger.Error("worker iteration failed", "err", err)
}
}
}
}
Questo worker si ferma pulitamente quando il contesto è cancellato, e il suo ticker è pulito correttamente tramite defer ticker.Stop(). In main, creeresti un contesto radice legato ai segnali OS:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
worker := &Worker{logger: slog.Default()}
if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("worker stopped", "err", err)
}
}
Questo è il contesto usato correttamente: descrive la durata del lavoro del processo, e quando l’OS invia un segnale, l’intero albero di goroutine che condividono questo contesto si fermerà insieme.
Prevenire le perdite di goroutine con la cancellazione del contesto
Una perdita di goroutine si verifica quando una goroutine rimane bloccata per sempre dopo che non è più utile.
Il contesto aiuta a prevenirlo.
Male:
func StartWorker() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
}
Questa goroutine non ha un percorso di arresto.
Meglio:
func StartWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doWork()
}
}
}()
}
Qualsiasi goroutine che esegue un ciclo dovrebbe quasi sempre avere un percorso di cancellazione.
Questo non significa che ogni goroutine deve ricevere il contesto direttamente, ma il sistema dovrebbe avere un modo chiaro per fermarla.
context.AfterFunc
context.AfterFunc esegue una funzione dopo che un contesto è stato cancellato.
Può essere utile per la pulizia, sbloccare operazioni o collegare API che non supportano nativamente il contesto.
Esempio:
func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
stop := context.AfterFunc(ctx, func() {
// Svegliare o pulire se necessario.
})
defer stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
return nil
}
}
Usa AfterFunc con cautela — avvia la logica quando accade la cancellazione, il che può rendere il flusso di controllo più difficile da seguire. Per la maggior parte del codice dell’applicazione, un normale select su ctx.Done() è più chiaro e più facile da ragionare. AfterFunc è più prezioso quando devi adattare la cancellazione del contesto a un’API che non accetta già il contesto.
context.WithoutCancel
context.WithoutCancel crea un contesto che non viene cancellato quando il genitore è cancellato.
Questo è utile, ma è anche facile da usare male.
Esempio di caso d’uso:
func Handler(audit *AuditLog) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Gestisci richiesta...
_ = ctx
auditCtx := context.WithoutCancel(ctx)
go func() {
ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
defer cancel()
_ = audit.Write(ctx, "request completed")
}()
}
}
L’idea è che la scrittura di audit potrebbe aver bisogno di continuare brevemente anche dopo che il contesto della richiesta è stato cancellato. Questo dovrebbe essere raro e deliberato — non usare WithoutCancel come modo per evitare di affrontare la cancellazione. Usalo solo quando il lavoro figlio deve genuinamente sopravvivere alla cancellazione del genitore, e aggiungi sempre un nuovo timeout: un contesto che ignora la cancellazione ma non porta nessuna scadenza può facilmente creare perdite di goroutine in background.
Valori del contesto fatti bene
I valori del contesto sono per dati con ambito di richiesta che attraversano i confini dell’API.
Buoni esempi:
- ID richiesta
- ID trace
- ID utente autenticato
- ID tenant
- locale
- principale di sicurezza
- metadati di correlazione
Cattivi esempi:
- connessione database
- logger come dipendenza nascosta
- flag di funzionalità per il flusso di controllo ordinario
- parametri di funzione opzionali
- configurazione
- client di servizio
Una regola utile: se il valore è parte dell’identità della richiesta o del contesto di osservabilità, può appartenere al contesto. Se è una dipendenza di cui il tuo codice ha bisogno per fare il suo lavoro, passala esplicitamente.
Usa chiavi tipizzate per i valori del contesto
Non usare stringhe semplici come chiavi del contesto.
Male:
ctx = context.WithValue(ctx, "userID", "123")
Questo può collidere con altri pacchetti.
Usa un tipo di chiave personalizzato non esportato:
type userIDKey struct{}
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDKey{}, userID)
}
func UserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(userIDKey{}).(string)
return userID, ok
}
Questo modello ti dà sicurezza di tipo al confine del pacchetto, evita collisioni di chiavi con altri pacchetti e mantiene la superficie dell’API del contesto pulita con funzioni accessore tipizzate.
Non usare i valori del contesto per parametri opzionali
Questo è male:
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
Questo nasconde il contratto della funzione.
Preferisci parametri espliciti:
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
I valori del contesto non dovrebbero sostituire gli argomenti delle funzioni. L’input nascosto rende il codice più difficile da capire, testare e revisionare — e chiunque legga la firma della funzione non avrà idea che il parametro esista.
Logging e contesto
Ci sono due approcci comuni al logging con il contesto. Gli esempi qui usano il pacchetto log/slog di Go — per un’analisi più approfondita del logging strutturato con slog nei servizi di produzione, vedi Structured Logging in Go with slog.
Approccio 1: Estrai valori e allegali ai log
func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
if requestID, ok := RequestIDFromContext(ctx); ok {
logger = logger.With("request_id", requestID)
}
logger.Info(msg)
}
Questo mantiene il logger esplicito come una dipendenza appropriata e usa il contesto solo per valori con ambito di richiesta che legittimamente devono attraversare i confini dell’API.
Approccio 2: Memorizza il logger nel contesto
Alcuni codebase memorizzano un logger nel contesto.
Questo può essere conveniente, ma non lo raccomando come predefinito. Trasforma il contesto in un contenitore di dipendenze.
La mia preferenza:
- Passa le dipendenze del logger esplicitamente.
- Memorizza gli ID trace e gli ID richiesta nel contesto.
- Aggiungi quei valori ai log ai confini o nel middleware.
Questo mantiene le dipendenze visibili.
Contesto e tracing
Il tracing è uno dei casi d’uso più forti per i valori del contesto, ed è un adattamento genuinamente buono. OpenTelemetry e sistemi simili usano il contesto per propagare span di trace attraverso chiamate di funzione e confini di processo, perché i dati di trace sono esattamente il tipo di metadati con ambito di richiesta per cui il contesto è stato progettato.
Un modello tipico assomiglia a:
func (s *Service) DoWork(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "Service.DoWork")
defer span.End()
return s.repo.Query(ctx)
}
Il contesto porta lo span di trace attivo, e il repository può creare uno span figlio da esso. Ogni livello aggiunge il proprio span senza passaggio esplicito di oggetti tracer — il contesto fa quel lavoro in modo trasparente attraverso l’intero albero di chiamate.
Gestione degli errori con il contesto
Quando un’operazione si ferma a causa della cancellazione del contesto, preservare quell’informazione. I modelli qui completano le strategie di progettazione degli errori più ampie coperte in Go Error Handling Architecture.
Esempio:
err := svc.DoWork(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Il cliente ha cancellato o il chiamante ha fermato il lavoro.
return err
}
if errors.Is(err, context.DeadlineExceeded) {
// Timeout.
return err
}
return err
}
Non avvolgere ciecamente gli errori del contesto in modo che li nasconda.
L’avvolgimento con %w preserva errors.Is, quindi i chiamanti possono ancora rilevare la cancellazione o il timeout:
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Sostituire l’errore entirely scarta quell’informazione e rompe qualsiasi chiamante che controlla per tipi specifici di errori del contesto:
if err != nil {
return errors.New("query user failed")
}
Mappatura degli errori del contesto alle risposte HTTP
Gli errori del contesto spesso si mappano a risultati HTTP diversi.
Esempio:
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled):
// Il cliente è probabilmente andato via.
// Alcuni sistemi registrano questo come richiesta chiusa dal cliente.
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
Non trattare la cancellazione del cliente come un fallimento dell’applicazione — se l’utente ha chiuso la scheda del browser, non è il tuo servizio a comportarsi male, e registrarla come errore aggiunge rumore senza segnale.
Contesto nel middleware
Il middleware HTTP è un luogo comune per aggiungere valori con ambito di richiesta.
Esempio di middleware ID richiesta:
type requestIDKey struct{}
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, requestIDKey{}, requestID)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
requestID, ok := ctx.Value(requestIDKey{}).(string)
return requestID, ok
}
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newRequestID()
}
ctx := WithRequestID(r.Context(), requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Questo è un buon uso del contesto. L’ID richiesta appartiene alla richiesta, dovrebbe viaggiare attraverso l’intera catena di chiamate, e allegarlo ai log e alle trace a ogni livello è esattamente il tipo di preoccupazione di osservabilità trasversale che i valori del contesto sono progettati per supportare.
Contesto nei test
Nei test, evita di usare context.Background() in modo cieco.
Preferisci t.Context() quando il lavoro appartiene alla durata del test:
func TestService(t *testing.T) {
ctx := t.Context()
err := service.DoWork(ctx)
if err != nil {
t.Fatal(err)
}
}
Per il comportamento di timeout, testa con un timeout reale solo se il timeout è piccolo e significativo.
Per il codice concorrente e dipendente dal tempo, considera l’uso di testing/synctest — Testing Concurrent Go Code with synctest copre questo strumento in profondità:
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
time.Sleep(30 * time.Second)
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("got %v, want deadline exceeded", ctx.Err())
}
})
}
Questo ti permette di testare valori di timeout reali senza aspettare il tempo reale.
Contesto e errgroup
Per gruppi di goroutine che dovrebbero cancellarsi insieme, errgroup è spesso un buon adattamento.
Esempio:
func FetchAll(ctx context.Context, ids []string, client *Client) error {
g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
id := id
g.Go(func() error {
_, err := client.Fetch(ctx, id)
return err
})
}
return g.Wait()
}
Se una goroutine restituisce un errore, il contesto del gruppo viene cancellato e altre goroutine che rispettano ctx.Done() possono fermarsi precocemente. Questo è molto più pulito che gestire manualmente più goroutine, canali e percorsi di cancellazione. La frase chiave qui è “rispetta il contesto” — errgroup non può fermare il lavoro che ignora ctx.Done().
Arresto grazioso
Il contesto è centrale per l’arresto grazioso.
Una configurazione tipica del server ha:
- un contesto radice cancellato dai segnali OS
- un server HTTP
- worker in background
- un timeout di arresto
- logica di pulizia
Esempio:
func main() {
root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
server := &http.Server{
Addr: ":8080",
Handler: routes(),
}
go func() {
<-root.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
slog.Error("server shutdown failed", "err", err)
}
}()
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}
Nota che il contesto di arresto non è lo stesso del contesto radice — il radice è già cancellato quando arriva il segnale OS. Un contesto di timeout separato dà al processo di arresto una quantità di tempo delimitata per drenare le richieste in corso prima di forzare l’uscita, che è la distinzione sottile ma importante che rende l’arresto grazioso funzionare davvero.
Anti-pattern comuni
Anti-pattern 1: Usare il contesto come contenitore di dipendenze
Male:
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)
Passa le dipendenze esplicitamente.
Anti-pattern 2: Creare context.Background all’interno della logica di business
Male:
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Save(context.Background())
}
Questo rompe la propagazione della cancellazione.
Anti-pattern 3: Dimenticare cancel
Male:
ctx, _ := context.WithTimeout(parent, time.Second)
Buono:
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Anti-pattern 4: Mettere parametri opzionali nel contesto
Male:
ctx = context.WithValue(ctx, "includeDeleted", true)
Usa struct di opzioni esplicite.
Anti-pattern 5: Passare il contesto troppo profondo nel codice puro
Male:
func Add(ctx context.Context, a, b int) int {
return a + b
}
Il calcolo puro non ha bisogno del contesto a meno che non sia lungo o cancellabile.
Anti-pattern 6: Ignorare la cancellazione nei cicli
Male:
for item := range items {
process(item)
}
Meglio:
for item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := process(ctx, item); err != nil {
return err
}
}
Anti-pattern 7: Inghiottire gli errori del contesto
Male:
if err != nil {
return errors.New("operation failed")
}
Buono:
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
Preserva gli errori di cancellazione e scadenza.
Una checklist pratica del contesto
Usa questa checklist per il codice backend Go.
Firme delle funzioni
- Il contesto è il primo parametro.
- Il contesto non è memorizzato in struct a lunga durata.
- Il contesto non è passato a funzioni helper pure a meno che non sia necessario.
- Il contesto nil non è mai usato.
Cancellazione
- I cicli lunghi controllano
ctx.Done(). - Le goroutine hanno un percorso di arresto.
- Le durate dei worker sono legate a un contesto genitore.
- La cancellazione del contesto è propagata alle chiamate downstream.
Timeout
- I timeout delle richieste esterne sono impostati al confine.
- I timeout delle sotto-operazioni sono più piccoli del budget esterno.
- Le funzioni cancel sono sempre chiamate.
- I timeout non sono impilati ciecamente a ogni livello.
Valori
- I valori del contesto sono con ambito di richiesta.
- Le chiavi usano tipi personalizzati, non stringhe semplici.
- Le dipendenze non sono memorizzate nel contesto.
- I parametri opzionali non sono memorizzati nel contesto.
Errori
context.Canceledecontext.DeadlineExceededsono preservati.- Gli errori del contesto sono mappati correttamente ai confini dell’API.
- La cancellazione consapevole della causa è usata solo quando il motivo è importante.
Test
- I test usano
t.Context()dove appropriato. - I test di timeout evitano sleep reali lenti.
- Il comportamento di timeout concorrente è testato con
testing/synctestquando utile. - Le perdite di goroutine sono controllate assicurando che esistano percorsi di arresto.
Come auditare l’uso del contesto in un codebase Go
Cerca questi modelli:
grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .
Poi chiedi:
context.Background()è usato solo ai confini di livello superiore?- Le funzioni cancel sono sempre chiamate?
- I timeout sono posizionati a confini sensati?
- I valori del contesto sono davvero con ambito di richiesta?
- Le dipendenze sono nascoste nei valori del contesto?
- Le goroutine sono fermabili?
- Gli errori del contesto sono preservati?
Questa è una buona abitudine di code review, perché molti bug del contesto non sono bug di sintassi — sono bug di durata che si manifestano solo sotto cancellazione, carico o condizioni di arresto.
Le mie regole opiniate
Queste regole sono noiose, ma funzionano.
Regola 1: Il contesto è flusso di controllo
Usa il contesto per controllare la cancellazione, le scadenze e i metadati della richiesta.
Non usarlo per nascondere dipendenze.
Regola 2: Il chiamante possiede il budget
Una funzione dovrebbe solitamente rispettare il contesto che riceve.
Crea solo un timeout figlio più corto quando la sotto-operazione ha bisogno di un budget più piccolo specifico.
Regola 3: Background appartiene al confine
Usa context.Background() in main, test e setup di livello superiore.
Non usarlo all’interno dei metodi del servizio e del repository per sfuggire alla cancellazione.
Regola 4: I valori dovrebbero essere noiosi
ID richiesta, ID trace, ID utente e ID tenant appartengono al contesto. Connessioni database, logger, struct di configurazione e client di servizio non lo sono — sono dipendenze e dovrebbero essere passate esplicitamente.
Regola 5: Ogni goroutine ha bisogno di una durata
Se una goroutine inizia, dovresti sapere esattamente come si ferma. Il contesto è spesso la risposta giusta, e se non è il contesto, dovrebbe esserci qualche altro meccanismo chiaro — un canale, un primitivo sync o un segnale esplicito.
Pensieri finali
context.Context non è complicato perché l’API è grande — l’API è piccola. È complicato perché rappresenta la durata, e la durata è architettura. Ogni decisione su dove il contesto fluisce, dove è derivato e dove si ferma è una decisione su come il tuo servizio gestisce il fallimento, il carico e l’arresto.
Un contesto ben usato rende i servizi Go più facili da cancellare, più facili da arrestare, più facili da osservare e meno propensi a perdere goroutine. Un contesto usato male nasconde le dipendenze, scarta le scadenze e rende il codice più difficile da ragionare sotto pressione.
Il takeaway pratico è semplice:
Passa il contesto giù.
Non memorizzarlo.
Non sostituire i parametri espliciti con valori.
Rispetta la cancellazione.
Usa i timeout ai confini.
Chiama sempre cancel.
Questo è il contesto Go fatto bene.
Questo articolo fa parte del cluster App Architecture in Production, che copre struttura del codice, accesso ai dati, pattern di integrazione e architettura di test per sistemi Go e Python di produzione.