Dépannage du Go Context Timeout : 7 causes racines de context deadline exceeded et contrôle de précision
Votre microservice a encore été écrasé par context deadline exceeded
3h du matin, le canal d'alertes explose — la latence P99 du service de commandes monte à 5 secondes, la passerelle amont signale context deadline exceeded sans relâche. Vous augmentez le timeout de 3s à 5s, de 5s à 10s, mais vous ne faites que repousser le problème. Pire encore, vous découvrez des milliers de fuites de goroutines, l'utilisation mémoire augmente régulièrement, jusqu'au OOM kill.
Le contrôle de timeout du Go Context ne se résume pas à ajouter context.WithTimeout. Quelle doit être la durée du timeout ? Comment l'annulation se propage-t-elle ? Comment les goroutines filles sont-elles nettoyées ? Comment les timeouts en cascade dans la chaîne de microservices se comportent-ils ? Sans comprendre cela, context deadline exceeded vous hantera encore et encore.
Cet article part de 7 causes racines et vous guide à travers le pipeline complet configuration de timeout → propagation d'annulation → nettoyage de goroutine → contrôle en cascade des microservices.
Concepts fondamentaux du Context
| Concept | Description |
|---|---|
| context.Context | Interface de la bibliothèque standard Go transportant le deadline, le signal d'annulation et les valeurs à portée de requête |
| context.WithTimeout | Crée un Context enfant avec timeout, s'annule automatiquement après la durée |
| context.WithDeadline | Crée un Context enfant avec un deadline absolu |
| context.WithCancel | Crée un Context enfant annulable manuellement |
| context.WithoutCancel | Go 1.21+, crée un Context enfant non affecté par l'annulation du parent |
| context.AfterFunc | Go 1.21+, exécute automatiquement un callback après l'annulation du Context |
| context.Cause | Go 1.20+, récupère la cause racine de l'annulation du Context |
Mécanisme de propagation du timeout
Request Chain:
Gateway(5s) → OrderService(3s) → PaymentService(2s) → InventoryService(1s)
Propagation Rules:
1. Child Context timeout cannot exceed parent Context
2. Parent Context cancellation auto-cancels all children
3. After timeout, Done channel closes, Err returns deadline exceeded
4. Cancellation is idempotent, multiple Cancel() calls won't error
Analyse du problème : 7 causes racines de context deadline exceeded
- Timeout trop court: Les fluctuations réseau et la charge du service n'ont pas été prises en compte, la latence P99 dépasse le seuil de timeout
- Context non propagé: La fonction accepte un Context mais l'appelant passe
context.TODO()oucontext.Background() - Fuite de goroutine: Après l'annulation du Context, les goroutines filles ne vérifient pas le canal Done, continuent de s'exécuter
- Amplification en cascade du timeout: Chaque couche de microservice définit un timeout, le timeout total est compressé couche par couche
- Client HTTP non configuré:
http.Client{}n'a pas de timeout par défaut, les requêtes peuvent bloquer indéfiniment - Requête de base de données sans timeout: Le temps d'exécution SQL n'est pas contrôlé, les requêtes longes entraînent toute la requête
- Surcharge de Context: La fonction interne crée un nouveau Context avec
context.Background(), perdant le signal d'annulation externe
Étape par étape : Implémentation du contrôle de timeout de précision
Étape 1 : Contrôle de timeout de base
package main
import (
"context"
"fmt"
"time"
)
func fetchUserData(ctx context.Context, userID string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resultCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
data, err := queryDatabase(ctx, userID)
if err != nil {
errCh <- err
return
}
resultCh <- data
}()
select {
case data := <-resultCh:
return data, nil
case err := <-errCh:
return "", err
case <-ctx.Done():
return "", fmt.Errorf("fetch user data: %w", ctx.Err())
}
}
func queryDatabase(ctx context.Context, userID string) (string, error) {
select {
case <-time.After(2 * time.Second):
return fmt.Sprintf("user_data_%s", userID), nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx := context.Background()
data, err := fetchUserData(ctx, "12345")
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Data: %s\n", data)
}
Étape 2 : Propagation du timeout dans la chaîne de microservices
package middleware
import (
"context"
"time"
)
type TimeoutConfig struct {
GatewayTimeout time.Duration
OrderServiceTimeout time.Duration
PaymentTimeout time.Duration
InventoryTimeout time.Duration
}
func DefaultTimeoutConfig() *TimeoutConfig {
return &TimeoutConfig{
GatewayTimeout: 5 * time.Second,
OrderServiceTimeout: 3 * time.Second,
PaymentTimeout: 2 * time.Second,
InventoryTimeout: 1 * time.Second,
}
}
type contextKey string
const timeoutKey contextKey = "timeout_config"
func WithTimeoutConfig(ctx context.Context, cfg *TimeoutConfig) context.Context {
return context.WithValue(ctx, timeoutKey, cfg)
}
func TimeoutConfigFromContext(ctx context.Context) *TimeoutConfig {
if cfg, ok := ctx.Value(timeoutKey).(*TimeoutConfig); ok {
return cfg
}
return DefaultTimeoutConfig()
}
func DeriveServiceTimeout(ctx context.Context, serviceTimeout time.Duration) (context.Context, context.CancelFunc) {
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining < serviceTimeout {
return context.WithDeadline(ctx, deadline)
}
}
return context.WithTimeout(ctx, serviceTimeout)
}
Étape 3 : Prévention des fuites de goroutine
package pool
import (
"context"
"sync"
)
type WorkerPool struct {
maxWorkers int
tasks chan func() error
wg sync.WaitGroup
}
func NewWorkerPool(maxWorkers, queueSize int) *WorkerPool {
return &WorkerPool{
maxWorkers: maxWorkers,
tasks: make(chan func() error, queueSize),
}
}
func (p *WorkerPool) Start(ctx context.Context) {
for i := 0; i < p.maxWorkers; i++ {
p.wg.Add(1)
go func(workerID int) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
case task, ok := <-p.tasks:
if !ok {
return
}
task()
}
}
}(i)
}
}
func (p *WorkerPool) Submit(ctx context.Context, task func() error) error {
select {
case p.tasks <- task:
return nil
case <-ctx.Done():
return ctx.Err()
default:
return fmt.Errorf("task queue is full")
}
}
func (p *WorkerPool) Stop() {
close(p.tasks)
p.wg.Wait()
}
Étape 4 : Configuration du timeout du client HTTP
package httpclient
import (
"context"
"net"
"net/http"
"time"
)
type ClientConfig struct {
Timeout time.Duration
DialTimeout time.Duration
TLSHandshakeTimeout time.Duration
MaxIdleConns int
MaxIdleConnsPerHost int
IdleConnTimeout time.Duration
}
func DefaultClientConfig() *ClientConfig {
return &ClientConfig{
Timeout: 10 * time.Second,
DialTimeout: 3 * time.Second,
TLSHandshakeTimeout: 3 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
}
func NewClient(cfg *ClientConfig) *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.DialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
IdleConnTimeout: cfg.IdleConnTimeout,
ResponseHeaderTimeout: cfg.Timeout,
}
return &http.Client{
Timeout: cfg.Timeout,
Transport: transport,
}
}
func DoRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("request cancelled: %w", ctx.Err())
}
return nil, fmt.Errorf("request failed: %w", err)
}
return resp, nil
}
Étape 5 : Timeout de requête de base de données
package db
import (
"context"
"database/sql"
"time"
)
type DBOption struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
QueryTimeout time.Duration
}
func DefaultDBOption() *DBOption {
return &DBOption{
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: 5 * time.Minute,
ConnMaxIdleTime: 1 * time.Minute,
QueryTimeout: 3 * time.Second,
}
}
func OpenDB(driverName, dataSource string, opt *DBOption) (*sql.DB, error) {
db, err := sql.Open(driverName, dataSource)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(opt.MaxOpenConns)
db.SetMaxIdleConns(opt.MaxIdleConns)
db.SetConnMaxLifetime(opt.ConnMaxLifetime)
db.SetConnMaxIdleTime(opt.ConnMaxIdleTime)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
func QueryWithTimeout(ctx context.Context, db *sql.DB, query string, args ...any) (*sql.Rows, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("query timeout after 3s: %w", err)
}
return nil, err
}
return rows, nil
}
Guide des pièges courants
Piège 1 : Stocker le Context dans un Struct
// ❌ Wrong: storing Context in a struct
type UserService struct {
ctx context.Context
}
func (s *UserService) GetUser(id string) error {
return s.queryDatabase(s.ctx, id)
}
// ✅ Correct: pass Context as function parameter
type UserService struct {
db *sql.DB
}
func (s *UserService) GetUser(ctx context.Context, id string) error {
return s.queryDatabase(ctx, id)
}
Piège 2 : Oublier d'appeler Cancel provoque une fuite
// ❌ Wrong: cancel from WithTimeout not called
ctx, _ := context.WithTimeout(parentCtx, 5*time.Second)
result, err := doWork(ctx)
// ✅ Correct: always defer cancel
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel()
result, err := doWork(ctx)
Piège 3 : Remplacer le Context parent par context.Background()
// ❌ Wrong: inner function uses Background, losing cancellation signal
func processOrder(ctx context.Context, orderID string) error {
innerCtx := context.Background()
return paymentService.charge(innerCtx, orderID)
}
// ✅ Correct: pass parent Context to maintain cancellation propagation
func processOrder(ctx context.Context, orderID string) error {
return paymentService.charge(ctx, orderID)
}
Piège 4 : La goroutine ne vérifie pas le canal Done
// ❌ Wrong: goroutine doesn't respond to cancellation
go func() {
result := heavyComputation()
resultCh <- result
}()
// ✅ Correct: check Context cancellation inside goroutine
go func() {
result, err := heavyComputationWithCtx(ctx)
if err != nil {
return
}
select {
case resultCh <- result:
case <-ctx.Done():
}
}()
Piège 5 : Conflit entre le timeout du client HTTP et du Transport
// ❌ Wrong: Client.Timeout and Transport timeout overlap, may timeout early
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
ResponseHeaderTimeout: 5 * time.Second,
},
}
// ✅ Correct: Client.Timeout controls overall, Transport only connection phase
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
},
}
Dépannage des erreurs
| # | Message d'erreur | Cause | Solution |
|---|---|---|---|
| 1 | context deadline exceeded |
L'opération a dépassé le timeout du Context | Vérifier si le timeout est raisonnable, optimiser les requêtes/requêtes lentes |
| 2 | context canceled |
Context annulé manuellement (cancel() appelé) | Vérifier la source de l'annulation, confirmer si c'est le comportement attendu |
| 3 | grpc: context canceled |
Context annulé pendant un appel gRPC | Vérifier le timeout client et le temps de traitement serveur |
| 4 | net/http: request canceled |
Requête HTTP annulée pendant le transfert | Vérifier Client.Timeout et le timeout du Context |
| 5 | driver: bad connection |
Connexion BD expirée pendant la requête | Augmenter ConnMaxLifetime, vérifier le timeout QueryContext |
| 6 | i/o timeout |
Opération d'E/S réseau expirée | Augmenter DialTimeout, vérifier la connectivité réseau |
| 7 | TLS handshake timeout |
Handshake TLS expiré | Augmenter TLSHandshakeTimeout, vérifier la chaîne de certificats |
| 8 | connection reset by peer |
Le pair a fermé la connexion après le timeout | Vérifier les paramètres de timeout du pair, s'assurer que les deux côtés sont d'accord |
| 9 | goroutine leak detected |
La goroutine ne s'est pas terminée après l'annulation du Context | Vérifier ctx.Done() dans les goroutines, assurer une sortie rapide |
| 10 | queue is full |
File d'attente de requêtes pleine, soumission échouée | Augmenter la capacité de la file, ajouter des workers, ajouter du contre-pression |
Optimisation avancée
1. Contrôle de timeout adaptatif
package adaptive
import (
"context"
"sort"
"sync"
"time"
)
type AdaptiveTimeout struct {
mu sync.Mutex
history []time.Duration
maxHistory int
percentile float64
minTimeout time.Duration
maxTimeout time.Duration
safetyMargin float64
}
func NewAdaptiveTimeout(percentile float64, minTimeout, maxTimeout time.Duration) *AdaptiveTimeout {
return &AdaptiveTimeout{
history: make([]time.Duration, 0, 100),
maxHistory: 100,
percentile: percentile,
minTimeout: minTimeout,
maxTimeout: maxTimeout,
safetyMargin: 1.5,
}
}
func (a *AdaptiveTimeout) Record(duration time.Duration) {
a.mu.Lock()
defer a.mu.Unlock()
a.history = append(a.history, duration)
if len(a.history) > a.maxHistory {
a.history = a.history[1:]
}
}
func (a *AdaptiveTimeout) Timeout() time.Duration {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.history) == 0 {
return a.maxTimeout
}
sorted := make([]time.Duration, len(a.history))
copy(sorted, a.history)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
idx := int(float64(len(sorted)) * a.percentile)
if idx >= len(sorted) {
idx = len(sorted) - 1
}
timeout := time.Duration(float64(sorted[idx]) * a.safetyMargin)
if timeout < a.minTimeout {
timeout = a.minTimeout
}
if timeout > a.maxTimeout {
timeout = a.maxTimeout
}
return timeout
}
func (a *AdaptiveTimeout) Context(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, a.Timeout())
}
2. Middleware de propagation de timeout
package middleware
import (
"context"
"fmt"
"strconv"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
const timeoutMetadataKey = "x-request-timeout-ms"
func TimeoutPropagationInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
if deadline, ok := ctx.Deadline(); ok {
remainingMs := time.Until(deadline).Milliseconds()
if remainingMs > 0 {
md, _ := metadata.FromOutgoingContext(ctx)
md = md.Copy()
md.Set(timeoutMetadataKey, fmt.Sprintf("%d", remainingMs))
ctx = metadata.NewOutgoingContext(ctx, md)
}
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
func TimeoutPropagationServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return handler(ctx, req)
}
values := md.Get(timeoutMetadataKey)
if len(values) == 0 {
return handler(ctx, req)
}
remainingMs, err := strconv.ParseInt(values[0], 10, 64)
if err != nil || remainingMs <= 0 {
return handler(ctx, req)
}
remaining := time.Duration(remainingMs) * time.Millisecond
if deadline, ok := ctx.Deadline(); ok {
if time.Until(deadline) < remaining {
remaining = time.Until(deadline)
}
}
ctx, cancel := context.WithTimeout(ctx, remaining)
defer cancel()
return handler(ctx, req)
}
}
3. Détection des fuites de goroutine
package leak
import (
"context"
"log"
"runtime"
"time"
)
type LeakDetector struct {
checkInterval time.Duration
threshold int
}
func NewLeakDetector(checkInterval time.Duration, threshold int) *LeakDetector {
return &LeakDetector{
checkInterval: checkInterval,
threshold: threshold,
}
}
func (d *LeakDetector) Start(ctx context.Context) {
ticker := time.NewTicker(d.checkInterval)
defer ticker.Stop()
var prevGoroutines int
var growthCount int
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
current := runtime.NumGoroutine()
if current > d.threshold {
log.Printf("[LEAK WARNING] goroutine count %d exceeds threshold %d", current, d.threshold)
}
growth := current - prevGoroutines
if growth > 10 {
growthCount++
if growthCount >= 3 {
log.Printf("[LEAK ALERT] goroutine count growing continuously: %d -> %d (%d consecutive growths)", prevGoroutines, current, growthCount)
}
} else {
growthCount = 0
}
prevGoroutines = current
}
}
}
Analyse comparative
| Dimension | context.WithTimeout | context.WithDeadline | context.WithCancel | time.After | select+timer |
|---|---|---|---|---|---|
| Précision du timeout | Milliseconde | Temps absolu | Pas de timeout | Milliseconde | Milliseconde |
| Propagation de l'annulation | ✅ Automatique | ✅ Automatique | ✅ Manuelle | ❌ Aucune | ❌ Aucune |
| Sûr pour les goroutines | ✅ | ✅ | ✅ | ⚠️ Risque de fuite | ⚠️ Risque de fuite |
| Cascade de microservices | ✅ | ✅ | ✅ | ❌ | ❌ |
| Propagation des valeurs | ✅ | ✅ | ✅ | ❌ | ❌ |
| Surcharge de ressources | Faible | Faible | Faible | Élevée (fuite) | Moyenne |
| Cas d'utilisation | Timeout général | Tâches planifiées | Annulation manuelle | Attente simple | Timeout local |
Résumé :
context deadline exceededne se résout pas en « augmentant le timeout ». Parmi les 7 causes racines, les plus dangereuses sont les fuites de goroutines et la surcharge de Context — les premières « saignent » lentement votre service jusqu'à l'OOM, les secondes rendent le contrôle de timeout inutile. Pratiques de timeout dans les microservices Go 2026 : 1) Définir un timeout global à la passerelle, propager le temps restant via les métadonnées ; 2) Chaque couche de service utiliseDeriveServiceTimeoutprenant min(timeout propre, temps restant) ; 3) Toutes les goroutines doivent vérifierctx.Done(); 4) Utiliser un timeout adaptatif plutôt qu'un timeout statique ; 5) Déployer la détection de fuites de goroutines. Rappelez-vous : le timeout ne consiste pas à être plus long, mais à être précis.
Outils en ligne recommandés
- Formateur JSON : /fr/json/format
- Encodage/Décodage Base64 : /fr/encode/base64
- Calculateur de hash : /fr/encode/hash
- Décodeur JWT : /fr/encode/jwt-decode
Essayez ces outils exécutés localement dans le navigateur — aucune inscription requise →