Utiliser context.Context de Go correctement : annulation, dépassements de délai et valeurs
Le contexte Go est un contrôle de flux, non un stockage.
Le context.Context de Go est suffisamment simple pour être utilisé à mauvais — et c’est là que se pose le problème.
La plupart des développeurs Go apprennent rapidement les règles de surface : passer le context comme premier argument, vérifier ctx.Done(), utiliser context.WithTimeout et ne jamais passer nil.
func DoSomething(ctx context.Context) error {
// ...
}
Ces règles sont utiles, mais elles ne couvrent que la partie facile. Dans les services de production, le context n’est pas simplement une convention de paramètre — c’est le plan de contrôle de la durée de vie d’une requête.

Le contexte indique au travail quand s’arrêter, combien de temps il lui reste, quelle voie d’annulation a été empruntée et quelles valeurs associées à la requête doivent traverser les frontières des API. Bien utilisé, il prévient les fuites de goroutines, évite un travail inutile, propage les délais (deadlines) et rend les services plus faciles à arrêter. Mal utilisé, il devient un sac de dépendances cachées, de variables globales factices, d’horloges temporisées oubliées, de minuteries fuites et d’un comportement d’annulation confus.
La version légèrement opinionnée est la suivante : utilisez le contexte pour l’annulation, les délais et les métadonnées de portée requête, mais ne l’utilisez pas comme conteneur de dépendances.
À quoi sert le contexte
Le package context a trois rôles principaux — l’annulation, les délais et les temporisations (timeouts), et les valeurs de portée requête — et ces trois rôles couvrent tout ce pour quoi il a été conçu.
Un contexte devrait répondre à des questions comme :
Cette requête a-t-elle été annulée ?
Combien de temps cette opération a-t-elle encore ?
Quel identifiant de requête doit être joint aux journaux ?
Quel utilisateur authentifié est associé à cette requête ?
Un contexte ne devrait pas répondre à des questions comme :
Où est ma connexion base de données ?
Où est mon journaliseur (logger) ?
Où est ma configuration ?
Quelle implémentation de service dois-je utiliser ?
Ce sont des dépendances — passez-les explicitement via les paramètres de fonction (voir Injection de dépendances en Go pour des modèles permettant de le faire proprement). Le contexte est pour la durée de vie et les métadonnées de la requête, pas pour le câblage de l’application.
La forme de base du contexte
L’interface centrale est petite :
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Les parties importantes sont :
Done()est fermé lorsque le contexte est annulé ou lorsque son délai expiré.Err()explique pourquoi le contexte a pris fin.Deadline()vous indique si le contexte a un délai.Value()stocke des données de portée requête.
La plupart du code n’implémente pas cette interface. Il reçoit un contexte et le passe plus bas.
La première règle : passer le contexte explicitement
Pour les fonctions qui effectuent un travail de portée requête ou annulable, passez le contexte comme premier paramètre — c’est la convention standard de Go et c’est ce que chaque bibliothèque et outil de l’écosystème attend :
func GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Faites cela pour les fonctions qui peuvent :
- Appeler une base de données
- Appeler un autre service
- Attendre sur une file d’attente
- Démarrer un travail en arrière-plan
- Bloquer sur une E/S
- Utiliser une temporisation
- Avoir besoin de valeurs de portée requête
- Avoir besoin d’annulation
N’ajoutez pas de contexte à de petites fonctions pures qui n’en ont pas besoin.
Ceci est correct :
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Toutes les fonctions n’ont pas besoin d’un contexte. Ajouter un contexte partout rend le code bruité.
Ne pas stocker le contexte dans des structures
Stocker un contexte dans une structure est l’une des erreurs les plus courantes dans les bases de code Go, et cela vaut la peine d’être souligné explicitement. Ne faites pas cela :
type UserService struct {
ctx context.Context
db *sql.DB
}
Faites ceci à la place :
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// ...
}
Un contexte appartient à une requête, une opération ou une tâche, tandis qu’une structure de service vit généralement beaucoup plus longtemps que n’importe quelle requête unique. Mélanger ces durées de vie rend l’annulation peu claire et rend difficile le raisonnement sur l’opération à laquelle un contexte appartient.
Il existe de rares exceptions pour les types qui représentent véritablement une durée de vie unique, mais elles sont assez rares pour que la règle par défaut devrait être simple :
Passez le contexte. Ne le stockez pas.
Ne pas passer un contexte nil
Ne passez jamais nil comme contexte.
Mauvais :
err := svc.DoWork(nil)
Utilisez context.Background() lorsqu’il n’y a pas de contexte existant :
err := svc.DoWork(context.Background())
Dans les tests, utilisez le contexte de test lorsque c’est possible :
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Un contexte nil peut provoquer un panic lorsque le code appelle des méthodes dessus. Un contexte d’arrière-plan est explicite et sûr.
Contextes d’arrière-plan, TODO et de requête
Il existe trois points de départ courants.
context.Background
Utilisez context.Background() au niveau supérieur d’un programme lorsqu’aucun contexte parent n’existe — c’est le contexte racine à partir duquel tous les contextes enfants sont dérivés :
func main() {
ctx := context.Background()
_ = run(ctx)
}
ou :
func TestSomething(t *testing.T) {
ctx := context.Background()
_ = ctx
}
context.TODO
Utilisez context.TODO() lorsque vous savez qu’un contexte doit être utilisé mais que vous n’avez pas encore décidé lequel.
ctx := context.TODO()
C’est utile lors des migrations, mais cela ne devrait pas devenir permanent si un contexte réel existe.
Contexte de requête
Dans les serveurs HTTP, utilisez le contexte de la requête :
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_ = ctx
}
Le contexte de la requête est annulé lorsque la connexion du client se ferme, que la requête est annulée ou que le serveur termine le traitement de la requête.
Pour les services Web, c’est généralement le contexte que vous devez passer au code applicatif.
Annulation avec context.WithCancel
Utilisez context.WithCancel lorsque vous voulez arrêter explicitement un travail.
ctx, cancel := context.WithCancel(parent)
defer cancel()
La fonction cancel retournée annule le contexte enfant et libère les ressources associées. Appelez-la toujours lorsque vous avez terminé — même si le contexte finira par expirer, appeler cancel tôt évite de maintenir les ressources actives plus longtemps que nécessaire.
Exemple :
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
}
}
Le modèle est simple :
- Dérivez un contexte enfant.
- Différez le cancel.
- Passez le contexte enfant au travail qui devrait s’arrêter ensemble.
- Surveillez
ctx.Done().
Temporisations avec context.WithTimeout
Utilisez context.WithTimeout lorsqu’une opération a une durée maximale.
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Exemple avec 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)
}
Cela rend la temporisation partie intégrante de l’opération, et non d’un réglage global caché.
Toujours appeler cancel
Lorsque vous appelez WithCancel, WithTimeout ou WithDeadline, appelez toujours la fonction cancel retournée — cela compte pour la correction.
Bon :
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Mauvais :
ctx, _ := context.WithTimeout(parent, 5*time.Second)
L’oubli d’appeler cancel peut maintenir les minuteries et les contextes enfants actifs plus longtemps que nécessaire.
Les canaux de minuterie eux-mêmes font également partie du contrat : depuis Go 1.27, les canaux derrière time.After et time.NewTimer sont toujours sans tampon, et l’ancienne option de désactivation asynctimerchan ne compile plus plus avec sa valeur antérieure à 1.23. Le Guide de mise à niveau Go 1.27 liste ce paramètre aux côtés des autres valeurs GODEBUG supprimées qui font échouer la compilation.
Délais absolus (Deadlines) vs temporisations relatives
Une temporisation est relative :
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Un délai absolu est :
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
La plupart du code applicatif utilise des temporisations. Les délais absolus sont utiles lorsqu’une requête a une heure de fin fixe qui doit être partagée entre plusieurs opérations — par exemple, si une requête a 900 millisecondes restantes, ne donnez pas à chaque appel en aval une nouvelle temporisation d’une seconde ; propagez plutôt le budget restant.
Budgets de temporisation à travers les couches de service
Une erreur courante est d’empiler aveuglément les temporisations.
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)
}
Cela semble inoffensif, mais cela masque le budget réel. La couche de service devrait généralement respecter le délai du叫appelant plutôt que de réinitialiser la minuterie à la même valeur.
Un meilleur modèle est :
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 {
// gèrer l'erreur
return
}
}
Ensuite, à l’intérieur du service :
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Query(ctx)
}
Ajoutez une temporisation enfant uniquement lorsqu’une sous-opération a besoin d’un budget plus petit :
func (s *Service) DoWork(ctx context.Context) error {
queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
return s.repo.Query(queryCtx)
}
Le bon modèle mental est direct : la requête entière a un budget extérieur unique, les sous-opérations spécifiques peuvent avoir des budgets plus petits prélevés sur ce budget, et aucune couche n’étend silencieusement la requête au-delà de ce que l’appelant a prévu.
Vérifier ctx.Err() pour distinguer l’annulation de la temporisation
Lorsqu’un contexte prend fin, ctx.Err() retourne la raison.
Généralement, c’est l’un des deux suivants :
context.Canceled
context.DeadlineExceeded
Exemple :
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return handle(result)
}
Cela permet aux appelants de distinguer l’annulation de la temporisation, et cette distinction a de l’importance dans la pratique. Une requête annulée signifie souvent que le client s’est déconnecté, tandis qu’une erreur de délai dépassé signifie généralement que votre service était trop lent — elles ne devraient pas toujours être journalisées, réessayées ou rapportées de la même manière.
Utiliser context.Cause pour de meilleures raisons d’annulation
Go moderne prend également en charge l’annulation prise en charge par la cause.
Les fonctions utiles incluent :
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
Un simple ctx.Err() vous indique la raison générale : annulé ou délai dépassé.
context.Cause(ctx) peut vous indiquer la cause plus spécifique.
Exemple :
var ErrShutdown = errors.New("server shutting down")
func Run(ctx context.Context) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
go func() {
// Un signal d'arrêt est arrivé.
cancel(ErrShutdown)
}()
<-ctx.Done()
return context.Cause(ctx)
}
Utilisez l’annulation par cause lorsque la raison importe aux appelants, aux journaux ou au comportement de nettoyage, et évitez-la là où un simple ctx.Err() suffit — les détails supplémentaires ne valent la peine que lorsque le diagnostic en a réellement besoin.
Exemple de serveur HTTP
Un gestionnaire HTTP normal devrait commencer par r.Context(). Pour un tutoriel complet sur la structuration des services HTTP en Go, voir Construire des API REST en Go. Gin, Echo et Fiber encapsulent chacun context.Context à l’intérieur de leur propre type de contexte de requête, et les différences entre les piles sont comparées dans Frameworks Web Go en 2026 : net/http, chi, Gin, Echo, Fiber comparés.
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)
}
}
Le service devrait accepter et propager le contexte :
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
Le dépôt (repository) devrait utiliser des méthodes de base de données prises en charge par le contexte :
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
}
L’élément important est la chaîne — chaque couche passe le même contexte à la suivante :
Ne brisez pas la chaîne en créant context.Background() au milieu.
L’erreur context.Background() : briser la chaîne d’annulation
C’est un bug courant :
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(context.Background(), id)
}
Cela supprime toutes les informations d’annulation et de délai de l’appelant. Si le client se déconnecte, la requête base de données continue de s’exécuter. Si la requête expiré, le travail en aval peut toujours être en cours. Si le serveur est en train de s’arrêter, ce code l’ignore complètement. Remplacer le contexte reçu par context.Background() à l’intérieur de la logique métier est presque toujours une erreur.
Utilisez le contexte qui vous a été donné :
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
return s.repo.GetUser(ctx, id)
}
N’utilisez context.Background() qu’à la frontière où aucun contexte parent n’existe.
Exemple de client HTTP
Pour les requêtes HTTP sortantes, attachez le contexte à la requête.
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)
}
Ne faites pas ceci :
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
Cela crée une requête sans le contexte de l’opération.
Évitez également de ne vous fier qu’à http.Client.Timeout. Cela peut être utile comme limite de sécurité, mais les contextes de requête offrent une meilleure propagation à travers la chaîne d’appels.
Un modèle courant est :
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)
}
Utilisez cela lorsque l’appel API en aval a un budget spécifique dans une plus grande requête.
Exemple base de données
La plupart des API de base de données Go ont des méthodes prises en charge par le contexte. Pour un aperçu plus large de la façon dont les bibliothèques d’accès aux données Go gèrent le contexte — y compris GORM, Ent, Bun et sqlc — voir Comparaison des ORM Go pour PostgreSQL.
Utilisez-les.
Bon :
rows, err := db.QueryContext(ctx, query, args...)
Bon :
err := db.QueryRowContext(ctx, query, id).Scan(&name)
Bon :
result, err := db.ExecContext(ctx, query, args...)
Mauvais :
rows, err := db.Query(query, args...)
Les formes prises en charge par le contexte permettent aux opérations de base de données de s’arrêter lorsque la requête est annulée ou expirée, ce qui est particulièrement important pour les requêtes lentes, les bases de données surchargées et les API orientées utilisateur où la latence affecte directement l’expérience utilisateur.
Transactions et contexte
Les transactions nécessitent un traitement prudent du contexte.
Une transaction devrait généralement commencer avec le contexte de l’opération :
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
Puis utilisez le même contexte pour les opérations de transaction :
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
Soyez prudent avec les temporisations autour des transactions. Si le contexte est annulé avant Commit, la transaction peut être annulée. Ce peut être ce que vous voulez, mais cela devrait être intentionnel.
Pour les transactions longues, la meilleure réponse est généralement une temporisation plus longue — c’est une transaction plus courte qui fait moins de travail par unité.
Travailleurs en arrière-plan et contexte
Les travailleurs en arrière-plan (background workers) devraient recevoir un contexte qui représente leur durée de vie.
Exemple :
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)
}
}
}
}
Ce travailleur s’arrête proprement lorsque le contexte est annulé, et sa minuterie est correctement nettoyée via defer ticker.Stop(). Dans main, vous créeriez un contexte racine lié aux signaux du système d’exploitation :
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)
}
}
C’est le contexte utilisé correctement : il décrit la durée de vie du travail du processus, et lorsque le système d’exploitation envoie un signal, tout l’arbre de goroutines partageant ce contexte s’arrêtera ensemble.
Prévention des fuites de goroutines par annulation de contexte
Une fuite de goroutine se produit lorsqu’une goroutine reste bloquée indéfiniment après qu’elle n’est plus utile.
Le contexte aide à prévenir cela.
Mauvais :
func StartWorker() {
go func() {
for {
doWork()
time.Sleep(time.Second)
}
}()
}
Cette goroutine n’a pas de voie d’arrêt.
Mieux :
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()
}
}
}()
}
Toute goroutine qui boucle devrait presque toujours avoir une voie d’annulation.
Cela ne signifie pas que chaque goroutine doit recevoir directement le contexte, mais le système doit avoir un moyen clair de l’arrêter.
context.AfterFunc
context.AfterFunc exécute une fonction après qu’un contexte a été annulé.
Cela peut être utile pour le nettoyage, le déblocage d’opérations, ou le pontage d’API qui ne prennent pas nativement en charge le contexte.
Exemple :
func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
stop := context.AfterFunc(ctx, func() {
// Se réveiller ou nettoyer si nécessaire.
})
defer stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-ch:
return nil
}
}
Utilisez AfterFunc avec prudence — il démarre la logique lorsque l’annulation se produit, ce qui peut rendre le flux de contrôle plus difficile à suivre. Pour la plupart du code applicatif, un simple select sur ctx.Done() est plus clair et plus facile à raisonner. AfterFunc est le plus précieux lorsque vous devez adapter l’annulation du contexte à une API qui n’accepte pas déjà de contexte.
context.WithoutCancel
context.WithoutCancel crée un contexte qui n’est pas annulé lorsque le parent est annulé.
C’est utile, mais c’est aussi facile à mal utiliser.
Exemple d’utilisation :
func Handler(audit *AuditLog) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Gérer la requête...
_ = ctx
auditCtx := context.WithoutCancel(ctx)
go func() {
ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
defer cancel()
_ = audit.Write(ctx, "request completed")
}()
}
}
L’idée est que l’écriture de l’audit peut avoir besoin de continuer brièvement même après que le contexte de la requête a été annulé. Cela devrait être rare et délibéré — n’utilisez pas WithoutCancel comme un moyen d’éviter de gérer l’annulation. Utilisez-le uniquement lorsque le travail enfant doit véritablement survivre à l’annulation du parent, et ajoutez toujours une nouvelle temporisation : un contexte qui ignore l’annulation mais ne porte aucun délai peut facilement créer des fuites de goroutines en arrière-plan.
Les valeurs de contexte faites correctement
Les valeurs de contexte sont pour les données de portée requête qui traversent les frontières des API.
Bonnes exemples :
- ID de requête
- ID de traçage
- ID de l’utilisateur authentifié
- ID de locataire (tenant)
- Locale
- Principal de sécurité
- Métadonnées de corrélation
Mauvais exemples :
- Connexion base de données
- Journaliseur comme dépendance cachée
- Drapeaux de fonctionnalités (feature flags) pour un flux de contrôle ordinaire
- Paramètres de fonction optionnels
- Configuration
- Clients de service
Une règle utile : si la valeur fait partie de l’identité de la requête ou du contexte d’observabilité, elle peut appartenir au contexte. Si c’est une dépendance dont votre code a besoin pour faire son travail, passez-la explicitement.
Utiliser des clés typées pour les valeurs de contexte
N’utilisez pas de chaînes simples comme clés de contexte.
Mauvais :
ctx = context.WithValue(ctx, "userID", "123")
Cela peut entrer en collision avec d’autres packages.
Utilisez un type de clé personnalisé non exporté :
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
}
Ce modèle vous donne une sécurité de type à la frontière du package, évite les collisions de clés avec d’autres packages et maintient la surface de l’API du contexte propre avec des fonctions d’accès typées.
Ne pas utiliser les valeurs de contexte pour les paramètres optionnels
Ceci est mauvais :
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
Cela masque le contrat de la fonction.
Préférez des paramètres explicites :
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
Les valeurs de contexte ne devraient pas remplacer les arguments de fonction. Une entrée cachée rend le code plus difficile à comprendre, à tester et à examiner — et toute personne lisant la signature de la fonction n’aura aucune idée que le paramètre existe même.
Journalisation et contexte
Il existe deux approches courantes pour la journalisation avec le contexte. Les exemples ici utilisent le package log/slog de Go — pour une plongée plus profonde dans la journalisation structurée avec slog dans les services de production, voir Journalisation structurée en Go avec slog.
Approche 1 : Extraire les valeurs et les joindre aux journaux
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)
}
Cela maintient le journaliseur explicite comme une véritable dépendance et n’utilise le contexte que pour les valeurs de portée requête qui ont légitimement besoin de traverser les frontières des API.
Approche 2 : Stocker le journaliseur dans le contexte
Certaines bases de code stockent un journaliseur dans le contexte.
Cela peut être pratique, mais je ne le recommande pas comme défaut. Cela transforme le contexte en conteneur de dépendances.
Ma préférence :
- Passer les dépendances de journalisation explicitement.
- Stocker les ID de traçage et les ID de requête dans le contexte.
- Ajouter ces valeurs aux journaux aux frontières ou dans le middleware.
Cela maintient les dépendances visibles.
Contexte et traçage
Le traçage est l’un des plus forts cas d’utilisation pour les valeurs de contexte, et c’est un vrai bon ajustement. OpenTelemetry et des systèmes similaires utilisent le contexte pour propager les spans de traçage à travers les appels de fonctions et les frontières de processus, parce que les données de trace sont exactement le type de métadonnées de portée requête pour lequel le contexte a été conçu pour porter.
Un modèle typique ressemble à :
func (s *Service) DoWork(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "Service.DoWork")
defer span.End()
return s.repo.Query(ctx)
}
Le contexte porte la span de traçage active, et le dépôt peut créer une span enfant à partir de celle-ci. Chaque couche ajoute sa propre span sans aucun passage explicite d’objets tracer — le contexte fait ce travail en toute transparence à travers l’arbre d’appels entier.
Gestion des erreurs avec le contexte
Lorsqu’une opération s’arrête à cause de l’annulation du contexte, préservez cette information. Les modèles ici complètent les stratégies de conception d’erreurs plus larges couvertes dans Architecture de gestion des erreurs Go.
Exemple :
err := svc.DoWork(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// Le client a annulé ou l'appelant a arrêté le travail.
return err
}
if errors.Is(err, context.DeadlineExceeded) {
// Temporisation dépassée.
return err
}
return err
}
Ne masquez pas aveuglément les erreurs de contexte d’une manière qui les cache.
L’encapsulation avec %w préserve errors.Is, de sorte que les appelants peuvent toujours détecter l’annulation ou la temporisation :
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Remplacer entièrement l’erreur supprime cette information et casse tout appelant qui vérifie des types d’erreur de contexte spécifiques :
if err != nil {
return errors.New("query user failed")
}
Cartographie des erreurs de contexte vers les réponses HTTP
Les erreurs de contexte se traduisent souvent par des résultats HTTP différents.
Exemple :
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled) :
// Le client a probablement disparu.
// Certains systèmes journalisent cela comme une fermeture de requête par le client.
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
}
}
Ne traitez pas l’annulation par le client comme un échec de l’application — si l’utilisateur a fermé l’onglet du navigateur, ce n’est pas votre service qui se comporte mal, et le journaliser comme une erreur ajoute du bruit sans signal.
Contexte dans le middleware
Le middleware HTTP est un endroit courant pour ajouter des valeurs de portée requête.
Exemple de middleware d’ID de requête :
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))
})
}
C’est une bonne utilisation du contexte. L’ID de requête appartient à la requête, il devrait traverser toute la chaîne d’appels, et l’attacher aux journaux et aux traces à chaque couche est exactement le type de préoccupation transversale d’observabilité que les valeurs de contexte sont conçues pour soutenir.
Contexte dans les tests
Dans les tests, évitez d’utiliser context.Background() aveuglément.
Préférez t.Context() lorsque le travail appartient à la durée de vie du test :
func TestService(t *testing.T) {
ctx := t.Context()
err := service.DoWork(ctx)
if err != nil {
t.Fatal(err)
}
}
Pour le comportement de temporisation, testez avec une vraie temporisation seulement si la temporisation est petite et significative.
Pour le code concurrent et dépendant du temps, envisagez d’utiliser testing/synctest — Tester le code Go concurrent avec synctest couvre cet outil en profondeur :
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())
}
})
}
Cela vous permet de tester de vraies valeurs de temporisation sans attendre le temps réel.
Contexte et errgroup
Pour les groupes de goroutines qui devraient s’annuler ensemble, errgroup est souvent un bon ajustement.
errgroup coordonne l’annulation, mais il ne met pas de limites par lui-même : sans limite, une boucle d’un millier d’éléments lance un millier de goroutines. Pour le côté capacité — SetLimit, les canaux de sémaphore, les pools de travailleurs, et la forme de fuite qui survit à Wait — voir Bonder la concurrence en Go.
Exemple :
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()
}
Si une goroutine retourne une erreur, le contexte du groupe est annulé et d’autres goroutines qui respectent ctx.Done() peuvent s’arrêter tôt. C’est beaucoup plus propre que de gérer manuellement plusieurs goroutines, canaux et voies d’annulation. L’expression clé ici est “respecter le contexte” — errgroup ne peut pas arrêter un travail qui ignore ctx.Done().
Arrêt progressif (Graceful shutdown)
Le contexte est central pour l’arrêt progressif.
Une configuration de serveur typique a :
- un contexte racine annulé par les signaux du système d’exploitation
- un serveur HTTP
- des travailleurs en arrière-plan
- une temporisation d’arrêt
- une logique de nettoyage
Exemple :
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)
}
}
Remarquez que le contexte d’arrêt n’est pas le même que le contexte racine — la racine est déjà annulée lorsque le signal du système d’exploitation arrive. Un contexte de temporisation séparé donne au processus d’arrêt une quantité de temps bornée pour drainer les requêtes en cours avant de forcer l’arrêt, ce qui est la distinction subtile mais importante qui rend l’arrêt progressif réellement fonctionnel.
Anti-modèles courants
Anti-modèle 1 : Utiliser le contexte comme conteneur de dépendances
Mauvais :
ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)
Passez les dépendances explicitement.
Anti-modèle 2 : Créer context.Background à l’intérieur de la logique métier
Mauvais :
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Save(context.Background())
}
Cela brise la propagation de l’annulation.
Anti-modèle 3 : Oublier cancel
Mauvais :
ctx, _ := context.WithTimeout(parent, time.Second)
Bon :
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Anti-modèle 4 : Mettre des paramètres optionnels dans le contexte
Mauvais :
ctx = context.WithValue(ctx, "includeDeleted", true)
Utilisez des structures d’options explicites.
Anti-modèle 5 : Passer le contexte trop profond dans du code pur
Mauvais :
func Add(ctx context.Context, a, b int) int {
return a + b
}
Le calcul pur n’a pas besoin de contexte à moins qu’il soit long ou annulable.
Anti-modèle 6 : Ignorer l’annulation dans les boucles
Mauvais :
for item := range items {
process(item)
}
Mieux :
for item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := process(ctx, item); err != nil {
return err
}
}
Anti-modèle 7 : Avaler les erreurs de contexte
Mauvais :
if err != nil {
return errors.New("operation failed")
}
Bon :
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
Préservez les erreurs d’annulation et de délai.
Une liste de contrôle pratique du contexte
Utilisez cette liste de contrôle pour le code backend Go.
Signatures de fonction
- Le contexte est le premier paramètre.
- Le contexte n’est pas stocké dans des structures à longue durée de vie.
- Le contexte n’est pas passé aux fonctions d’aide pures à moins que cela ne soit nécessaire.
- Le contexte nil n’est jamais utilisé.
Annulation
- Les boucles longues vérifient
ctx.Done(). - Les goroutines ont une voie d’arrêt.
- Les durées de vie des travailleurs sont liées à un contexte parent.
- L’annulation du contexte est propagée aux appels en aval.
Temporisations
- Les temporisations de requête externe sont définies à la frontière.
- Les temporisations de sous-opération sont plus petites que le budget externe.
- Les fonctions cancel sont toujours appelées.
- Les temporisations ne sont pas empilées aveuglément à chaque couche.
Valeurs
- Les valeurs de contexte sont de portée requête.
- Les clés utilisent des types personnalisés, pas de chaînes simples.
- Les dépendances ne sont pas stockées dans le contexte.
- Les paramètres optionnels ne sont pas stockés dans le contexte.
Erreurs
context.Canceledetcontext.DeadlineExceededsont préservés.- Les erreurs de contexte sont correctement mappées aux frontières des API.
- L’annulation par cause est utilisée uniquement lorsque la raison importe.
Tests
- Les tests utilisent
t.Context()là où c’est approprié. - Les tests de temporisation évitent les vrais sommeils lents.
- Le comportement de temporisation concurrent est testé avec
testing/synctestlorsque c’est utile. - Les fuites de goroutines sont vérifiées en s’assurant que les voies d’arrêt existent.
Comment auditer l’utilisation du contexte dans une base de code Go
Cherchez ces modèles :
grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .
Puis demandez-vous :
context.Background()n’est-il utilisé qu’aux frontières de niveau supérieur ?- Les fonctions cancel sont-elles toujours appelées ?
- Les temporisations sont-elles placées aux frontières sensées ?
- Les valeurs de contexte sont-elles réellement de portée requête ?
- Les dépendances sont-elles cachées dans les valeurs de contexte ?
- Les goroutines sont-elles arrêtées ?
- Les erreurs de contexte sont-elles préservées ?
C’est une bonne habitude de revue de code, car de nombreux bugs de contexte ne sont pas des bugs de syntaxe — ce sont des bugs de durée de vie qui ne se manifestent que sous annulation, charge ou conditions d’arrêt.
Mes règles opinionnées
Ces règles sont ennuyeuses, mais elles fonctionnent.
Règle 1 : Le contexte est le flux de contrôle
Utilisez le contexte pour contrôler l’annulation, les délais et les métadonnées de la requête.
Ne l’utilisez pas pour contraindre des dépendances.
Règle 2 : L’appelant possède le budget
Une fonction devrait généralement respecter le contexte qu’elle reçoit.
Créez seulement une temporisation enfant plus courte lorsque la sous-opération a besoin d’un budget plus petit spécifique.
Règle 3 : Arrière-plan appartient à la frontière
Utilisez context.Background() dans main, les tests et la configuration de niveau supérieur.
Ne l’utilisez pas dans les méthodes de service et de dépôt pour échapper à l’annulation.
Règle 4 : Les valeurs devraient être ennuyeuses
L’ID de requête, l’ID de traçage, l’ID d’utilisateur et l’ID de locataire appartiennent au contexte. Les connexions base de données, les journaliseurs, les structures de configuration et les clients de service n’y appartiennent pas — ce sont des dépendances et devraient être passés explicitement.
Règle 5 : Chaque goroutine a besoin d’une durée de vie
Si une goroutine démarre, vous devriez savoir exactement comment elle s’arrête. Le contexte est souvent la bonne réponse, et si ce n’est pas le contexte, il devrait y avoir un autre mécanisme clair — un canal, un primitive de synchro, ou un signal explicite.
Pensées finales
context.Context n’est pas compliqué parce que l’API est grande — l’API est petite. C’est compliqué parce qu’il représente la durée de vie, et la durée de vie est de l’architecture. Chaque décision sur où le contexte coule, où il est dérivé, et où il s’arrête est une décision sur la façon dont votre service gère l’échec, la charge et l’arrêt.
Un contexte bien utilisé rend les services Go plus faciles à annuler, plus faciles à arrêter, plus faciles à observer, et moins susceptibles de fuir des goroutines. Un contexte mal utilisé cache des dépendances, supprime des délais, et rend le code plus difficile à raisonner sous pression.
Le point pratique à retenir est simple :
Passez le contexte vers le bas.
Ne le stockez pas.
Ne remplacez pas les paramètres explicites par des valeurs.
Respectez l'annulation.
Utilisez des temporisations aux frontières.
Appelez toujours cancel.
C’est le contexte Go fait correctement.
Cet article fait partie du cluster Architecture d’application en production, qui couvre la structure du code, l’accès aux données, les modèles d’intégration et l’architecture de test pour les systèmes Go et Python de production.