Produktionsvorfall · ASP.NET Core · Juli 2026

Eine Race Condition auf HttpClient-Headern in Produktion beseitigen

Unter echter Nebenläufigkeit begann eine produktive mandantenfähige ASP.NET-Core-Plattform auf eine Weise zu scheitern, die niemand allein reproduzieren konnte: Fehler in der Seitenleiste, HTTP 500, Weiterleitungen zum Login. Ich habe den Fehler über die Azure-Telemetrie und den HTTP-Stack bis zu den geteilten, veränderbaren Headern eines Singleton-HttpClient verfolgt, sie durch Nachrichten pro Anfrage ersetzt, die Race mit Tests abgedeckt und die Korrektur ausgeliefert, ohne eine Schnittstelle zu ändern.

Meine Rolle
Diagnose, Korrektur, Regressionstests und Release-Dokumentation, durchgehend.
Stack
  • ASP.NET Core
  • C#
  • HttpClient
  • Polly
  • Azure Application Insights
  • NUnit

Auf einen Blick

  • Ursache aus Azure-Telemetrie und Stack Traces gefunden, nicht aus einer lokalen Reproduktion
  • 12 gezielte Regressionstests, darunter 2 Benutzer mit 40 überlappenden Aufrufen über einen Client
  • Nach dem Deployment traten die Fehler nicht mehr auf (operative Beobachtung)

Kontext

Der Web-Host einer mandantenfähigen ASP.NET-Core-Plattform ruft im Namen des angemeldeten Benutzers eine separate API auf und leitet dessen JWT zusammen mit einem Mandanten-/Umgebungs-Header weiter. Die Service-Klassen teilten sich einen einzigen Singleton-HttpClient und veränderten dessen Standard-Header vor jedem Aufruf.

Unter echter Nebenläufigkeit — mehrere angemeldete Benutzer, überlappende Aufrufe — zeigte die Produktion Fehler in der Seitenleiste, HTTP-500-Antworten und unerwartete Weiterleitungen zur Login-Seite. Application Insights protokollierte fehlgeschlagene Abhängigkeiten mit Result Code 0 und eine NullReferenceException in der Header-Schreibroutine der HTTP-Verbindung.

Was ich getan habe

  • Azure-Telemetrie, Stack Traces, Code-Historie und die Dependency-Injection-Konfiguration korreliert, bis der geteilte veränderbare Zustand als gemeinsamer Faktor hervortrat.
  • Die nebenläufige Veränderung der Standard-Header des Clients als Ursache identifiziert und das damit verbundene Sicherheitsrisiko benannt: Ein überlappender Aufruf konnte theoretisch das Bearer-Token oder die Mandantenwerte einer anderen Anfrage tragen.
  • Die Veränderung geteilter Header durch eine neue HttpRequestMessage pro Versuch ersetzt, gesendet über SendAsync, mit Autorisierungs-, Mandanten-, Accept- und API-Key-Headern an genau dieser Nachricht.
  • Den Anfragekörper bei jedem Polly-Retry neu erzeugt, die Weitergabe des Umgebungswerts korrigiert, der einmal konsumiert und dann verloren ging, und verhindert, dass ein fehlgeschlagener Lookup des Seitenkatalogs in den Cache geschrieben wird — gültige Ergebnisse tragen jetzt eine explizite TTL von fünf Minuten.
  • Die gezielte Regressionssuite geschrieben und die Änderung für das Release dokumentiert.

Entscheidungen

Nachrichten pro Anfrage, nicht Clients pro Anfrage

Ein Client pro Aufruf hätte die Race beseitigt, aber Socket-Erschöpfung und ein Umschreiben der Registrierung riskiert. Den Singleton zu behalten und jeden benutzerspezifischen Wert auf die HttpRequestMessage zu verlagern, liess öffentliche Schnittstellen und DI-Konfiguration unberührt.

Keine Schnittstellenänderung

Die Verträge von Frontend, BFF und API blieben identisch: Die Korrektur ging in einem normalen Release raus, mit trivialem Rollback.

Fail closed

Die API antwortet weiterhin mit 401, wenn die Authentifizierung fehlt oder ungültig ist; die Korrektur fügt keinen Fallback hinzu, der ein fehlendes Token verdecken könnte, und Tokens bleiben aus den Fehlerlogs.

Niemals einen Fehlschlag cachen

Ein vorübergehender Fehler des Seitenkatalogs wurde wie ein gültiges Ergebnis gespeichert und vergiftete die Seitenleiste für spätere Anfragen. Jetzt werden nur gültige Ergebnisse gecacht, mit begrenzter TTL.

Die konkrete Schwierigkeit

Der Fehler war nicht deterministisch. Er brauchte echte Überlappung zwischen Benutzern und trat in einer Einzelsitzung nie auf, also mussten die Belege aus Telemetriemustern und den Interna des HTTP-Stacks zusammengesetzt werden, nicht aus einem Debugger.

Die Tests mussten die Race absichtlich erzwingen: zwei simulierte Benutzer mit 40 gleichzeitigen Aufrufen über denselben Singleton-Client, mit der Prüfung, dass jeder Aufruf sein eigenes Bearer-Token und seine eigenen Mandantenwerte trägt, plus Fälle zu Geschwister-Tasks und Cache-Verhalten.

Ergebnis

  • 12 von 12 gezielten Regressionstests bestanden: Szenario 2 Benutzer × 40 Aufrufe, Isolation von Bearer und Umgebung, Geschwister-Tasks und Cache-Verhalten.
  • Release-Build ohne Fehler; die vollständige Suite meldete 443 bestandene Tests und 10 vorbestehende Baseline-Fehler von 453, durch diese Arbeit unverändert.
  • Kein öffentlicher Vertrag von Frontend, BFF oder API hat sich geändert.
  • Nach dem Deployment traten die Seitenleisten- und Login-Fehler nicht mehr auf, und die entsprechenden Fehler in Application Insights wurden nicht mehr beobachtet.

Zahlen, mit ihren Grenzen

  • Gezielte Regressionstests12 / 12 bestanden
  • Nebenläufigkeitsszenario2 Benutzer × 40 überlappende Aufrufe
  • Vollständige Suite (Release-Build)443 / 453 bestanden10 vorbestehende Fehler, unverändert
  • Geänderte öffentliche Verträge0

Was diese Zahlen nicht sagen

  • Der Erfolg nach dem Deployment ist eine operative Beobachtung: Die für Benutzer sichtbaren Fehler hörten auf, und die Telemetriefehler verschwanden. Eine exportierte Monitoring-Metrik wurde nicht aufbewahrt.
  • Die kreuzweise Token-Exposition war ein glaubwürdiges, durch geteilte Header erzeugtes Risiko, kein dokumentierter Vorfall: Ein tatsächlicher Austausch von Zugangsdaten wurde nicht beobachtet.
  • Die Testzahlen sind die historischen Ergebnisse von Juli 2026, kein erneuter Lauf für diese Seite.

Was es zeigt

Ich kann einen Produktionsfehler über Telemetrie, Framework-Interna und Legacy-Code nachverfolgen, ihn beheben, ohne einen Vertrag zu brechen, und die Korrektur mit Tests belegen, die die Race erzwingen.

Zuordnung

Diagnose, Korrektur und Tests waren meine Arbeit. Das Deployment folgte dem normalen Release-Prozess des Teams.