Context en Go : timeout, cancel et transmission de valeurs
1. Aperçu
Lorsque vous écrivez un service Go traitant plusieurs goroutines, vous avez besoin d’un moyen unifié de dire aux goroutines enfants : « arrête, je n’ai plus besoin du résultat » ou « exécute-toi seulement pendant 2 secondes puis abandonne ». context.Context a été créé pour résoudre exactement ce problème.
Cet article couvre trois tâches principales que vous effectuez avec context : l’annulation (cancel) manuelle, la configuration de timeout/deadline, et la transmission de valeurs request-scoped à travers les couches d’appels de fonction. Une fois la lecture terminée, vous saurez écrire des handlers HTTP, des fonctions appelant la DB et des fonctions appelant des goroutines enfants qui savent s’arrêter au bon moment, sans laisser de goroutines fuir.
2. Context est-il une interface et pourquoi en avez-vous besoin
context.Context est une interface décrivant le contexte d’un flux de traitement (généralement une requête). Elle porte :
- Un signal d’annulation (cancel signal).
- Un délai/timeout.
- Quelques petites paires clé-valeur associées à la requête.
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
Done()retourne un canal qui se ferme lorsque le contexte est annulé ou expire.Err()indique la raison :context.Canceledoucontext.DeadlineExceeded.Value()récupère une valeur attachée au contexte.
Remarque : Context ne doit pas être utilisé pour transmettre des paramètres facultatifs arbitraires. Il ne doit porter que des signaux de contrôle et des données request-scoped comme un ID de requête, l’utilisateur actuel — pas une config ou un objet métier important.
3. Annulation manuelle avec WithCancel
Utilisez-le quand vous voulez décider vous-même quand annuler, par exemple, quand l’une de plusieurs goroutines a un résultat, annulez les autres goroutines.
func fetchFromMultipleSources(ctx context.Context) (string, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // appelez toujours cancel pour libérer les ressources
resultCh := make(chan string, 1)
for _, src := range []string{"cache", "db", "api"} {
go func(source string) {
select {
case <-time.After(100 * time.Millisecond):
resultCh <- source
cancel() // résultat obtenu, annulez les autres sources
case <-ctx.Done():
return // annulé, sortir tôt
}
}(src)
}
select {
case res := <-resultCh:
return res, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
context.WithCancel(ctx)crée un contexte enfant et une fonctioncancelpour l’annuler.defer cancel()est obligatoire : sinon, le contexte enfant « pend » jusqu’à ce que le parent soit annulé, causant une fuite des goroutines qui font unselectsurctx.Done().- Quelle que soit la goroutine qui termine en premier appelle
cancel(), les autres goroutines reçoivent le signal viactx.Done()et s’arrêtent.
Erreur courante : oublier defer cancel(). Si la fonction revient tôt (par exemple en cas d’error) sans annuler le contexte, la goroutine enfant reste active jusqu’à ce qu’elle soit garbage collectée indirectement — c’est en fait une fuite.
4. Timeout et deadline avec WithTimeout, WithDeadline
4.1. WithTimeout pour une limite de temps relative
func callPaymentService(ctx context.Context, orderID string) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://payment.local/orders/"+orderID, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // peut être context.DeadlineExceeded
}
defer resp.Body.Close()
return nil
}
context.WithTimeout(ctx, 2*time.Second)annule automatiquement le contexte après 2 secondes.http.NewRequestWithContextattache le contexte à la requête ; quand le contexte expire,http.Clientannule automatiquement la requête en cours.- Si plus de 2 secondes se sont écoulées,
resp, err := http.DefaultClient.Do(req)retourne uneerrenveloppantcontext.DeadlineExceeded.
Vérifier une erreur de timeout :
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("service de paiement timeout")
}
return err
}
4.2. WithDeadline pour un délai absolu
func processBeforeMidnight(ctx context.Context) error {
deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.Local)
ctx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
select {
case <-time.After(5 * time.Hour):
return errors.New("traitement terminé")
case <-ctx.Done():
return ctx.Err()
}
}
WithDeadlineest utilisé quand vous avez un délai spécifique (par exemple, fin de journée), contrairement àWithTimeoutqui est une durée relative à partir de maintenant.- Essentiellement,
WithTimeout(ctx, d)n’est qu’une fine couche appelantWithDeadline(ctx, time.Now().Add(d)).
5. Transmettre des valeurs via le contexte avec WithValue
Utilisez-le pour les données associées à la requête, pas les paramètres métier.
type requestIDKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
func RequestIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey{}).(string)
return id, ok
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
ctx := WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func orderHandler(w http.ResponseWriter, r *http.Request) {
if id, ok := RequestIDFromContext(r.Context()); ok {
log.Printf("[%s] traitement de la commande", id)
}
}
- Utilisez
type requestIDKey struct{}comme clé distincte — évitez d’utiliserstringdirectement car cela pourrait entrer en conflit avec les clés d’autres packages. WithRequestIDetRequestIDFromContextencapsulent l’opération set/get, les autres packages n’ont pas besoin de connaître la véritable clé.r.WithContext(ctx)crée une nouvelle requête avec le contexte ayant la valeur attachée, la transmet via la chaîne middleware.
Remarque : N’utilisez pas
context.WithValuepour transmettre*sql.DB, une config, ou quoi que ce soit dont la fonction a besoin pour fonctionner correctement. Ces choses devraient être des paramètres explicites ou des champs de struct. Les valeurs de contexte ne conviennent que pour les données « sympathiques à avoir » comme l’ID de requête, l’ID de trace, l’utilisateur connecté.
6. Context.TODO et context.Background
func main() {
ctx := context.Background() // contexte racine, n'est jamais annulé
run(ctx)
}
func legacyFunctionBeingRefactored() {
ctx := context.TODO() // placeholder, sera remplacé par un vrai contexte plus tard
_ = ctx
}
context.Background()est le contexte racine, n’est jamais annulé ; utilisé dansmain, les tests, ou les initialisations top-level.context.TODO()est utilisé quand vous refactorisez et que vous n’êtes pas sûr de quel contexte passer — fonctionnellement similaire àBackground()mais signale clairement l’intention au lecteur du code et aux outils de linting.
7. Propager le contexte à travers plusieurs couches d’appels de fonction
Convention : ctx context.Context est toujours le premier paramètre.
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
if err := s.validate(ctx, order); err != nil {
return err
}
return s.repo.Save(ctx, order)
}
func (r *OrderRepository) Save(ctx context.Context, order Order) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO orders (id, total) VALUES (?, ?)", order.ID, order.Total)
return err
}
ExecContextaccepte un contexte, si la requête HTTP au-dessus est annulée (le client ferme la connexion), le driver DB annule immédiatement la requête en cours — évitant de conserver inutilement une connexion.- Règle : toute fonction appelant I/O (HTTP, DB, gRPC) doit accepter un contexte et le passer aux API qui le supportent (suffixe
*Context).
8. Tester le comportement avec des tests
func TestFetchFromMultipleSources_ReturnsFastestSource(t *testing.T) {
ctx := context.Background()
result, err := fetchFromMultipleSources(ctx)
require.NoError(t, err)
assert.Contains(t, []string{"cache", "db", "api"}, result)
}
func TestCallPaymentService_TimesOutWhenSlow(t *testing.T) {
slowServer := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
time.Sleep(3 * time.Second)
}))
defer slowServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, slowServer.URL, nil)
_, err := http.DefaultClient.Do(req)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
<!-- go.mod: ajouter la bibliothèque d'assertions (pas du XML, juste une note) -->
go get github.com/stretchr/testify
- Le premier test utilise
context.Background()car vous n’avez généralement pas besoin de contrôler le timeout dans un test unitaire normal. - Le second test définit intentionnellement un timeout de 500 ms plus court que le temps de traitement du serveur (3 s) pour confirmer que l’erreur retournée est bien
context.DeadlineExceeded. httptest.NewServercrée un serveur factice, pas besoin de mock compliqué.
9. Erreurs courantes
- Ne pas appeler
cancel(): appelez toujoursdefer cancel()immédiatement aprèsWithCancel/WithTimeout/WithDeadline, même si le contexte expirera de lui-même — Go vet vous avertira si vous oubliez. - Stocker le contexte dans un champ de struct : le contexte doit être passé via les paramètres de fonction, ne le stockez pas en tant que champ de struct (sauf quelques cas particuliers comme
http.Request). Le stocker en tant que champ peut facilement conduire à un contexte ancien, périmé. - Utiliser le contexte pour transmettre les paramètres obligatoires : si la fonction ne s’exécute pas correctement sans cette valeur, transmettez-la comme un paramètre normal, ne la cachez pas dans le contexte.
- Créer un nouveau contexte au lieu d’utiliser le contexte parent : si vous appelez
context.Background()à l’intérieur d’une fonction qui reçoitctxde l’appelant, vous perdez la capacité d’annulation/timeout d’en haut — dérivez toujours du contexte reçu :context.WithTimeout(ctx, ...), pascontext.WithTimeout(context.Background(), ...).
Quand vous n’avez pas besoin de contexte : pour les fonctions pures (pure function), sans I/O, sans goroutine, vous n’avez pas besoin de contexte comme paramètre — l’ajouter crée juste du bruit.
10. Conclusion
Le contexte résout trois tâches essentielles dans la concurrence Go : signaler l’annulation, imposer une limite de temps, et porter quelques petites valeurs à travers les couches d’appels de fonction. La règle de sécurité la plus importante est toujours d’accepter ctx comme premier paramètre, toujours faire defer cancel(), et utiliser WithValue uniquement pour les données request-scoped vraiment auxiliaires. Maîtriser ces principes vous permettra d’écrire des services Go sans fuite de goroutines et qui réagissent correctement quand le client timeout ou annule la requête.