Fiabilité5 min de lecture

Timeout des appels HTTP : prévention du blocage des sockets et résilience réseau

Comprendre pourquoi l'absence de timeout sur les appels fetch ou axios paralyse les serveurs Node.js, et comment configurer des limites réseau et métier avec AbortController.

Dans l'écosystème JavaScript moderne, fetch est devenu l'interface universelle pour interagir avec des services tiers, des microservices et des bases de données distantes. Cependant, une caractéristique de l'API standard fetch surprend de nombreux développeurs : la spécification ne fournit pas une option standard universelle timeout.

Les comportements réseau effectifs (délais d'expiration TCP, seuils de coupure socket ou garde-fous de proxy) dépendent du runtime (Node.js, Bun, navigateurs), du client HTTP sous-jacent et de l'infrastructure d'hébergement. Sans borne explicite, une connexion réseau bloquée ou un serveur distant défaillant peut maintenir des ressources ouvertes bien plus longtemps que souhaité.

L'utilisation d'AbortSignal ou d'AbortSignal.timeout() permet précisément d'imposer une borne applicative contrôlée lorsque ces mécanismes sont supportés.


Pourquoi borner l'attente est vital en environnement serveur

Sur un serveur applicatif (comme une route d'API Next.js ou un serveur Express), chaque requête entrante consomme des ressources système : un descripteur de socket réseau, un enregistrement dans la table de connexion TCP du système d'exploitation, et de la mémoire vive associée au contexte de la promesse JavaScript.

sequenceDiagram
    participant Client as Navigateur Client
    participant Server as Serveur Next.js
    participant API as API Externe Défaillante

    Client->>Server: Requête HTTP (Consommation de thread)
    Server->>API: fetch() sans borne applicative
    Note over API: Panne réseau / Blocage TCP
    Note over Server: Socket mobilisé anormalement longtemps
    Note over Client: En attente (Gateway Timeout 504)
    Note over Server: Épuisement des descripteurs de fichiers
    Server-->>Client: Dégradation ou indisponibilité du service

Lorsqu'un service externe subit une panne de connectivité ou un ralentissement sévère :

  1. Les requêtes fetch initiées sans borne applicative restent ouvertes et accumulent les contextes d'exécution.
  2. Le pool de connexions HTTP du serveur local s'engorge progressivement.
  3. Les nouvelles requêtes de vos propres utilisateurs légitimes sont ralenties ou mises en file d'attente jusqu'à l'émission d'erreurs 504 Gateway Timeout par votre proxy inverse (Cloudflare, Vercel ou NGINX).

Timeout vs Retry : deux piliers complémentaires

Il est primordial de ne pas confondre Timeout et Retry :

  • Le Timeout définit la durée maximale pendant laquelle votre application accepte d'attendre une réponse avant de considérer l'opération comme échouée et de libérer immédiatement les ressources allouées.
  • Le Retry (voir Politiques de retry non borné) définit l'action corrective à mener une fois que l'échec (éventuellement causé par un timeout) est constaté.

Un retry sans timeout est totalement inopérant si la requête initiale reste suspendue pendant 15 minutes dans le vide.


L'implémentation moderne : AbortController et AbortSignal.timeout

Depuis Node.js 18+ et dans tous les runtimes modernes compatibles avec les standards Web, la gestion des timeouts s'effectue au moyen de l'objet AbortController ou de son raccourci moderne AbortSignal.timeout().

1. La méthode concise moderne : AbortSignal.timeout()

Idéale pour les appels simples sans logique d'annulation externe :

export async function fetchCatalog(category: string) {
  try {
    const response = await fetch(`https://api.fournisseur.com/items?cat=${category}`, {
      // Interrompt automatiquement la connexion si aucun octet n'est reçu après 4000ms
      signal: AbortSignal.timeout(4000),
    });

    if (!response.ok) {
      throw new Error(`Erreur HTTP: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    if (error instanceof Error && error.name === "TimeoutError") {
      console.error("L'API fournisseur a dépassé le seuil de tolérance de 4s.");
      // Déclencher un fallback gracieux ou retourner des données en cache
      return getCachedCatalog(category);
    }
    throw error;
  }
}

2. La méthode historique polyvalente : AbortController explicite

Nécessaire si vous devez combiner un timeout temporel avec une interruption manuelle (par exemple lors de l'arrêt gracieux du serveur) :

export async function queryExternalService(payload: unknown, timeoutMs = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const res = await fetch("https://api.tiers.com/v1/process", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
      signal: controller.signal,
    });
    return await res.json();
  } catch (error) {
    if (error instanceof Error && error.name === "AbortError") {
      throw new Error(`La requête vers le service tiers a été interrompue après ${timeoutMs}ms`);
    }
    throw error;
  } finally {
    // Nettoyer impérativement le timer pour ne pas maintenir le process Node actif
    clearTimeout(timer);
  }
}

Timeout réseau vs Timeout métier

Une distinction technique essentielle doit guider votre architecture :

Le timeout réseau (Transport)

Il mesure le temps nécessaire pour établir la connexion TCP/TLS et commencer à recevoir les paquets de données. Il protège l'infrastructure contre les pannes d'infrastructure brute et les routes réseau coupées.

Le timeout métier (End-to-End)

Il englobe l'intégralité du traitement : acheminement, exécution d'une requête SQL complexe sur le serveur tiers, génération de la réponse et téléchargement de la charge utile.

  • Un appel à un webhook de notification doit avoir un timeout court (ex. 2 à 3 secondes).
  • Un appel de génération par un modèle d'IA ou une conversion de document PDF peut nécessiter un timeout de 30 à 60 secondes. Dans ce cas, l'appel doit impérativement être traité de manière asynchrone (via une file de messages ou un job en arrière-plan) plutôt que de bloquer une requête HTTP synchrone de l'utilisateur final.

Attention aux effets de bord critiques lors d'un timeout

Un timeout interrompt l'attente du client, mais n'interrompt pas nécessairement l'exécution du travail sur le serveur distant :

  • Si vous envoyez une requête de virement bancaire ou de débit Stripe et que votre client subit un timeout après 5 secondes, le serveur distant a peut-être déjà débité la carte avant que le réseau ne coupe.
  • Ne rejouez jamais un appel non idempotent ayant subi un timeout sans disposer d'une clé d'idempotence stricte (consultez le guide sur la sécurité des webhooks Stripe).

Ce que GVO observe lors de l'audit

L'analyse de code GVO vérifie systématiquement la configuration des appels réseau sortants dans les API routes, actions de serveurs et modules d'intégration :

  • GVO-REL-HTTP-TIMEOUT-V1 : Détecte les invocations de fetch, axios, got ou d'instances HTTP natives ne transmettant aucun paramètre de timeout ni signal d'interruption (AbortSignal ou signal).
  • Le moteur signale le risque d'épuisement du pool de connexions et recommande l'injection systématique de AbortSignal.timeout(ms) ajusté selon la criticité du flux.

Sources et références

  • MDN Web Docs : Fetch API & AbortSignal: timeout() static method.
  • Node.js Documentation : Globals - AbortSignal & Resource Management.
  • Cloudflare Workers Docs : Subrequests limits, connection pooling, and cancellation.
  • W3C Fetch Specification : Fetching with abort signals and controller semantics.

Vérifier ce point sur votre projet

GVO confronte le code et les configurations de votre application aux règles de sécurité réelles.