Les timeouts de contexte Go empêchent que des appels DB lents et des requêtes externes s'accumulent. Apprenez la propagation des deadlines, l'annulation et des valeurs par défaut sûres.

Une seule requête lente est rarement « seulement lente. » Pendant qu'elle attend, elle maintient une goroutine active, conserve de la mémoire pour les buffers et les objets de réponse, et occupe souvent une connexion à la base de données ou une place dans un pool. Quand suffisamment de requêtes lentes s'accumulent, votre API cesse de faire du travail utile parce que ses ressources limitées sont bloquées.
Vous le ressentez généralement à trois niveaux. Les goroutines s'accumulent et le coût d'ordonnancement augmente, donc la latence se dégrade pour tout le monde. Les pools de base de données manquent de connexions libres, si bien que même les requêtes rapides se retrouvent en file derrière les lentes. La mémoire augmente à cause des données en vol et des réponses partiellement construites, ce qui accroît le travail du GC.
Ajouter plus de serveurs ne résout souvent pas le problème. Si chaque instance bute sur le même goulot d'étranglement (un petit pool de BDD, un upstream lent, des limites de débit partagées), vous déplacez simplement la file d'attente et payez plus, alors que les erreurs continuent d'augmenter.
Imaginez un handler qui fait un fan-out : il charge un utilisateur depuis PostgreSQL, appelle un service de paiements, puis un service de recommandations. Si l'appel de recommandations bloque et que rien ne l'annule, la requête ne se termine jamais. La connexion DB peut finir par être rendue, mais la goroutine et les ressources du client HTTP restent occupées. Multipliez cela par des centaines de requêtes et vous obtenez une fonte lente.
L'objectif est simple : fixer une limite de temps claire, arrêter le travail quand le temps est écoulé, libérer les ressources et renvoyer une erreur prévisible. Les timeouts de contexte Go donnent à chaque étape une deadline pour que le travail s'arrête quand l'utilisateur n'attend plus.
Un context.Context est un petit objet que vous passez dans la chaîne d'appels pour que chaque couche s'accorde sur une chose : quand cette requête doit s'arrêter. Les timeouts sont le moyen courant d'empêcher une dépendance lente de bloquer votre serveur.
Un contexte peut transporter trois types d'informations : une deadline (moment où le travail doit s'arrêter), un signal d'annulation (quelqu'un a décidé d'arrêter plus tôt), et quelques valeurs scoped à la requête (à utiliser parcimonieusement, et jamais pour de grosses données).
L'annulation n'est pas magique. Un contexte expose un canal Done(). Quand il se ferme, la requête est annulée ou son temps est écoulé. Le code qui respecte le contexte vérifie Done() (souvent avec un select) et retourne tôt. Vous pouvez aussi vérifier ctx.Err() pour savoir pourquoi il s'est terminé, généralement context.Canceled ou context.DeadlineExceeded.
Utilisez context.WithTimeout pour « stopper après X secondes ». Utilisez context.WithDeadline quand vous connaissez déjà le moment exact de coupure. Utilisez context.WithCancel quand une condition parente doit arrêter le travail (client déconnecté, utilisateur parti, vous avez déjà la réponse).
Quand un contexte est annulé, le comportement correct est ennuyeux mais important : arrêter le travail, arrêter d'attendre des I/O lentes, et renvoyer une erreur claire. Si un handler attend une requête DB et que le contexte se termine, retournez rapidement et laissez l'appel DB s'avorter s'il supporte le contexte.
L'endroit le plus sûr pour arrêter les requêtes lentes est la frontière où le trafic entre dans votre service. Si une requête doit expirer, vous voulez que cela se produise de manière prévisible et tôt, pas après qu'elle ait accaparé des goroutines, des connexions DB et de la mémoire.
Commencez par la périphérie (load balancer, API gateway, reverse proxy) et fixez un plafond strict sur la durée de vie de toute requête. Cela protège votre service Go même si un handler oublie de définir un timeout.
À l'intérieur de votre serveur Go, configurez des timeouts HTTP afin que le serveur n'attende pas indéfiniment un client lent ou une réponse bloquée. Au minimum, configurez des timeouts pour la lecture des en-têtes, la lecture du corps complet, l'écriture de la réponse et la durée de vie des connexions idle.
Choisissez un budget de requête par défaut qui correspond à votre produit. Pour beaucoup d'APIs, 1 à 3 secondes est un point de départ raisonnable pour les requêtes typiques, avec une limite plus élevée pour les opérations connues comme lentes (export, etc.). Le nombre exact importe moins que la cohérence, la mesure et une règle claire pour les exceptions.
Les réponses en streaming demandent une attention particulière. Il est facile de créer un flux infini accidentel où le serveur garde la connexion ouverte et écrit de petits morceaux indéfiniment, ou attend indéfiniment avant le premier octet. Décidez à l'avance si un endpoint est vraiment un stream. Si ce n'est pas le cas, imposez un temps total maximal et un temps maximal avant le premier octet.
Une fois que la frontière a une deadline claire, il est beaucoup plus simple de propager cette deadline à travers toute la requête.
Le point de départ le plus simple est le handler HTTP. C'est là qu'une requête entre dans votre système, donc c'est un endroit naturel pour fixer une limite stricte.
Créez un nouveau contexte avec une deadline, et assurez-vous d'appeler cancel. Puis passez ce contexte à tout ce qui peut bloquer : travail sur la base de données, appels HTTP, ou calculs lents.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
ctx à chaque appel bloquantUne bonne règle : si une fonction peut attendre des I/O, elle devrait accepter un context.Context. Gardez les handlers lisibles en poussant les détails dans de petits helpers comme loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
Si la deadline est atteinte (ou le client déconnecté), arrêtez le travail et renvoyez une réponse lisible par l'utilisateur. Un mapping courant est context.DeadlineExceeded -> 504 Gateway Timeout, et context.Canceled -> « client parti » (souvent sans corps de réponse).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Client went away. Avoid doing more work.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Ce pattern empêche l'accumulation. Une fois le timer expiré, chaque fonction sensible au contexte dans la chaîne reçoit le même signal d'arrêt et peut sortir rapidement.
Quand votre handler a un contexte avec une deadline, la règle la plus importante est simple : utilisez ce même ctx jusqu'à l'appel à la base de données. C'est ainsi que les timeouts arrêtent le travail au lieu de se contenter d'empêcher votre handler d'attendre.
Avec database/sql, préférez les méthodes compatibles contexte :
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
Si le budget du handler est de 2 secondes, la base de données devrait ne recevoir qu'une part de ce temps. Laissez de la marge pour l'encodage JSON, d'autres dépendances et le traitement des erreurs. Un point de départ simple est de donner à Postgres 30% à 60% du budget total. Avec une deadline handler de 2 secondes, cela peut représenter 800ms à 1.2s.
Quand le contexte est annulé, le driver demande à Postgres d'arrêter la requête. Généralement, la connexion retourne au pool et peut être réutilisée. Si l'annulation survient pendant un incident réseau, le driver peut éliminer cette connexion et en ouvrir une nouvelle plus tard. Quoi qu'il en soit, vous évitez une goroutine qui attend indéfiniment.
En vérifiant les erreurs, traitez les timeouts différemment des vraies erreurs DB. Si errors.Is(err, context.DeadlineExceeded), vous avez manqué de temps et devez renvoyer un timeout. Si errors.Is(err, context.Canceled), le client est parti et vous devriez arrêter discrètement. Les autres erreurs sont des problèmes normaux de requête (SQL incorrect, ligne manquante, permissions).
Si votre handler a une deadline, vos appels HTTP sortants doivent aussi la respecter. Sinon le client abandonne, mais votre serveur continue d'attendre un upstream lent et bloque des goroutines, des sockets et de la mémoire.
Construisez les requêtes sortantes avec le contexte parent pour que l'annulation voyage automatiquement :
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
Ce timeout par appel est un filet de sécurité. La deadline parent reste le patron. Une horloge pour toute la requête, plus de petites limites pour les étapes risquées.
Configurez aussi des timeouts au niveau du transport. Le contexte annule la requête, mais les timeouts du transport vous protègent des handshakes lents et des serveurs qui n'envoient jamais d'en-têtes.
Un détail qui piège les équipes : les corps de réponse doivent être fermés sur chaque chemin. Si vous retournez tôt (vérification du status, erreur de décodage JSON, timeout de contexte), fermez quand même le body. Fuir des bodies peut silencieusement épuiser les connexions du pool et provoquer des pics de latence inexplicables.
Un scénario concret : votre API appelle un fournisseur de paiement. Le client abandonne après 2 secondes, mais l'upstream bloque pendant 30 secondes. Sans annulation de requête et sans timeouts de transport, vous payez ces 30 secondes d'attente pour chaque requête abandonnée.
Une seule requête touche normalement plusieurs éléments lents : travail du handler, requête DB, et un ou plusieurs API externes. Si vous donnez à chaque étape un timeout généreux, le temps total croît discrètement jusqu'à ce que les utilisateurs le ressentent et que votre serveur s'accumule.
La budgétisation est la solution la plus simple. Fixez une deadline parent pour toute la requête, puis donnez à chaque dépendance une tranche plus petite. Les deadlines enfants doivent précéder la deadline parent pour échouer vite et laisser le temps de renvoyer une erreur propre.
Règles empiriques qui fonctionnent dans de vrais services :
Évitez d'empiler des timeouts qui se combattent. Si votre contexte handler a une deadline à 2s et que votre client HTTP a un timeout à 10s, vous êtes sûr mais c'est déroutant. Si c'est l'inverse, le client peut couper tôt pour une raison extérieure.
Pour le travail en arrière-plan (audit logs, métriques, e-mails), ne réutilisez pas le contexte de la requête. Utilisez un contexte séparé avec son propre timeout court pour que les annulations client n'éliminent pas des nettoyages importants.
La plupart des bugs de timeout ne sont pas dans le handler. Ils surviennent une ou deux couches plus bas, où la deadline est silencieusement perdue. Si vous fixez des timeouts à la frontière mais les ignorez au milieu, vous pouvez toujours vous retrouver avec des goroutines, des requêtes DB ou des appels HTTP qui continuent après que le client soit parti.
Les patterns qui reviennent souvent sont simples :
context.Background() (ou TODO). Cela déconnecte le travail de l'annulation client et de la deadline du handler.ctx.Done(). La requête est annulée, mais votre code continue d'attendre.context.WithTimeout. Vous vous retrouvez avec beaucoup de timers et des deadlines confuses.ctx aux appels bloquants (requêtes DB, HTTP sortant, publications de messages). Un timeout handler ne sert à rien si l'appel dépendance l'ignore.Un échec classique : vous ajoutez un timeout de 2s dans le handler, puis votre repository utilise context.Background() pour la requête DB. Sous charge, une requête lente continue même après que le client ait renoncé, et la pile augmente.
Corrigez le basique : passez ctx comme premier argument dans votre pile d'appels. Dans les travaux longs, ajoutez des vérifications rapides comme select { case <-ctx.Done(): return ctx.Err() default: }. Mappez context.DeadlineExceeded à une réponse timeout (souvent 504) et context.Canceled à un style de réponse client-annulé (souvent 408 ou 499 selon vos conventions).
Les timeouts n'aident que si vous pouvez les voir se produire et confirmer que le système récupère proprement. Quand quelque chose est lent, la requête doit s'arrêter, les ressources doivent être libérées et l'API doit rester réactive.
Pour chaque requête, loggez le même petit ensemble de champs pour pouvoir comparer les requêtes normales et les timeouts. Incluez la deadline du contexte (si elle existe) et ce qui a mis fin au travail.
Les champs utiles incluent la deadline (ou « none »), le temps écoulé total, la raison de l'annulation (timeout vs client canceled), une étiquette courte d'opération ("db.query users", "http.call billing") et un request ID.
Un pattern minimal ressemble à ceci :
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
Les logs aident à déboguer une requête. Les métriques montrent les tendances.
Suivez quelques signaux qui piquent généralement tôt quand les timeouts sont mal réglés : nombre de timeouts par route et par dépendance, requêtes en vol (doivent plafonner sous charge), temps d'attente du pool DB, et percentiles de latence (p95/p99) segmentés par succès vs timeout.
Rendez la lenteur prévisible. Ajoutez un délai de debug à un handler, ralentissez une requête DB avec une attente délibérée, ou fournissez un serveur de test externe qui dort. Puis vérifiez deux choses : vous voyez l'erreur de timeout, et le travail s'arrête rapidement après l'annulation.
Un petit test de charge aide aussi. Lancez 20 à 50 requêtes concurrentes pendant 30 à 60 secondes avec une dépendance forcée lente. Le nombre de goroutines et les requêtes en vol devraient monter puis se stabiliser. S'ils continuent d'augmenter, quelque chose ignore l'annulation du contexte.
Les timeouts n'aident que s'ils sont appliqués partout où une requête peut attendre. Avant de déployer, faites une passe sur le code et confirmez que les mêmes règles sont suivies dans chaque handler.
context.DeadlineExceeded et context.Canceled.http.NewRequestWithContext (ou req = req.WithContext(ctx)) et le client a des timeouts sur le transport (dial, TLS, header). Évitez de compter sur http.DefaultClient en production.Un petit exercice « dépendance lente » avant la release en vaut la peine. Ajoutez un délai artificiel de 2 secondes à une requête SQL et confirmez trois choses : le handler retourne à temps, l'appel DB s'arrête réellement (pas seulement le handler), et vos logs indiquent clairement qu'il s'agissait d'un timeout DB.
Imaginez un endpoint comme GET /v1/account/summary. Une action utilisateur déclenche trois choses : une requête PostgreSQL (compte + activité récente) et deux appels HTTP externes (par ex. vérification du statut de facturation et enrichissement de profil).
Donnez à toute la requête un budget dur de 2 secondes. Sans budget, une dépendance lente peut garder des goroutines, connexions DB et mémoire bloqués jusqu'à ce que votre API commence à timeouter partout.
Une répartition simple pourrait être 800ms pour la requête DB, 600ms pour l'appel externe A et 600ms pour l'appel externe B.
Une fois la deadline connue, propagez-la. Chaque dépendance reçoit son propre timeout plus petit, mais hérite toujours de l'annulation du parent.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
Si l'appel externe B ralentit et prend 2.5 secondes, votre handler doit cesser d'attendre à 600ms, annuler le travail en vol et renvoyer une réponse claire de timeout au client. Le client voit un échec rapide au lieu d'un spinner bloqué.
Vos logs doivent indiquer clairement ce qui a consommé le budget, par exemple : DB fini rapidement, externe A réussi, externe B a atteint son cap et a renvoyé context deadline exceeded.
Une fois qu'un endpoint réel fonctionne bien avec les timeouts et l'annulation, transformez cela en un pattern répétable. Appliquez-le de bout en bout : deadline du handler, appels DB et HTTP sortants. Puis copiez la même structure sur l'endpoint suivant.
Vous irez plus vite si vous centralisez les parties ennuyeuses : un helper pour timeout à la frontière, des wrappers qui garantissent que ctx est passé aux appels DB et HTTP, et un mapping d'erreur et un format de logs unifiés.
Si vous voulez prototyper rapidement ce pattern, Koder.ai (koder.ai) peut générer des handlers Go et des appels de service à partir d'une invite de chat, et vous pouvez exporter le code source pour y appliquer vos propres helpers de timeout et vos budgets. L'objectif est la cohérence : les appels lents s'arrêtent tôt, les erreurs se ressemblent, et le débogage ne dépend pas de qui a écrit l'endpoint.
Une requête lente conserve des ressources limitées pendant qu'elle attend : une goroutine, de la mémoire pour les buffers et les objets de réponse, et souvent une connexion à la base de données ou une connexion HTTP client. Quand suffisamment de requêtes attendent en même temps, des files d'attente se forment, la latence augmente pour tout le trafic et le service peut échouer même si chaque requête finirait par aboutir.
Définissez un délai clair à la frontière de la requête (proxy/gateway et dans le serveur Go), dérivez un contexte minuté dans le handler, et passez ce ctx à chaque appel bloquant (base de données et HTTP sortant). Quand la deadline est atteinte, retournez rapidement une réponse de timeout cohérente et arrêtez tout travail en cours qui supporte l'annulation.
Utilisez context.WithTimeout(parent, d) quand vous voulez « arrêter après cette durée », ce qui est le plus courant dans les handlers. Utilisez context.WithDeadline(parent, t) quand vous avez déjà une heure de coupure fixe à respecter. Utilisez context.WithCancel(parent) quand une condition interne doit interrompre le travail tôt, par exemple « on a déjà une réponse » ou « le client s'est déconnecté ».
Appelez toujours la fonction cancel, typiquement avec defer cancel() juste après avoir créé le contexte dérivé. L'annulation libère le timer et donne un signal d'arrêt clair au travail enfant, surtout dans les chemins de code qui retournent tôt avant que la deadline ne se déclenche.
Créez le contexte de requête une fois dans le handler et passez-le en premier argument aux fonctions qui peuvent bloquer. Un contrôle rapide consiste à chercher context.Background() ou context.TODO() dans les chemins de requête ; ils cassent souvent la propagation de l'annulation en déconnectant le travail de la deadline du handler.
Utilisez les méthodes compatibles contexte de database/sql comme QueryContext, QueryRowContext et ExecContext (ou les équivalents du pilote que vous utilisez). Quand le contexte se termine, le driver peut demander à PostgreSQL d'annuler la requête pour éviter de continuer à consommer du temps et des connexions après la fin de la requête.
Attachez le contexte parent de la requête à la requête sortante avec http.NewRequestWithContext(ctx, ...), et configurez aussi des timeouts sur le client/transport pour vous protéger durant la connexion, le TLS et l'attente des en-têtes. Même en cas d'erreur ou de réponse non-200, fermez toujours le corps de la réponse afin que les connexions retournent au pool.
Choisissez d'abord un budget total pour la requête, puis allouez à chaque dépendance une tranche plus petite qui s'y inscrit, en laissant une petite marge pour le handler et l'encodage de la réponse. Si le contexte parent n'a presque plus de temps, évitez de lancer un travail coûteux qui ne peut raisonnablement pas finir avant la deadline.
Un mapping commun consiste à convertir context.DeadlineExceeded en 504 Gateway Timeout avec un message court comme « request timed out ». Pour context.Canceled, cela signifie généralement que le client s'est déconnecté ; souvent la meilleure action est d'arrêter le travail et de ne pas écrire de corps, afin de ne pas gaspiller davantage de ressources.
Les erreurs les plus fréquentes sont : abandonner le contexte de requête en utilisant context.Background(), démarrer des retries ou des sleeps sans vérifier ctx.Done(), et oublier d'attacher ctx aux appels bloquants. Un autre problème subtil est d'empiler de nombreux timeouts indépendants partout, ce qui rend les échecs difficiles à raisonner et peut provoquer des coupures inopinées.