@@ -187,6 +187,98 @@ public async Task FetchMetadataFailureTest_Blocking()
187187 await FetchMetadataFailureTestBody ( ) ;
188188 }
189189
190+ // Reproduces a bug in the blocking path (Switch.Microsoft.IdentityModel.UpdateConfigAsBlocking).
191+ // When a fetch fails and a subsequent call arrives within the backoff window (i.e. _syncAfter > now),
192+ // GetConfigurationWithBlockingAsync skips the fetch entirely and throws IDX20803 with a null
193+ // InnerException because _fetchMetadataFailure is declared as a local variable (reset to null on
194+ // every invocation) rather than persisted between calls. This loses the original IOException
195+ // (and the HttpDocumentRetriever.StatusCode / ResponseContent it carries in Data), preventing
196+ // callers from distinguishing client errors (4xx -> misconfigured tenant) from server errors
197+ // (5xx -> transient).
198+ [ Fact ]
199+ public async Task FetchMetadataFailure_Blocking_PreservesInnerExceptionDuringBackoffWindow ( )
200+ {
201+ AppContext . SetSwitch ( AppContextSwitches . UpdateConfigAsBlockingSwitch , true ) ;
202+
203+ var context = new CompareContext ( $ "{ this } .{ nameof ( FetchMetadataFailure_Blocking_PreservesInnerExceptionDuringBackoffWindow ) } ") ;
204+
205+ var documentRetriever = new HttpDocumentRetriever (
206+ HttpResponseMessageUtils . SetupHttpClientThatReturns ( "OpenIdConnectMetadata.json" , HttpStatusCode . NotFound ) ) ;
207+ var configManager = new ConfigurationManager < OpenIdConnectConfiguration > (
208+ "https://example.invalid/.well-known/openid-configuration" ,
209+ new OpenIdConnectConfigurationRetriever ( ) ,
210+ documentRetriever ) ;
211+
212+ // First call: fetch is attempted and fails. The thrown InvalidOperationException should
213+ // wrap the original IOException carrying the HTTP status code in its Data dictionary.
214+ Exception firstException = null ;
215+ try
216+ {
217+ _ = await configManager . GetConfigurationAsync ( CancellationToken . None ) ;
218+ }
219+ catch ( Exception ex )
220+ {
221+ firstException = ex ;
222+ }
223+
224+ if ( firstException == null )
225+ context . AddDiff ( "Expected first GetConfigurationAsync call to throw." ) ;
226+ else
227+ {
228+ if ( firstException . InnerException == null )
229+ context . AddDiff ( "Expected first call's InvalidOperationException to wrap the underlying IOException." ) ;
230+ else if ( ! ExceptionChainContainsStatusCode ( firstException ) )
231+ context . AddDiff ( "Expected first call's exception chain to contain HttpDocumentRetriever.StatusCode in Data." ) ;
232+ }
233+
234+ // Force the backoff window: ensure _syncAfter is in the future so the next call skips the fetch
235+ // and goes through the "stale metadata is better than no metadata" path. _currentConfiguration
236+ // is still null (bootstrap never succeeded), so the manager re-throws IDX20803.
237+ TestUtilities . SetField ( configManager , "_syncAfter" , DateTimeOffset . UtcNow . AddHours ( 1 ) ) ;
238+
239+ Exception secondException = null ;
240+ try
241+ {
242+ _ = await configManager . GetConfigurationAsync ( CancellationToken . None ) ;
243+ }
244+ catch ( Exception ex )
245+ {
246+ secondException = ex ;
247+ }
248+
249+ if ( secondException == null )
250+ context . AddDiff ( "Expected second GetConfigurationAsync call (within backoff window) to throw." ) ;
251+ else
252+ {
253+ // The bug: second call throws IDX20803 with InnerException == null, losing the HTTP
254+ // status code that callers use to classify the error (401 vs 503).
255+ if ( secondException . InnerException == null )
256+ {
257+ context . AddDiff (
258+ "BUG: Second call within backoff window threw IDX20803 with a null InnerException. " +
259+ "The original IOException (with HttpDocumentRetriever.StatusCode in Data) was lost " +
260+ "because _fetchMetadataFailure is a local variable in GetConfigurationWithBlockingAsync." ) ;
261+ }
262+ else if ( ! ExceptionChainContainsStatusCode ( secondException ) )
263+ {
264+ context . AddDiff ( "Expected second call's exception chain to contain HttpDocumentRetriever.StatusCode in Data." ) ;
265+ }
266+ }
267+
268+ TestUtilities . AssertFailIfErrors ( context ) ;
269+ }
270+
271+ private static bool ExceptionChainContainsStatusCode ( Exception exception )
272+ {
273+ for ( Exception current = exception ; current != null ; current = current . InnerException )
274+ {
275+ if ( current . Data . Contains ( HttpDocumentRetriever . StatusCode ) )
276+ return true ;
277+ }
278+
279+ return false ;
280+ }
281+
190282 private async ValueTask FetchMetadataFailureTestBody ( )
191283 {
192284 var context = new CompareContext ( $ "{ this } .FetchMetadataFailureTest") ;
0 commit comments