Production incident · ASP.NET Core · July 2026

Eliminating a cross-request HttpClient header race in production

Under real concurrency a live multi-tenant ASP.NET Core platform started failing in ways nobody could reproduce alone: sidebar errors, HTTP 500s, redirects to login. I traced the failures through Azure telemetry and the HTTP stack to shared mutable headers on a singleton HttpClient, replaced them with request-scoped messages, covered the race with tests and shipped the fix without changing any interface.

My role
Diagnosis, fix, regression tests and release documentation, end to end.
Stack
  • ASP.NET Core
  • C#
  • HttpClient
  • Polly
  • Azure Application Insights
  • NUnit

At a glance

  • Root cause found from Azure telemetry and stack traces, not from a local reproduction
  • 12 targeted regression tests, including 2 users making 40 overlapping calls through one client
  • Failures stopped recurring after deployment (operational observation)

Context

The web host of a multi-tenant ASP.NET Core platform calls a separate API on behalf of the logged-in user, forwarding that user's JWT together with a tenant/environment header. The service classes shared one singleton HttpClient and mutated its default headers before every call.

Under real concurrency — several users logged in, overlapping calls — production began showing sidebar failures, HTTP 500 responses and unexpected redirects to the login page. Application Insights recorded failed dependencies with result code 0 and a NullReferenceException inside the HTTP connection's header-writing routine.

What I did

  • Correlated Azure telemetry, stack traces, historical code and the dependency-injection setup until the shared mutable state stood out as the common factor.
  • Identified concurrent mutation of the client's default headers as the cause, and named the security risk it implied: an overlapping call could in theory carry another request's bearer token or tenant values.
  • Replaced shared-header mutation with a fresh HttpRequestMessage for every attempt, sent through SendAsync, with the authorization, tenant, accept and API-key headers attached to that individual message.
  • Re-created the request body for every Polly retry, corrected the environment value propagation that was consumed once and then lost, and stopped a failed page-catalogue lookup from being written to cache — successful results now carry an explicit five-minute TTL.
  • Wrote the targeted regression suite and documented the change for the release.

Decisions

Request-scoped messages, not per-request clients

A client per call would have removed the race but invited socket exhaustion and a rewrite of the registration. Keeping the singleton and moving every per-user value onto the HttpRequestMessage left the public interfaces and the DI setup untouched.

No interface changes

Frontend, BFF and API contracts stayed identical, so the fix shipped in a normal release with a trivial rollback.

Fail closed

The API keeps returning 401 when authentication is missing or invalid; the fix adds no fallback that could mask a missing token, and tokens stay out of error logs.

Never cache a failure

A temporary failure of the page catalogue used to be stored like a valid result and poison the sidebar for later requests. Only successful results are cached now, with a bounded TTL.

The hard part

The failure was nondeterministic. It needed real overlap between users and never appeared in a single-user session, so the evidence had to be assembled from telemetry patterns and the internals of the HTTP stack rather than from a debugger.

The tests had to force the race on purpose: two simulated users issuing 40 concurrent calls through the same singleton client, asserting that every call carried its own bearer and tenant values, plus sibling-task and cache-behaviour cases.

Result

  • 12 of 12 targeted regression tests passed: the 2-user × 40-call scenario, bearer and environment isolation, sibling tasks and cache behaviour.
  • Release build with zero errors; the full suite reported 443 passing tests and 10 pre-existing baseline failures out of 453, unchanged by this work.
  • No public frontend, BFF or API contract changed.
  • After deployment the sidebar and login failures stopped recurring and the matching Application Insights errors were no longer observed.

Numbers, with their limits

  • Targeted regression tests12 / 12 passed
  • Concurrency scenario2 users × 40 overlapping calls
  • Full suite (Release build)443 / 453 passing10 pre-existing baseline failures, unchanged
  • Public contracts changed0

What these numbers do not say

  • Post-deployment success is an operational observation: the user-facing failures stopped and the telemetry errors disappeared. No exported monitoring metric was retained.
  • The cross-user token exposure was a credible risk created by shared headers, not a documented incident: no actual credential crossover was observed.
  • Test counts are the historical results of July 2026, not a re-run made for this page.

What it proves

I can trace a production failure across telemetry, framework internals and legacy code, fix it without breaking a contract, and prove the fix with tests that force the race.

Attribution

Diagnosis, fix and tests were my work. Deployment followed the team's normal release process.