Incidente in produzione · ASP.NET Core · Luglio 2026

Eliminare una race condition sugli header HttpClient in produzione

Sotto concorrenza reale una piattaforma ASP.NET Core multi-tenant in produzione ha iniziato a fallire in modi che nessuno riusciva a riprodurre da solo: errori nella sidebar, HTTP 500, redirect al login. Ho ricostruito il guasto attraverso la telemetria Azure e lo stack HTTP fino agli header condivisi e mutabili di un HttpClient singleton, li ho sostituiti con messaggi per singola richiesta, ho coperto la race con test e ho rilasciato la correzione senza cambiare alcuna interfaccia.

Il mio ruolo
Diagnosi, correzione, test di regressione e documentazione di rilascio, end to end.
Stack
  • ASP.NET Core
  • C#
  • HttpClient
  • Polly
  • Azure Application Insights
  • NUnit

In sintesi

  • Causa radice trovata da telemetria Azure e stack trace, non da una riproduzione locale
  • 12 test di regressione mirati, inclusi 2 utenti con 40 chiamate sovrapposte sullo stesso client
  • Dopo il rilascio gli errori non si sono più ripresentati (osservazione operativa)

Contesto

L'host web di una piattaforma ASP.NET Core multi-tenant chiama un'API separata per conto dell'utente autenticato, inoltrando il suo JWT insieme a un header di tenant/ambiente. Le classi di servizio condividevano un unico HttpClient singleton e ne modificavano gli header di default prima di ogni chiamata.

Sotto concorrenza reale — più utenti connessi, chiamate sovrapposte — la produzione ha iniziato a mostrare errori nella sidebar, risposte HTTP 500 e redirect inattesi alla pagina di login. Application Insights registrava dipendenze fallite con result code 0 e una NullReferenceException nella routine di scrittura degli header della connessione HTTP.

Cosa ho fatto

  • Ho correlato telemetria Azure, stack trace, storico del codice e configurazione della dependency injection finché lo stato condiviso mutabile è emerso come fattore comune.
  • Ho identificato la mutazione concorrente degli header di default del client come causa, esplicitando anche il rischio di sicurezza implicito: una chiamata sovrapposta poteva in teoria portare il bearer token o i valori tenant di un'altra richiesta.
  • Ho sostituito la mutazione degli header condivisi con un nuovo HttpRequestMessage per ogni tentativo, inviato via SendAsync, con gli header di autorizzazione, tenant, accept e chiave API agganciati a quel singolo messaggio.
  • Ho ricreato il corpo della richiesta a ogni retry di Polly, corretto la propagazione del valore ambiente che veniva consumato una volta e poi perso, e impedito che un lookup fallito del catalogo pagine finisse in cache — i risultati validi hanno ora un TTL esplicito di cinque minuti.
  • Ho scritto la suite di regressione mirata e documentato il cambiamento per il rilascio.

Decisioni

Messaggi per richiesta, non client per richiesta

Un client per ogni chiamata avrebbe eliminato la race ma aperto la porta all'esaurimento dei socket e a una riscrittura della registrazione. Mantenere il singleton e spostare ogni valore per-utente sull'HttpRequestMessage ha lasciato intatte interfacce pubbliche e configurazione DI.

Nessun cambio di interfaccia

I contratti di frontend, BFF e API sono rimasti identici: la correzione è uscita in un rilascio normale, con rollback banale.

Fail closed

L'API continua a rispondere 401 quando l'autenticazione manca o non è valida; la correzione non aggiunge fallback che possano mascherare un token assente, e i token restano fuori dai log di errore.

Mai mettere in cache un fallimento

Un errore temporaneo del catalogo pagine veniva salvato come risultato valido e inquinava la sidebar per le richieste successive. Ora vanno in cache solo i risultati validi, con TTL limitato.

La difficoltà concreta

Il guasto era non deterministico. Richiedeva una sovrapposizione reale tra utenti e non compariva mai in una sessione singola, quindi le prove andavano ricostruite dai pattern della telemetria e dagli interni dello stack HTTP, non da un debugger.

I test dovevano forzare la race di proposito: due utenti simulati che emettono 40 chiamate concorrenti sullo stesso client singleton, verificando che ogni chiamata portasse il proprio bearer e i propri valori tenant, più casi su task fratelli e sul comportamento della cache.

Risultato

  • 12 test di regressione mirati su 12 superati: scenario 2 utenti × 40 chiamate, isolamento di bearer e ambiente, task fratelli e comportamento della cache.
  • Build Release senza errori; la suite completa riportava 443 test superati e 10 fallimenti preesistenti su 453, non toccati da questo lavoro.
  • Nessun contratto pubblico di frontend, BFF o API è cambiato.
  • Dopo il rilascio gli errori di sidebar e login non si sono più ripresentati e gli errori corrispondenti in Application Insights non sono stati più osservati.

Numeri, con i loro limiti

  • Test di regressione mirati12 / 12 superati
  • Scenario di concorrenza2 utenti × 40 chiamate sovrapposte
  • Suite completa (build Release)443 / 453 superati10 fallimenti preesistenti, invariati
  • Contratti pubblici modificati0

Cosa questi numeri non dicono

  • Il successo post-rilascio è un'osservazione operativa: gli errori visibili agli utenti sono cessati e quelli in telemetria sono scomparsi. Non è stata conservata alcuna metrica di monitoraggio esportata.
  • L'esposizione incrociata dei token era un rischio credibile creato dagli header condivisi, non un incidente documentato: nessuno scambio reale di credenziali è stato osservato.
  • I conteggi dei test sono i risultati storici di luglio 2026, non una riesecuzione fatta per questa pagina.

Cosa dimostra

So risalire a un guasto di produzione attraverso telemetria, interni del framework e codice legacy, correggerlo senza rompere un contratto e dimostrare la correzione con test che forzano la race.

Attribuzione

Diagnosi, correzione e test sono lavoro mio. Il rilascio ha seguito il normale processo del team.