La gestion correcte de context.Context en Go : annulation, délais et valeurs
Le contexte Go est un mécanisme de contrôle de flux, pas un système de stockage.
Le context.Context de Go est suffisamment simple pour être mal utilisé — et c’est là que réside le problème.
La plupart des développeurs Go apprennent rapidement les règles de surface : passer le contexte 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 en production, le contexte n’est pas seulement une convention de paramètre — c’est le plan de contrôle pour la durée de vie de la requête.

Le contexte indique au travail quand s’arrêter, combien de temps il lui reste, quel chemin d’annulation a été pris et quelles valeurs portées à la requête doivent traverser les frontières des API. Bien utilisé, il empêche les fuites de goroutines, évite le travail inutile, propage les délais et rend les services plus faciles à arrêter. Mal utilisé, il devient un sac de dépendances cachées, de variables globales factices, de délais oubliés, de minuteries fuitées et d’un comportement d’annulation confus.
La version légèrement opinionnée est celle-ci : utilisez le contexte pour l’annulation, les délais et les métadonnées portées à la requête, et ne l’utilisez pas comme un conteneur de dépendances.
À quoi sert le contexte
Le package context a trois missions principales — l’annulation, les délais et les valeurs portées à la requête — et ces trois missions couvrent tout ce pour quoi il est conçu.
Un contexte devrait répondre à des questions comme :
Cette requête a-t-elle été annulée ?
Combien de temps reste-t-il à cette opération ?
Quel ID de requête doit être attaché aux logs ?
Quel utilisateur authentifié est associé à cette requête ?
Un contexte ne devrait pas répondre à des questions comme :
Où est ma connexion à la base de données ?
Où est mon 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 des fonctions (voir Injection de dépendances en Go pour des modèles pour le faire proprement). Le contexte est pour la durée de vie de la requête 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 de base 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 que son délai expire.Err()explique pourquoi le contexte s’est terminé.Deadline()vous indique si le contexte a un délai.Value()stocke les données portées à la requête.
La plupart du code n’implémente pas cette interface. Il reçoit un contexte et le passe en aval.
La première règle : passer le contexte explicitement
Pour les fonctions qui effectuent un travail porté à la requête ou annulable, passez le contexte comme premier paramètre — c’est la convention Go standard et 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 un délai d’attente
- Avoir besoin de valeurs portées à la requête
- Avoir besoin d’annulation
N’ajoutez pas de contexte aux petites fonctions pures qui n’en ont pas besoin.
Cela est acceptable :
func NormalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
Chaque fonction n’a pas besoin d’un contexte. Ajouter un contexte partout rend le code bruyant.
Ne pas stocker le contexte dans des structs
Stocker un contexte dans une struct est l’une des erreurs les plus courantes dans les bases de code Go, et il vaut la peine de le signaler explicitement. Ne faites pas ceci :
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 struct de service vit généralement beaucoup plus longtemps que n’importe quelle requête individuelle. Mélanger ces durées de vie rend l’annulation floue et rend difficile la raison pour laquelle un contexte appartient à une opération.
Il existe des exceptions rares pour les types qui représentent réellement une durée de vie d’opération unique, mais elles sont suffisamment rares pour que la règle par défaut soit simple :
Passez le contexte. Ne le stockez pas.
Ne pas passer nil comme contexte
Ne passez jamais nil comme contexte.
Mauvais :
err := svc.DoWork(nil)
Utilisez context.Background() lorsqu’il n’existe pas de contexte existant :
err := svc.DoWork(context.Background())
Dans les tests, utilisez le contexte de test lorsque possible :
func TestDoWork(t *testing.T) {
err := svc.DoWork(t.Context())
if err != nil {
t.Fatal(err)
}
}
Un contexte nil peut paniquer lorsque le code appelle des méthodes dessus. Un contexte d’arrière-plan est explicite et sûr.
Contextes Background, TODO et de requête
Il existe trois points de départ communs.
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 n’avez pas encore décidé lequel.
ctx := context.TODO()
Ceci est utile pendant la migration, 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 client se ferme, la requête est annulée ou 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 d’application.
Annulation avec context.WithCancel
Utilisez context.WithCancel lorsque vous souhaitez arrêter le travail explicitement.
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 garder les ressources en vie 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érive un contexte enfant.
- Ajoute cancel en defer.
- Passe le contexte enfant au travail qui devrait s’arrêter ensemble.
- Surveillez
ctx.Done().
Délais d’attente 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 le délai d’attente partie de l’opération, et non un paramètre global caché.
Toujours appeler cancel
Lorsque vous appelez WithCancel, WithTimeout ou WithDeadline, appelez toujours la fonction cancel retournée — cela est important pour la correction.
Bon :
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
Mauvais :
ctx, _ := context.WithTimeout(parent, 5*time.Second)
L’échec à appeler cancel peut garder les minuteries et les contextes enfants en vie plus longtemps que nécessaire.
Délais absolus vs délais relatifs
Un délai relatif (timeout) est :
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
Un délai absolu (deadline) est :
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()
La plupart du code d’application utilise des délais relatifs. 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 un délai d’attente frais de 1 seconde ; propagez plutôt le budget restant.
Budgets de délai d’attente à travers les couches de service
Une erreur courante est d’empiler les délais d’attente aveuglément.
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 cache le véritable budget. La couche de service devrait généralement respecter le délai de l’appelant au lieu 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
}
}
Puis à l’intérieur du service :
func (s *Service) DoWork(ctx context.Context) error {
return s.repo.Query(ctx)
}
Ajoutez un délai 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 modèle mental correct est simple : l’ensemble de la requête a un budget extérieur, les sous-opérations spécifiques peuvent avoir des budgets plus petits découpés de ce budget, et aucune couche n’étend silencieusement la requête au-delà de ce que l’appelant a prévu.
Vérifiez ctx.Err() pour distinguer l’annulation du délai d’attente
Lorsqu’un contexte se termine, ctx.Err() retourne la raison.
Habituellement, c’est l’un des 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 du délai d’attente, et cette distinction est importante en 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 — ils ne devraient pas toujours être journalisés, réessayés ou rapportés de la même manière.
Utilisez context.Cause pour de meilleures raisons d’annulation
Le Go moderne prend également en charge l’annulation consciente de la cause.
Les fonctions utiles incluent :
context.WithCancelCausecontext.WithTimeoutCausecontext.WithDeadlineCausecontext.Cause
Le ctx.Err() simple vous indique la raison large : 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 a été reçu.
cancel(ErrShutdown)
}()
<-ctx.Done()
return context.Cause(ctx)
}
Utilisez l’annulation consciente de la cause lorsque la raison est importante pour les appelants, les logs ou le comportement de nettoyage, et évitez-la lorsque ctx.Err() simple est suffisant — le détail supplémentaire n’en vaut la peine que lorsque le diagnostic le nécessite réellement.
Exemple de serveur HTTP
Un gestionnaire HTTP normal devrait commencer avec r.Context(). Pour un guide complet de la structuration des services HTTP Go, voir Construction d’APIs REST en 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)
}
}
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 devrait utiliser des méthodes de base de données conscientes du 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’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 jette toutes les informations d’annulation et de délai de l’appelant. Si le client se déconnecte, la requête de 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 s’arrête, 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 incorrect.
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 vous fier uniquement à http.Client.Timeout. Cela peut être utile comme limite de sécurité, mais les contextes de requête vous donnent 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 ceci lorsque l’appel API en aval a un budget spécifique à l’intérieur d’une requête plus grande.
Exemple de base de données
La plupart des APIs de base de données Go ont des méthodes conscientes du 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 conscientes du contexte permettent aux opérations de base de données de s’arrêter lorsque la requête est annulée ou expiré, ce qui est particulièrement important pour les requêtes lentes, les bases de données surchargées et les APIs orientées utilisateur où la latence affecte directement l’expérience utilisateur.
Transactions et contexte
Les transactions nécessitent une gestion contextuelle soignée.
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 délais d’attente autour des transactions. Si le contexte est annulé avant Commit, la transaction peut être annulée. Cela peut être ce que vous voulez, mais cela devrait être intentionnel.
Pour les longues transactions, la meilleure réponse est généralement pas un délai d’attente plus long — c’est une transaction plus courte qui fait moins de travail par unité.
Workers en arrière-plan et contexte
Les workers en arrière-plan 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 worker s’arrête proprement lorsque le contexte est annulé, et son ticker est correctement nettoyé via defer ticker.Stop(). Dans main, vous créeriez un contexte racine lié aux signaux 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)
}
}
C’est le contexte utilisé correctement : il décrit la durée de vie du travail du processus, et lorsque l’OS envoie un signal, l’ensemble de l’arbre de goroutines qui partagent ce contexte s’arrêtera ensemble.
Prévention des fuites de goroutines avec l’annulation du contexte
Une fuite de goroutine se produit lorsqu’une goroutine reste bloquée à jamais 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 chemin 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 un chemin d’annulation.
Cela ne signifie pas que chaque goroutine doit recevoir le contexte directement, mais le système devrait avoir un moyen clair de l’arrêter.
context.AfterFunc
context.AfterFunc exécute une fonction après qu’un contexte est annulé.
Il peut être utile pour le nettoyage, le déblocage des opérations ou la mise en bridge d’APIs qui ne prennent pas nativement en charge le contexte.
Exemple :
func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
stop := context.AfterFunc(ctx, func() {
// 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 d’application, un select normal 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à le contexte.
context.WithoutCancel
context.WithoutCancel crée un contexte qui n’est pas annulé lorsque le parent est annulé.
Ceci est utile, mais c’est aussi facile à mal utiliser.
Exemple de cas 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 d’audit peut avoir besoin de continuer brièvement même après que le contexte de la requête est 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 réellement survivre à l’annulation du parent, et ajoutez toujours un nouveau délai d’attente : un contexte qui ignore l’annulation mais ne porte pas de délai peut facilement créer des fuites de goroutines en arrière-plan.
Valeurs de contexte bien utilisées
Les valeurs de contexte sont pour les données portées à la requête qui traversent les frontières des API.
Bons exemples :
- ID de requête
- ID de trace
- ID d’utilisateur authentifié
- ID de locataire
- Locale
- Principale de sécurité
- Métadonnées de corrélation
Mauvais exemples :
- Connexion à la base de données
- Logger en tant que dépendance cachée
- Drapeaux de fonctionnalité pour le contrôle de flux 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.
Utilisez 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 la sécurité de type à la frontière du package, évite les collisions de clés avec d’autres packages et garde la surface de l’API du contexte propre avec des fonctions d’accès typées.
N’utilisez pas les valeurs de contexte pour les paramètres optionnels
Ceci est mauvais :
ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)
Cela cache le contrat de la fonction.
Préférez les paramètres explicites :
users, err := repo.ListUsers(ctx, ListUsersOptions{
PageSize: 100,
})
Les valeurs de contexte ne devraient pas remplacer les arguments de fonction. L’entrée cachée rend le code plus difficile à comprendre, à tester et à réviser — et quiconque lit la signature de la fonction ne saura pas que le paramètre existe.
Journalisation et contexte
Il y a 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 attacher aux logs
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 garde le logger explicite en tant que dépendance appropriée et utilise le contexte uniquement pour les valeurs portées à la requête qui ont légitimement besoin de traverser les frontières des API.
Approche 2 : Stocker le logger dans le contexte
Certaines bases de code stockent un logger dans le contexte.
Cela peut être pratique, mais je ne le recommande pas par défaut. Cela transforme le contexte en un conteneur de dépendances.
Ma préférence :
- Passez les dépendances de logger explicitement.
- Stockez les IDs de trace et les IDs de requête dans le contexte.
- Ajoutez ces valeurs aux logs aux frontières ou au middleware.
Cela garde les dépendances visibles.
Contexte et traçage
Le traçage est l’un des cas d’utilisation les plus forts pour les valeurs de contexte, et c’est un ajustement vraiment bon. OpenTelemetry et des systèmes similaires utilisent le contexte pour propager les spans de trace à travers les appels de fonction et les frontières de processus, car les données de trace sont exactement le genre de métadonnées portées à la requête pour lesquelles le contexte a été conçu.
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 le span de trace actif, et le dépôt peut créer un span enfant à partir de celui-ci. Chaque couche ajoute son propre span sans aucun passage explicite d’objets tracer — le contexte fait ce travail de manière transparente à travers l’ensemble de l’arbre d’appels.
Gestion des erreurs avec le contexte
Lorsqu’une opération s’arrête à cause de l’annulation du contexte, conservez cette information. Les modèles ici complètent les stratégies de conception d’erreurs plus larges couvertes dans Architecture de gestion des erreurs en 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) {
// Délai d'attente.
return err
}
return err
}
Ne wrappez pas aveuglément les erreurs de contexte d’une manière qui les cache.
L’enveloppement avec %w préserve errors.Is, donc les appelants peuvent toujours détecter l’annulation ou le délai d’attente :
if err != nil {
return fmt.Errorf("query user: %w", err)
}
Le remplacement de l’erreur complètement jette cette information et casse tout appelant qui vérifie des types d’erreurs de contexte spécifiques :
if err != nil {
return errors.New("query user failed")
}
Mapping des erreurs de contexte aux réponses HTTP
Les erreurs de contexte se mappent souvent à différentes issues HTTP.
Exemple :
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, context.Canceled):
// Le client est probablement parti.
// Certains systèmes enregistrent cela comme une requête fermée 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 du client comme une défaillance 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 lieu courant pour ajouter des valeurs portées à la 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 un bon usage du contexte. L’ID de requête appartient à la requête, il devrait voyager à travers la chaîne d’appels complète, et l’attacher aux logs et traces à chaque couche est exactement le genre de préoccupation d’observabilité transversale que les valeurs de contexte sont conçues pour supporter.
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 délai d’attente, testez avec un vrai délai d’attente uniquement si le délai d’attente est petit et significatif.
Pour le code concurrent et dépendant du temps, envisagez d’utiliser testing/synctest — Test du 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 délai d’attente 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.
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 les 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 chemins d’annulation. La phrase clé ici est “respecter le contexte” — errgroup ne peut pas arrêter le travail qui ignore ctx.Done().
Arrêt élégant
Le contexte est central pour l’arrêt élégant.
Une configuration de serveur typique a :
- un contexte racine annulé par les signaux OS
- un serveur HTTP
- des workers en arrière-plan
- un délai d’attente 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 OS arrive. Un contexte de délai d’attente séparé donne au processus d’arrêt une quantité bornée de temps pour drainer les requêtes en cours avant de quitter de force, ce qui est la distinction subtile mais importante qui fait que l’arrêt élégant fonctionne vraiment.
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 structs d’options explicites.
Anti-modèle 5 : Passer le contexte trop profondément dans le 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 ne 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 pour le 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 structs à longue durée de vie.
- Le contexte n’est pas passé aux fonctions d’aide pures sauf si nécessaire.
- Le contexte nil n’est jamais utilisé.
Annulation
- Les boucles à longue exécution vérifient
ctx.Done(). - Les goroutines ont un chemin d’arrêt.
- Les durées de vie des workers sont liées à un contexte parent.
- L’annulation du contexte est propagée aux appels en aval.
Délais d’attente
- Les délais d’attente de requête extérieure sont définis à la frontière.
- Les délais d’attente des sous-opérations sont plus petits que le budget extérieur.
- Les fonctions cancel sont toujours appelées.
- Les délais d’attente ne sont pas empilés aveuglément à chaque couche.
Valeurs
- Les valeurs de contexte sont portées à la requête.
- Les clés utilisent des types personnalisés, pas des 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 mappées correctement aux frontières d’API.
- L’annulation consciente de la cause est utilisée uniquement lorsque la raison est importante.
Tests
- Les tests utilisent
t.Context()lorsque approprié. - Les tests de délai d’attente évitent les sleeps réels lents.
- Le comportement de délai d’attente concurrent est testé avec
testing/synctestlorsque utile. - Les fuites de goroutines sont vérifiées en s’assurant que les chemins d’arrêt existent.
Comment auditer l’utilisation du contexte dans une base de code Go
Recherchez 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 :
context.Background()est-il utilisé uniquement aux frontières de haut niveau ?- Les fonctions cancel sont-elles toujours appelées ?
- Les délais d’attente sont-ils placés à des frontières sensées ?
- Les valeurs de contexte sont-elles vraiment portées à la 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 un flux de contrôle
Utilisez le contexte pour contrôler l’annulation, les délais et les métadonnées de requête.
Ne l’utilisez pas pour transporter 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 un délai enfant plus court uniquement lorsque la sous-opération a besoin d’un budget spécifique plus petit.
Règle 3 : Background appartient à la frontière
Utilisez context.Background() dans main, les tests et la configuration de haut niveau.
Ne l’utilisez pas à l’intérieur des 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 trace, l’ID d’utilisateur et l’ID de locataire appartiennent au contexte. Les connexions de base de données, les loggers, les structs de configuration et les clients de service n’appartiennent pas — ce sont des dépendances et devraient être passées 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, une primitive sync 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 circule, 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 fuiter des goroutines. Un contexte mal utilisé cache les dépendances, jette les délais et rend le code plus difficile à raisonner sous pression.
La conclusion pratique est simple :
Passez le contexte en aval.
Ne le stockez pas.
Ne remplacez pas les paramètres explicites par des valeurs.
Respectez l'annulation.
Utilisez les délais d'attente aux frontières.
Appelez toujours cancel.
C’est le contexte Go bien utilisé.
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.