Incident en production · ASP.NET Core · Juillet 2026

Éliminer une race condition sur les en-têtes HttpClient en production

Sous concurrence réelle, une plateforme ASP.NET Core multi-tenant en production a commencé à échouer d'une façon que personne ne parvenait à reproduire seul : erreurs de barre latérale, HTTP 500, redirections vers le login. J'ai remonté la panne à travers la télémétrie Azure et la pile HTTP jusqu'aux en-têtes partagés et mutables d'un HttpClient singleton, je les ai remplacés par des messages propres à chaque requête, j'ai couvert la race par des tests et j'ai livré le correctif sans modifier aucune interface.

Mon rôle
Diagnostic, correctif, tests de régression et documentation de release, de bout en bout.
Stack
  • ASP.NET Core
  • C#
  • HttpClient
  • Polly
  • Azure Application Insights
  • NUnit

En bref

  • Cause racine trouvée à partir de la télémétrie Azure et des stack traces, pas d'une reproduction locale
  • 12 tests de régression ciblés, dont 2 utilisateurs émettant 40 appels simultanés sur le même client
  • Après le déploiement, les échecs ne se sont plus reproduits (observation opérationnelle)

Contexte

L'hôte web d'une plateforme ASP.NET Core multi-tenant appelle une API séparée pour le compte de l'utilisateur connecté, en transmettant son JWT ainsi qu'un en-tête de tenant/environnement. Les classes de service partageaient un seul HttpClient singleton et modifiaient ses en-têtes par défaut avant chaque appel.

Sous concurrence réelle — plusieurs utilisateurs connectés, appels qui se chevauchent — la production a commencé à afficher des erreurs de barre latérale, des réponses HTTP 500 et des redirections inattendues vers la page de login. Application Insights enregistrait des dépendances en échec avec le code résultat 0 et une NullReferenceException dans la routine d'écriture des en-têtes de la connexion HTTP.

Ce que j'ai fait

  • J'ai corrélé la télémétrie Azure, les stack traces, l'historique du code et la configuration de l'injection de dépendances jusqu'à ce que l'état partagé mutable ressorte comme facteur commun.
  • J'ai identifié la mutation concurrente des en-têtes par défaut du client comme cause, en nommant aussi le risque de sécurité impliqué : un appel qui se chevauche pouvait en théorie porter le bearer token ou les valeurs de tenant d'une autre requête.
  • J'ai remplacé la mutation des en-têtes partagés par un nouveau HttpRequestMessage à chaque tentative, envoyé via SendAsync, avec les en-têtes d'autorisation, de tenant, d'accept et de clé API attachés à ce message précis.
  • J'ai recréé le corps de la requête à chaque retry Polly, corrigé la propagation de la valeur d'environnement consommée une fois puis perdue, et empêché qu'un lookup échoué du catalogue de pages soit écrit en cache — les résultats valides portent désormais un TTL explicite de cinq minutes.
  • J'ai écrit la suite de régression ciblée et documenté le changement pour la release.

Décisions

Des messages par requête, pas des clients par requête

Un client par appel aurait supprimé la race mais ouvert la porte à l'épuisement des sockets et à une réécriture de l'enregistrement. Garder le singleton et déplacer chaque valeur par utilisateur sur le HttpRequestMessage a laissé intactes les interfaces publiques et la configuration DI.

Aucun changement d'interface

Les contrats du frontend, du BFF et de l'API sont restés identiques : le correctif est parti dans une release normale, avec un rollback trivial.

Fail closed

L'API continue de répondre 401 quand l'authentification manque ou est invalide ; le correctif n'ajoute aucun fallback susceptible de masquer un token absent, et les tokens restent hors des logs d'erreur.

Ne jamais mettre un échec en cache

Un échec temporaire du catalogue de pages était stocké comme un résultat valide et polluait la barre latérale pour les requêtes suivantes. Seuls les résultats valides sont désormais mis en cache, avec un TTL borné.

La difficulté concrète

La panne était non déterministe. Elle exigeait un vrai chevauchement entre utilisateurs et n'apparaissait jamais en session isolée : les preuves devaient être assemblées à partir des motifs de télémétrie et des entrailles de la pile HTTP, pas d'un débogueur.

Les tests devaient forcer la race volontairement : deux utilisateurs simulés émettant 40 appels concurrents sur le même client singleton, avec vérification que chaque appel portait son propre bearer et ses propres valeurs de tenant, plus des cas sur les tâches sœurs et le comportement du cache.

Résultat

  • 12 tests de régression ciblés sur 12 réussis : scénario 2 utilisateurs × 40 appels, isolation du bearer et de l'environnement, tâches sœurs et comportement du cache.
  • Build Release sans erreur ; la suite complète rapportait 443 tests réussis et 10 échecs préexistants sur 453, non modifiés par ce travail.
  • Aucun contrat public du frontend, du BFF ou de l'API n'a changé.
  • Après le déploiement, les échecs de barre latérale et de login ne se sont plus reproduits et les erreurs correspondantes dans Application Insights n'ont plus été observées.

Les chiffres, avec leurs limites

  • Tests de régression ciblés12 / 12 réussis
  • Scénario de concurrence2 utilisateurs × 40 appels simultanés
  • Suite complète (build Release)443 / 453 réussis10 échecs préexistants, inchangés
  • Contrats publics modifiés0

Ce que ces chiffres ne disent pas

  • Le succès post-déploiement est une observation opérationnelle : les échecs visibles par les utilisateurs ont cessé et les erreurs de télémétrie ont disparu. Aucune métrique de monitoring exportée n'a été conservée.
  • L'exposition croisée des tokens était un risque crédible créé par les en-têtes partagés, pas un incident documenté : aucun échange réel d'identifiants n'a été observé.
  • Les comptages de tests sont les résultats historiques de juillet 2026, pas une réexécution faite pour cette page.

Ce que ça prouve

Je sais remonter une panne de production à travers la télémétrie, les entrailles du framework et du code legacy, la corriger sans casser un contrat, et prouver le correctif avec des tests qui forcent la race.

Attribution

Le diagnostic, le correctif et les tests sont mon travail. Le déploiement a suivi le processus de release habituel de l'équipe.