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.Canceled ou context.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 fonction cancel pour 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 un select sur ctx.Done().
  • Quelle que soit la goroutine qui termine en premier appelle cancel(), les autres goroutines reçoivent le signal via ctx.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.NewRequestWithContext attache le contexte à la requête ; quand le contexte expire, http.Client annule automatiquement la requête en cours.
  • Si plus de 2 secondes se sont écoulées, resp, err := http.DefaultClient.Do(req) retourne une err enveloppant context.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()
	}
}
  • WithDeadline est utilisé quand vous avez un délai spécifique (par exemple, fin de journée), contrairement à WithTimeout qui est une durée relative à partir de maintenant.
  • Essentiellement, WithTimeout(ctx, d) n’est qu’une fine couche appelant WithDeadline(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’utiliser string directement car cela pourrait entrer en conflit avec les clés d’autres packages.
  • WithRequestID et RequestIDFromContext encapsulent 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.WithValue pour 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é dans main, 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
}
  • ExecContext accepte 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.NewServer crée un serveur factice, pas besoin de mock compliqué.

9. Erreurs courantes

  • Ne pas appeler cancel() : appelez toujours defer cancel() immédiatement après WithCancel/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çoit ctx de l’appelant, vous perdez la capacité d’annulation/timeout d’en haut — dérivez toujours du contexte reçu : context.WithTimeout(ctx, ...), pas context.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.

golangcontextconcurrencygoroutinetimeout