Timeouts de contexte Go pour les API : du handler HTTP au SQL
Les timeouts de contexte en Go permettent de propager une deadline du handler HTTP jusqu'aux appels SQL, d'éviter les requêtes bloquées et de maintenir la stabilité sous charge.

Pourquoi les requêtes se bloquent (et pourquoi c'est pénalisant sous charge)
Une requête est "bloquée" quand elle attend quelque chose qui ne renvoie pas : une requête base de données lente, une connexion de pool bloquée, un problème DNS ou un service en amont qui accepte l'appel mais ne répond jamais.
Le symptôme est simple : certaines requêtes prennent une éternité, puis d'autres s'entassent derrière elles. On observe souvent une mémoire qui augmente, un nombre croissant de goroutines et une file de connexions ouvertes qui ne se vide jamais.
Sous charge, les requêtes bloquées font double peine. Elles occupent des workers et retiennent des ressources rares comme des connexions DB ou des verrous. Cela rend des requêtes normalement rapides lentes, ce qui crée plus de recouvrement, puis encore plus d'attente.
Les retries et les pics de trafic aggravent cette spirale. Un client timeoute et relance la requête alors que l'originale est toujours en cours, donc vous payez pour deux requêtes. Multipliez cela par de nombreux clients lors d'un bref ralentissement et vous pouvez surcharger la base ou atteindre les limites de connexions même si le trafic moyen est raisonnable.
Un timeout est simplement une promesse : "nous n'attendrons pas plus de X." Il permet d'échouer vite et de libérer des ressources, mais il n'accélère pas l'exécution du travail.
Il ne garantit pas non plus que le travail s'arrête instantanément. Par exemple, la base de données peut continuer l'exécution, un service en amont peut ignorer votre annulation, ou votre code lui‑même peut ne pas être sûr quand une annulation arrive.
Ce qu'un timeout garantit, c'est que votre handler peut arrêter d'attendre, renvoyer une erreur claire et libérer ce qu'il tient. Cette attente bornée empêche quelques appels lents de se transformer en panne complète.
L'objectif avec les timeouts de contexte Go est d'avoir une seule deadline partagée de la bordure jusqu'à l'appel le plus profond. Définissez‑la une fois à la frontière HTTP, transmettez le même contexte dans le code du service et utilisez‑le dans database/sql pour que la base sache aussi quand arrêter d'attendre.
Le contexte en Go en termes simples
Un context.Context est un petit objet que vous passez dans votre code pour décrire ce qui se passe maintenant. Il répond à des questions comme : "Cette requête est‑elle toujours valide ?", "Quand devons‑nous abandonner ?", et "Quelles valeurs liées à la requête doivent accompagner ce travail ?"
Le grand avantage est qu'une décision prise à la frontière de votre système (votre handler HTTP) peut protéger chaque étape en aval, tant que vous continuez à passer le même contexte.
Ce que transporte le contexte
Le contexte n'est pas un endroit pour des données métier. Il sert de signaux de contrôle et d'un petit ensemble de métadonnées liées à la requête : annulation, deadline/timeout et de petites infos comme un ID de requête pour les logs.
Timeout vs annulation est simple : un timeout est une raison d'annuler. Si vous définissez un timeout de 2 secondes, le contexte sera annulé au bout de 2 secondes. Mais un contexte peut aussi être annulé plus tôt si l'utilisateur ferme l'onglet, le load balancer coupe la connexion, ou votre code décide d'arrêter la requête.
Le contexte circule dans les appels de fonction en étant un paramètre explicite, généralement le premier : func DoThing(ctx context.Context, ...). C'est le but. Il est difficile de l'"oublier" quand il apparaît à chaque point d'appel.
Quand la deadline expire, tout ce qui observe ce contexte devrait s'arrêter rapidement. Par exemple, une requête SQL utilisant QueryContext devrait revenir tôt avec une erreur comme context deadline exceeded, et votre handler peut répondre par un timeout au lieu d'attendre que le serveur manque de workers.
Un bon modèle mental : une requête, un contexte, passé partout. Si la requête meurt, le travail devrait mourir aussi.
Définir une deadline claire à la frontière HTTP
Si vous voulez que les timeouts de bout en bout fonctionnent, décidez d'où démarre l'horloge. L'endroit le plus sûr est à la bordure HTTP, ainsi chaque appel en aval (logique métier, SQL, autres services) hérite de la même deadline.
Vous pouvez définir cette deadline à plusieurs niveaux. Les timeouts au niveau serveur sont une bonne baseline et vous protègent des clients lents. Le middleware est idéal pour la cohérence entre groupes de routes. La définir dans le handler est aussi acceptable quand vous voulez quelque chose d'explicite et local.
Pour la plupart des API, des timeouts par requête en middleware ou dans le handler sont les plus simples à raisonner. Gardez‑les réalistes : les utilisateurs préfèrent un échec rapide et clair plutôt qu'une requête qui reste bloquée. Beaucoup d'équipes utilisent des budgets plus courts pour les lectures (1–2s) et un peu plus longs pour les écritures (3–10s), selon ce que fait l'endpoint.
Voici un pattern simple de handler :
func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
report, err := s.reports.Generate(ctx, r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusGatewayTimeout)
return
}
json.NewEncoder(w).Encode(report)
}
Deux règles maintiennent cela efficace :
- Appelez toujours
cancel()pour que timers et ressources soient libérés rapidement. - Ne remplacez jamais le contexte de la requête par
context.Background()oucontext.TODO()à l'intérieur du handler. Cela casse la chaîne, et vos appels DB et sortants peuvent s'exécuter indéfiniment même après le départ du client.
Propager le contexte à travers votre code
Une fois la deadline fixée à la bordure HTTP, le vrai travail est de s'assurer que cette même deadline atteint chaque couche qui peut bloquer. L'idée est une seule horloge, partagée par le handler, le code métier et tout ce qui touche le réseau ou le disque.
Une règle simple maintient la cohérence : chaque fonction susceptible d'attendre doit accepter un context.Context, et il doit être en premier paramètre. Cela le rend évident aux points d'appel et ça devient une habitude.
Un pattern de signature pratique
Préférez des signatures comme DoThing(ctx context.Context, ...) pour les services et repositories. Évitez de cacher le contexte dans des structs ou de le recréer avec context.Background() dans les couches basses, car cela fait disparaître silencieusement la deadline de l'appelant.
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := h.svc.CreateOrder(ctx, r.Body); err != nil {
// map context errors to a clear client response elsewhere
http.Error(w, err.Error(), http.StatusRequestTimeout)
return
}
}
func (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {
// parsing or validation can still respect cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return s.repo.InsertOrder(ctx, /* data */)
}
Gérer proprement les sorties anticipées
Traitez ctx.Done() comme un chemin de contrôle normal. Deux habitudes aident :
- Vérifier
ctx.Err()avant de lancer un travail coûteux et après les longues boucles. - Remonter
ctx.Err()inchangé, afin que le handler puisse répondre vite et cesser de gaspiller des ressources.
Quand chaque couche transmet le même ctx, un seul timeout peut interrompre le parsing, la logique métier et les attentes DB en une seule fois.
Appliquer des deadlines aux requêtes database/sql
Une fois que votre handler HTTP a une deadline, assurez‑vous que le travail base de données l'écoute réellement. Avec database/sql, cela signifie utiliser les méthodes aware du contexte à chaque fois. Si vous appelez Query() ou Exec() sans contexte, votre API peut continuer d'attendre une requête lente même après que le client a abandonné.
Utilisez systématiquement : db.QueryContext, db.QueryRowContext, db.ExecContext et db.PrepareContext (puis QueryContext/ExecContext sur l'objet statement retourné).
func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, email FROM users WHERE id = $1`, id,
)
var u User
if err := row.Scan(&u.ID, &u.Email); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET email = $1 WHERE id = $2`, email, id,
)
return err
}
Deux points faciles à manquer.
D'abord, le driver SQL doit respecter l'annulation du contexte. Beaucoup le font, mais vérifiez dans votre stack en testant une requête lente et en confirmant qu'elle s'annule rapidement quand la deadline est dépassée.
Ensuite, pensez à un timeout côté base de données comme filet de sécurité. Par exemple, Postgres peut imposer une limite par instruction (statement timeout). Cela protège la base même si un bug d'application oublie de passer le contexte quelque part.
Quand une opération s'arrête à cause d'un timeout, traitez‑la différemment d'une erreur SQL normale. Vérifiez errors.Is(err, context.DeadlineExceeded) et errors.Is(err, context.Canceled) et renvoyez une réponse claire (comme un 504) plutôt que de considérer que "la base est en panne". Si vous générez des backends Go (par exemple avec AppMaster), garder ces chemins d'erreur distincts facilite aussi la lecture des logs et la raison des retries.
Appels en aval : clients HTTP, caches et autres services
Même si votre handler et vos requêtes SQL respectent le contexte, une requête peut encore bloquer si un appel en aval attend indéfiniment. Sous charge, quelques goroutines bloquées peuvent s'empiler, épuiser les pools de connexion et transformer un petit ralentissement en panne complète. La solution est une propagation cohérente plus un filet dur.
HTTP sortant
Lors d'un appel à une autre API, construisez la requête avec le même contexte pour que le délai et l'annulation se propagent automatiquement.
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { /* handle */ }
resp, err := httpClient.Do(req)
Ne comptez pas que sur le contexte. Configurez aussi le client HTTP et le transport pour être protégé si du code utilise accidentellement un background context, ou si DNS/TLS/connexions inactives se bloquent. Définissez http.Client.Timeout comme borne supérieure pour l'appel entier, configurez les timeouts du transport (dial, TLS handshake, headers) et réutilisez un client unique plutôt que d'en créer un par requête.
Caches et queues
Les caches, brokers de messages et clients RPC ont souvent leurs propres points d'attente : acquisition d'une connexion, attente d'une réponse, blocage sur une file pleine, ou attente d'un verrou. Assurez‑vous que ces opérations acceptent ctx, et utilisez aussi les timeouts fournis par les bibliothèques quand c'est possible.
Règle pratique : si la requête utilisateur n'a plus que 800ms, ne lancez pas un appel aval qui pourrait prendre 2s. Sautez‑le, dégradez ou renvoyez une réponse partielle.
Décidez en amont ce que signifie un timeout pour votre API. Parfois la bonne réponse est une erreur rapide. Parfois ce sont des données partielles pour des champs optionnels. Parfois ce sont des données mises en cache, clairement indiquées comme obsolètes.
Si vous construisez des backends Go (y compris générés, comme avec AppMaster), ceci fait la différence entre "les timeouts existent" et "les timeouts protègent systématiquement le système" quand le trafic augmente.
Étapes : refactorer une API pour utiliser des timeouts de bout en bout
Refactorer pour les timeouts revient à une habitude : passer le même context.Context de la bordure HTTP jusqu'à chaque appel qui peut bloquer.
Une façon pratique de procéder est de travailler de haut en bas :
- Changez vos handlers et méthodes de service pour accepter
ctx context.Context. - Mettez à jour chaque appel DB pour utiliser
QueryContextouExecContext. - Faites de même pour les appels externes (clients HTTP, caches, queues). Si une bibliothèque n'accepte pas
ctx, adaptez‑la ou remplacez‑la. - Décidez qui possède les timeouts. Une règle commune : le handler fixe la deadline globale ; les couches inférieures ne doivent créer que des deadlines plus courtes quand c'est nécessaire.
- Rendez les erreurs prévisibles à la frontière : mappez
context.DeadlineExceededetcontext.Canceledà des réponses HTTP claires.
Voici la forme souhaitée à travers les couches :
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
order, err := h.svc.GetOrder(ctx, r.PathValue("id"))
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(order)
}
func (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {
row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)
// scan...
}
Les valeurs de timeout doivent être ennuyeuses et cohérentes. Si le handler a 2 secondes au total, gardez les requêtes DB sous 1 seconde pour laisser de la marge pour l'encodage JSON et autres travaux.
Pour prouver que cela fonctionne, ajoutez un test qui force un timeout. Une approche simple est une méthode de repository factice qui bloque jusqu'à ctx.Done() puis renvoie ctx.Err(). Votre test doit vérifier que le handler renvoie rapidement un 504, pas après le délai factice.
Si vous générez des backends Go (par exemple AppMaster), la règle reste la même : un contexte par requête, passé partout, avec une responsabilité claire de la deadline.
Observabilité : prouver que les timeouts fonctionnent
Les timeouts n'aident que si vous pouvez les voir. L'objectif est simple : chaque requête a une deadline, et quand elle échoue vous pouvez savoir où le temps a été passé.
Commencez par des logs sûrs et utiles. Plutôt que d'inspecter tout le corps de la requête, consignez assez d'informations pour relier les éléments et repérer les chemins lents : ID de requête (ou trace ID), si une deadline est définie et combien de temps il reste à des points clés, le nom de l'opération (handler, nom de requête SQL, appel sortant), et la catégorie du résultat (ok, timeout, canceled, autre erreur).
Ajoutez quelques métriques ciblées pour que le comportement sous charge soit évident :
- Nombre de timeouts par endpoint et dépendance
- Latence des requêtes (p50/p95/p99)
- Requêtes en vol
- Latence des requêtes DB (p95/p99)
- Taux d'erreur ventilé par type
Quand vous traitez les erreurs, taggez‑les correctement. context.DeadlineExceeded signifie généralement que vous avez atteint votre budget. context.Canceled veut souvent dire que le client est parti ou qu'un timeout en amont s'est déclenché. Gardez ces cas séparés car les corrections diffèrent.
Tracing : trouver où le temps est perdu
Les spans de tracing devraient suivre le même contexte du handler HTTP jusqu'aux appels database/sql comme QueryContext. Par exemple, une requête timeoute à 2s et le trace montre 1.8s passées à attendre une connexion DB. Cela indique un problème de pool ou de transactions lentes, pas forcément le texte de la requête.
Si vous créez un tableau de bord interne (timeouts par route, requêtes lentes), un outil no‑code comme AppMaster peut aider à le livrer rapidement sans faire de l'observabilité un projet séparé.
Erreurs courantes qui annulent vos timeouts
La plupart des bugs « ça reste parfois bloqué » viennent de quelques erreurs petites mais fréquentes.
- Réinitialiser l'horloge en cours de route. Un handler fixe 2s, mais le repository crée un contexte frais avec son propre timeout (ou sans timeout). La DB peut alors continuer après le départ du client. Passez le
ctxentrant et ne le resserrez que pour une raison explicite. - Lancer des goroutines qui ne s'arrêtent jamais. Démarrer du travail avec
context.Background()(ou sans ctx) signifie qu'il continuera même après l'annulation de la requête. Passez le ctx de la requête aux goroutines etselectsurctx.Done(). - Deadlines trop courtes pour le trafic réel. Un timeout de 50ms peut marcher sur votre laptop et échouer en production pendant un petit pic, provoquant des retries et une mini‑panne auto‑infligée. Choisissez des timeouts basés sur la latence normale plus une marge.
- Cacher la vraie erreur. Traiter
context.DeadlineExceededcomme un 500 générique complique le debug et le comportement client. Mappez‑le sur une réponse de timeout claire et logguez la différence entre « annulé par le client » et « timeout ». - Laisser des ressources ouvertes lors d'une sortie anticipée. Si vous retournez tôt, assurez‑vous quand même de
defer rows.Close()et d'appeler la fonction cancel decontext.WithTimeout. Des rows non fermées ou un travail persistant peuvent épuiser les connexions sous charge.
Un exemple rapide : un endpoint lance une requête de rapport. Si l'utilisateur ferme l'onglet, le ctx du handler est annulé. Si votre appel SQL utilisait un nouveau background context, la requête continue, bloquant une connexion et ralentissant tout le monde. En transmettant le même ctx à QueryContext, l'appel DB est interrompu et le système récupère plus vite.
Checklist rapide pour des timeouts fiables
Les timeouts n'aident que s'ils sont cohérents. Un seul appel manqué peut garder une goroutine occupée, retenir une connexion DB et ralentir les requêtes suivantes.
- Définissez une deadline claire à la bordure (généralement le handler HTTP). Tout à l'intérieur de la requête doit l'hériter.
- Passez le même
ctxà travers les couches service et repository. Évitezcontext.Background()dans le code lié aux requêtes. - Utilisez les méthodes DB avec contexte partout :
QueryContext,QueryRowContext,ExecContext. - Attachez le même
ctxaux appels sortants (HTTP, caches, queues). Si vous créez un contexte enfant, qu'il soit plus court, pas plus long. - Gérez les annulations et timeouts de manière cohérente : renvoyez une erreur propre, arrêtez le travail et évitez les boucles de retry à l'intérieur d'une requête annulée.
Ensuite, vérifiez le comportement sous pression. Un timeout qui se déclenche mais ne libère pas les ressources assez vite nuit toujours à la fiabilité.
Les tableaux de bord doivent rendre les timeouts évidents, pas cachés dans des moyennes. Suivez quelques signaux qui répondent à "est‑ce que les deadlines sont réellement appliquées ?" : timeouts de requêtes et DB (séparément), percentiles de latence (p95, p99), stats du pool DB (connexions en cours d'utilisation, nombre d'attentes, durée d'attente) et une ventilation des causes d'erreur (context deadline exceeded vs autres échecs).
Si vous construisez des outils internes sur une plateforme comme AppMaster, la même checklist s'applique à tout service Go que vous y connectez : définir des deadlines à la frontière, les propager et confirmer dans les métriques que les requêtes bloquées deviennent des échecs rapides plutôt que des accumulations lentes.
Scénario d'exemple et prochaines étapes
Un endroit courant où cela paye est un endpoint de recherche. Imaginez GET /search?q=printer qui ralentit quand la base est occupée par une grosse requête de rapport. Sans deadline, chaque requête entrante peut rester assise à attendre une longue requête SQL. Sous charge, ces requêtes bloquées s'accumulent, occupent des goroutines et des connexions, et l'API entière semble gelée.
Avec une deadline claire dans le handler HTTP et le même ctx transmis au repository, le système cesse d'attendre quand le budget est épuisé. À l'expiration, le driver DB annule la requête (quand il le supporte), le handler renvoie, et le serveur peut continuer à servir de nouvelles requêtes au lieu d'attendre indéfiniment.
Le comportement visible par l'utilisateur est meilleur même en cas de problème. Au lieu de tourner pendant 30 à 120 secondes puis échouer de manière désordonnée, le client obtient une erreur rapide et prévisible (souvent un 504 ou 503 avec un message court comme "request timed out"). Plus important : le système récupère vite car les nouvelles requêtes ne sont pas bloquées derrière les anciennes.
Prochaines étapes pour que cela tienne sur tous les endpoints et équipes :
- Choisir des timeouts standards par type d'endpoint (recherche vs écritures vs exports).
- Exiger
QueryContextetExecContexten revue de code. - Rendre les erreurs de timeout explicites à la frontière (code d'état clair, message simple).
- Ajouter des métriques pour les timeouts et annulations afin de détecter les régressions tôt.
- Écrire un helper qui encapsule la création de contexte et le logging pour que chaque handler se comporte de la même façon.
Si vous construisez des services et outils internes avec AppMaster, vous pouvez appliquer ces règles de timeout de manière cohérente à travers des backends Go générés, des intégrations API et des tableaux de bord. AppMaster est disponible à appmaster.io (no-code, with real Go source code generation), ce qui en fait une option pratique si vous voulez un comportement de requête et une observabilité cohérents sans tout construire à la main.
FAQ
Une requête est « bloquée » quand elle attend quelque chose qui ne répond pas : une requête SQL lente, une connexion de pool bloquée, un problème DNS ou un service en amont qui accepte l'appel mais ne renvoie jamais de réponse. Sous charge, les requêtes bloquées s'accumulent, occupent des workers et des connexions, et peuvent transformer un petit ralentissement en panne plus large.
Fixez le délai global à la frontière HTTP et passez ce même ctx à chaque couche qui peut bloquer. Ce délai partagé empêche quelques opérations lentes de monopoliser les ressources assez longtemps pour provoquer un effet boule de neige.
Utilisez ctx, cancel := context.WithTimeout(r.Context(), d) et faites toujours defer cancel() dans le handler (ou le middleware). L'appel à cancel libère les timers et aide à arrêter l'attente rapidement quand la requête se termine plus tôt.
Ne remplacez pas le contexte par context.Background() ou context.TODO() dans le code lié aux requêtes : cela casse l'annulation et les délais. Si vous perdez le contexte de requête, le travail en aval comme les appels SQL ou HTTP sortants peut continuer à s'exécuter même après que le client est parti.
Considérez context.DeadlineExceeded et context.Canceled comme des issues normales et remontez‑les inchangées. À la frontière, mappez‑les sur des réponses claires (souvent 504 pour les timeouts) afin que les clients ne retentent pas aveuglément sur ce qui ressemble à une erreur 500.
Utilisez systématiquement les méthodes avec contexte : QueryContext, QueryRowContext, ExecContext et PrepareContext. Si vous appelez Query() ou Exec() sans contexte, votre handler peut expirer mais l'appel DB risque de continuer à bloquer la goroutine et à retenir une connexion.
Beaucoup de drivers le font, mais vérifiez dans votre stack en lançant une requête délibérément lente et en confirmant qu'elle s'interrompt rapidement après l'expiration du délai. C'est aussi judicieux d'avoir un timeout côté base de données (par exemple un statement timeout Postgres) en dernier ressort si un chemin oublie de transmettre ctx.
Créez la requête sortante avec http.NewRequestWithContext(ctx, ...) pour que le même délai et l'annulation soient appliqués. Configurez aussi le client et le transport HTTP (timeouts, dial, TLS, headers) comme borne supérieure, car le contexte ne vous protège pas si quelqu'un utilise par erreur un background context ou si DNS/TLS se bloque.
Évitez de créer des contextes frais qui étendent le budget temps dans des couches profondes ; si vous créez un contexte enfant, qu'il soit plus court, pas plus long. Si la requête a peu de temps restant, ne lancez pas un appel aval optionnel qui prendrait trop de temps : dégradez gracieusement ou renvoyez des données partielles quand c'est approprié.
Suivez séparément les timeouts et annulations par endpoint et dépendance, avec percentiles de latence et requêtes en vol. Dans les traces, prolongez le même contexte du handler vers les QueryContext pour voir si le temps a été passé à attendre une connexion DB, à exécuter une requête ou à être bloqué sur un autre service.


