Skip to content

Commit a294fa7

Browse files
authored
promote exception to property, add test (#3471)
1 parent d0bdcea commit a294fa7

3 files changed

Lines changed: 102 additions & 1 deletion

File tree

src/Microsoft.IdentityModel.Protocols/Configuration/ConfigurationManager.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ public partial class ConfigurationManager<T> : BaseConfigurationManager, IConfig
3131
private readonly IConfigurationValidator<T> _configValidator;
3232
private T _currentConfiguration;
3333

34+
// Tracks the most recent fetch failure for the blocking path. Promoted from a local in
35+
// GetConfigurationWithBlockingAsync so the original exception (e.g. an IOException carrying
36+
// HttpDocumentRetriever.StatusCode/ResponseContent in its Data dictionary) is preserved across
37+
// calls that arrive within the backoff window (_syncAfter > now) and skip the fetch.
38+
private Exception _fetchMetadataFailure;
39+
3440
// task states are used to ensure the call to 'update config' (UpdateCurrentConfiguration) is a singleton. Uses Interlocked.CompareExchange.
3541
// metadata is not being obtained
3642
private const int ConfigurationRetrieverIdle = 0;

src/Microsoft.IdentityModel.Protocols/Configuration/ConfigurationManager_Blocking.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ public partial class ConfigurationManager<T> where T : class
2323

2424
private async Task<T> GetConfigurationWithBlockingAsync(CancellationToken cancel)
2525
{
26-
Exception _fetchMetadataFailure = null;
2726
await _refreshLock.WaitAsync(cancel).ConfigureAwait(false);
2827

2928
long startTimestamp = TimeProvider.GetTimestamp();
@@ -52,6 +51,8 @@ private async Task<T> GetConfigurationWithBlockingAsync(CancellationToken cancel
5251

5352
UpdateConfiguration(configurationRetrieved.Configuration, configurationRetrieved.RetrievalTime, retrievalContext);
5453

54+
_fetchMetadataFailure = null;
55+
5556
return _currentConfiguration;
5657
}
5758
}
@@ -81,6 +82,8 @@ private async Task<T> GetConfigurationWithBlockingAsync(CancellationToken cancel
8182
_refreshRequested = false;
8283

8384
UpdateConfiguration(configuration, TimeProvider.GetUtcNow(), retrievalContext);
85+
86+
_fetchMetadataFailure = null;
8487
}
8588
catch (Exception ex)
8689
{

test/Microsoft.IdentityModel.Protocols.OpenIdConnect.Tests/ConfigurationManagerTests.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)