Un appel qui ne répond pas n’échoue pas vraiment : il reste en suspens, et c’est précisément ce qui le rend dangereux. Tôt ou tard, un service externe va ralentir, hoqueter, ou cesser purement et simplement de répondre, et sans limite de temps, votre application l’attendra indéfiniment, ressources mobilisées. La question n’est pas de savoir si cela arrivera, mais comment votre application réagira le jour où ça arrivera.
Ce deuxième volet de la série sur la résilience prolonge le pattern Retry vu précédemment avec un mécanisme à la fois simple et redoutablement efficace : le Timeout. À lui seul, il protège votre système contre l’une des défaillances les plus insidieuses en production : la dégradation en cascade.
Le problème : une attente qui n’est jamais gratuite
Dès qu’on interagit avec un service externe (base de données, API HTTP, gRPC, broker de messages), on dépend d’un système sur lequel on n’a aucun contrôle. Ce service peut prendre un temps conséquent à répondre, voire ne jamais répondre du tout. L’interaction est, par nature, non-déterministe : rien ne garantit qu’un appel qui répond en 50 ms aujourd’hui ne mettra pas 30 secondes demain.
Or cette attente n’est jamais gratuite. Tant qu’un appel n’a pas abouti, des ressources restent mobilisées des deux côtés :
- côté client, notre application immobilise des threads, des sockets, des connexions ;
- côté serveur, le service externe continue lui aussi de consommer des ressources pour traiter une requête dont le client attend toujours la réponse.
La dégradation en cascade
Le vrai danger n’est pas la lenteur d’un appel isolé, c’est ce qu’elle déclenche. En subissant passivement l’instabilité d’un service externe, on commence à importer sa défaillance dans notre propre système. Les requêtes en attente s’accumulent, les ressources se raréfient, la mémoire grimpe, et de proche en proche tout l’ensemble ralentit. Un seul dépendant qui traîne peut ainsi faire basculer une application entière.
C’est précisément pour couper court à cet effet domino qu’il faut prendre les devants et fixer une limite stricte à la durée de chaque interaction : un timeout.
Chaque interaction externe mérite son timeout
La règle est simple et ne souffre pas d’exception : toute communication avec l’extérieur doit être bornée dans le temps.
- une requête en base de données = un timeout ;
- une requête HTTP ou gRPC = un timeout ;
- une requête à un broker = un timeout.
Attention au faux sentiment de sécurité : certains protocoles imposent bien un timeout par défaut, mais celui-ci est souvent trop long ou totalement déconnecté de votre contexte métier. Un timeout par défaut de plusieurs minutes ne vous protège de rien dans une API censée répondre en moins d’une seconde. Mieux vaut donc le configurer explicitement plutôt que de s’en remettre à une valeur arbitraire.
En pratique
La bonne nouvelle, c’est que le timeout est un mécanisme de première classe dans la plupart des environnements. Sur la plateforme web, par exemple, AbortSignal.timeout() permet d’annuler proprement un fetch. Et la majorité des clients de bases de données ou de caches exposent leurs propres options de timeout, qu’il suffit de renseigner.
/**
* Simple mais efficace, le pattern Timeout permet de limiter
* la durée d'une opération.
* On peut utiliser AbortSignal#timeout pour les APIs qui le
* supportent, comme la Web API fetch.
*/
function networkCallWithTimeout() {
const timeout = AbortSignal.timeout(10_000);
return fetch("https://jsonplaceholder.typicode.com/todos/1", {
signal: timeout,
// ^^^^^^ timeout expiré => "[TimeoutError]: The operation was aborted due to timeout"
});
}
/**
* Autrement, ne pas oublier de configurer des timeouts avec
* les libs qui le permettent et d'ajuster les durées en
* fonction des besoins.
*/
knex
.select()
.from("books")
.timeout(10_000, { cancel: true });
const redis = new Redis({
host: "localhost",
port: 6379,
connectTimeout: 2000,
commandTimeout: 1000,
});
/**
* On peut ensuite combiner un Timeout avec le pattern Retry.
* On limite la durée d'une opération avec le Timeout, tout en
* réessayant en cas d'échec, que l'échec soit lié ou non au
* timeout.
*/
import { Duration, Effect, pipe, Schedule } from "effect";
const networkCallWithTimeoutAndRetry = pipe(
Effect.tryPromise(networkCallWithTimeout),
Effect.retry(Schedule.exponential(Duration.seconds(1), 2))
);
Le combo gagnant : Retry + Timeout
Le Timeout ne s’oppose pas au Retry vu dans l’épisode précédent : les deux patterns se complètent à merveille. En bornant chaque tentative dans le temps puis en réessayant, vous obtenez le meilleur des deux mondes : chaque essai est garanti de se terminer rapidement, et un échec passager ne condamne pas l’opération. À l’inverse, retenter sans timeout reviendrait à empiler des requêtes qui n’aboutissent jamais et à saturer le système encore plus vite.
Une annulation qui doit être honorée des deux côtés
Un point souvent négligé mérite qu’on s’y arrête : un timeout côté client ne libère pas magiquement les ressources côté serveur. Le service externe a la responsabilité de réagir à l’annulation pour que le bénéfice soit complet. Le scénario à viser ressemble à ceci :
- le client initie une requête HTTP avec un timeout défini ;
- le serveur démarre le traitement et s’abonne aux événements de la socket TCP ;
- le timeout côté client se déclenche, la socket est fermée ;
- le serveur reçoit l’événement de fermeture et peut, doit, libérer les ressources associées à la requête.
Sans cette quatrième étape, seul le client profite du timeout : le serveur, lui, continue de travailler dans le vide pour une réponse que plus personne n’attend. La coupure n’est vraiment propre que lorsque les deux extrémités jouent le jeu.
Choisir la bonne valeur dépend du contexte
Il n’existe pas de timeout universel. Pour certains services, une latence de 30 secondes est parfaitement normale ; pour d’autres, 5 secondes c’est déjà bien trop. La bonne valeur dépend entièrement de la nature de l’appel et des attentes côté métier.
C’est pourquoi le réglage d’un timeout ne devrait pas être une décision purement technique prise en isolation. Discutez-en avec les experts métier : savoir combien de temps une opération peut raisonnablement durer avant d’être considérée comme perdue est autant une question business qu’une question d’ingénierie. La résilience se construit aussi à ce niveau-là.
Conclusion
Le Timeout est l’un des patterns les plus simples à mettre en place, et pourtant l’un des plus rentables. En bornant chaque interaction externe dans le temps, vous empêchez la lenteur d’un dépendant de contaminer tout votre système, vous libérez vos ressources au plus vite et vous coupez net la dégradation en cascade avant qu’elle ne s’installe.
Associé au Retry, il forme un socle de résilience solide sur lequel viendront s’appuyer les patterns suivants de la série. La règle à retenir tient en une phrase : aucune interaction externe ne devrait pouvoir attendre indéfiniment.