Skip to content

Commit 333dc69

Browse files
keegan-carusoKeegan Caruso
and
Keegan Caruso
authored
Switch for metadata refresh to be blocking (#3193)
* Adds the ability for the metadata refresh to be done as a blocking call, as per 8.0.1 behavior. This is done through the Switch.Microsoft.IdentityModel.UpdateConfigAsBlocking switch. If set, configuration calls will be blocking on update, and exceptions when requesting new metadata will be returned to the caller --------- Co-authored-by: Keegan Caruso <[email protected]>
1 parent c939be3 commit 333dc69

File tree

8 files changed

+550
-119
lines changed

8 files changed

+550
-119
lines changed

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

+48-8
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ namespace Microsoft.IdentityModel.Protocols
1717
/// </summary>
1818
/// <typeparam name="T">The type of <see cref="IDocumentRetriever"/>.</typeparam>
1919
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable")]
20-
public class ConfigurationManager<T> : BaseConfigurationManager, IConfigurationManager<T> where T : class
20+
public partial class ConfigurationManager<T> : BaseConfigurationManager, IConfigurationManager<T> where T : class
2121
{
2222
internal Action _onBackgroundTaskFinish;
2323

@@ -138,7 +138,7 @@ public ConfigurationManager(string metadataAddress, IConfigurationRetriever<T> c
138138
/// <summary>
139139
/// Obtains an updated version of Configuration.
140140
/// </summary>
141-
/// <returns>Configuration of type T.</returns>
141+
/// <returns>Configuration of type <typeparamref name="T"/>.</returns>
142142
/// <remarks>If the time since the last call is less than <see cref="BaseConfigurationManager.AutomaticRefreshInterval"/> then <see cref="IConfigurationRetriever{T}.GetConfigurationAsync"/> is not called and the current Configuration is returned.</remarks>
143143
public async Task<T> GetConfigurationAsync()
144144
{
@@ -149,13 +149,40 @@ public async Task<T> GetConfigurationAsync()
149149
/// Obtains an updated version of Configuration.
150150
/// </summary>
151151
/// <param name="cancel">CancellationToken</param>
152-
/// <returns>Configuration of type T.</returns>
153-
/// <remarks>If the time since the last call is less than <see cref="BaseConfigurationManager.AutomaticRefreshInterval"/> then <see cref="IConfigurationRetriever{T}.GetConfigurationAsync"/> is not called and the current Configuration is returned.</remarks>
152+
/// <returns>Configuration of type <typeparamref name="T"/>.</returns>
153+
/// <remarks>
154+
/// <para>
155+
/// If the time since the last call is less than <see cref="BaseConfigurationManager.AutomaticRefreshInterval"/>
156+
/// then <see cref="IConfigurationRetriever{T}.GetConfigurationAsync"/> is not called and the current Configuration is returned.
157+
/// By default, this method blocks until the configuration is retrieved the first time. After the configuration was retrieved once,
158+
/// updates will happen in the background. Failures to retrieve the configuration on the background thread will be logged.
159+
/// </para>
160+
/// <para>
161+
/// If this operation is configured to be blocking through the switch 'Switch.Microsoft.IdentityModel.UpdateConfigAsBlocking'
162+
/// then this method will block each time the configuration needs to be updated or hasn't been retrieved. If the configuration
163+
/// cannot be initially retrieved an exception will be thrown. If the configuration has been retrieved, but cannot be updated,
164+
/// then the exception will be logged and the current configuration will be returned.
165+
/// </para>
166+
/// <para>
167+
/// By using the app context switch you choose what works best for you when there is a signing key update:
168+
/// either block requests from being validated until the new key is retrieved, or allow requests to be validated
169+
/// with the current key until the new key is retrieved. If blocking, a service receiving high concurrent request
170+
/// may experience thread starvation.
171+
/// </para>
172+
/// </remarks>
154173
public virtual async Task<T> GetConfigurationAsync(CancellationToken cancel)
155174
{
156175
if (_currentConfiguration != null && _syncAfter > TimeProvider.GetUtcNow())
157176
return _currentConfiguration;
158177

178+
if (AppContextSwitches.UpdateConfigAsBlocking)
179+
return await GetConfigurationWithBlockingAsync(cancel).ConfigureAwait(false);
180+
else
181+
return await GetConfigurationNonBlockingAsync(cancel).ConfigureAwait(false);
182+
}
183+
184+
private async Task<T> GetConfigurationNonBlockingAsync(CancellationToken cancel)
185+
{
159186
Exception fetchMetadataFailure = null;
160187

161188
// LOGIC
@@ -174,7 +201,6 @@ public virtual async Task<T> GetConfigurationAsync(CancellationToken cancel)
174201
return _currentConfiguration;
175202
}
176203

177-
#pragma warning disable CA1031 // Do not catch general exception types
178204
try
179205
{
180206
// Don't use the individual CT here, this is a shared operation that shouldn't be affected by an individual's cancellation.
@@ -205,6 +231,7 @@ public virtual async Task<T> GetConfigurationAsync(CancellationToken cancel)
205231

206232
UpdateConfiguration(configuration);
207233
}
234+
#pragma warning disable CA1031 // Do not catch general exception types
208235
catch (Exception ex)
209236
{
210237
fetchMetadataFailure = ex;
@@ -221,11 +248,11 @@ public virtual async Task<T> GetConfigurationAsync(CancellationToken cancel)
221248
LogHelper.MarkAsNonPII(ex)),
222249
ex));
223250
}
251+
#pragma warning restore CA1031 // Do not catch general exception types
224252
finally
225253
{
226254
_configurationNullLock.Release();
227255
}
228-
#pragma warning restore CA1031 // Do not catch general exception types
229256
}
230257
else
231258
{
@@ -260,7 +287,6 @@ public virtual async Task<T> GetConfigurationAsync(CancellationToken cancel)
260287
/// </summary>
261288
private void UpdateCurrentConfiguration()
262289
{
263-
#pragma warning disable CA1031 // Do not catch general exception types
264290
long startTimestamp = TimeProvider.GetTimestamp();
265291

266292
try
@@ -293,6 +319,7 @@ private void UpdateCurrentConfiguration()
293319
UpdateConfiguration(configuration);
294320
}
295321
}
322+
#pragma warning disable CA1031 // Do not catch general exception types
296323
catch (Exception ex)
297324
{
298325
var elapsedTime = TimeProvider.GetElapsedTime(startTimestamp);
@@ -309,13 +336,13 @@ private void UpdateCurrentConfiguration()
309336
ex),
310337
ex));
311338
}
339+
#pragma warning restore CA1031 // Do not catch general exception types
312340
finally
313341
{
314342
Interlocked.Exchange(ref _configurationRetrieverState, ConfigurationRetrieverIdle);
315343
}
316344

317345
_onBackgroundTaskFinish?.Invoke();
318-
#pragma warning restore CA1031 // Do not catch general exception types
319346
}
320347

321348
private void UpdateConfiguration(T configuration)
@@ -343,7 +370,20 @@ public override async Task<BaseConfiguration> GetBaseConfigurationAsync(Cancella
343370
/// <para>2. The time between when this method was called and DateTimeOffset.Now is greater than <see cref="BaseConfigurationManager.RefreshInterval"/>.</para>
344371
/// <para>If <see cref="BaseConfigurationManager.RefreshInterval"/> == <see cref="TimeSpan.MaxValue"/> then this method does nothing.</para>
345372
/// </summary>
373+
/// <remarks>
374+
/// If the strategy is configured to be blocking through the switch 'Switch.Microsoft.IdentityModel.UpdateConfigAsBlocking',
375+
/// then this method will not update the configuration, instead it will request the next call to <see cref="GetConfigurationAsync()"/>
376+
/// should request new configuration.
377+
/// </remarks>
346378
public override void RequestRefresh()
379+
{
380+
if (AppContextSwitches.UpdateConfigAsBlocking)
381+
RequestRefreshBlocking();
382+
else
383+
RequestRefreshBackgroundThread();
384+
}
385+
386+
private void RequestRefreshBackgroundThread()
347387
{
348388
DateTimeOffset now = TimeProvider.GetUtcNow();
349389

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.IdentityModel.Logging;
8+
using Microsoft.IdentityModel.Protocols.Configuration;
9+
using Microsoft.IdentityModel.Telemetry;
10+
using Microsoft.IdentityModel.Tokens;
11+
12+
namespace Microsoft.IdentityModel.Protocols
13+
{
14+
partial class ConfigurationManager<T> where T : class
15+
{
16+
private readonly SemaphoreSlim _refreshLock = new(1, 1);
17+
private TimeSpan _bootstrapRefreshInterval = TimeSpan.FromSeconds(1);
18+
19+
/// <summary>
20+
/// Only used to track the type of request for telemetry.
21+
/// </summary>
22+
private bool _refreshRequested;
23+
24+
private async Task<T> GetConfigurationWithBlockingAsync(CancellationToken cancel)
25+
{
26+
Exception _fetchMetadataFailure = null;
27+
await _refreshLock.WaitAsync(cancel).ConfigureAwait(false);
28+
29+
long startTimestamp = TimeProvider.GetTimestamp();
30+
31+
try
32+
{
33+
if (_syncAfter <= TimeProvider.GetUtcNow())
34+
{
35+
try
36+
{
37+
// Don't use the individual CT here, this is a shared operation that shouldn't be affected by an individual's cancellation.
38+
// The transport should have it's own timeouts, etc..
39+
var configuration = await _configRetriever.GetConfigurationAsync(MetadataAddress, _docRetriever, CancellationToken.None).ConfigureAwait(false);
40+
41+
var elapsedTime = TimeProvider.GetElapsedTime(startTimestamp);
42+
TelemetryClient.LogConfigurationRetrievalDuration(
43+
MetadataAddress,
44+
elapsedTime);
45+
46+
if (_configValidator != null)
47+
{
48+
ConfigurationValidationResult result = _configValidator.Validate(configuration);
49+
if (!result.Succeeded)
50+
throw LogHelper.LogExceptionMessage(new InvalidConfigurationException(LogHelper.FormatInvariant(LogMessages.IDX20810, result.ErrorMessage)));
51+
}
52+
53+
_lastRequestRefresh = TimeProvider.GetUtcNow().UtcDateTime;
54+
55+
TelemetryForUpdateBlocking();
56+
57+
if (_refreshRequested)
58+
_refreshRequested = false;
59+
60+
UpdateConfiguration(configuration);
61+
}
62+
catch (Exception ex)
63+
{
64+
_fetchMetadataFailure = ex;
65+
66+
if (_currentConfiguration == null)
67+
{
68+
if (_bootstrapRefreshInterval < RefreshInterval)
69+
{
70+
// Adopt exponential backoff for bootstrap refresh interval with a decorrelated jitter if it is not longer than the refresh interval.
71+
TimeSpan _bootstrapRefreshIntervalWithJitter = TimeSpan.FromSeconds(new Random().Next((int)_bootstrapRefreshInterval.TotalSeconds));
72+
_bootstrapRefreshInterval += _bootstrapRefreshInterval;
73+
_syncAfter = DateTimeUtil.Add(DateTime.UtcNow, _bootstrapRefreshIntervalWithJitter);
74+
}
75+
else
76+
{
77+
_syncAfter = DateTimeUtil.Add(
78+
TimeProvider.GetUtcNow().UtcDateTime,
79+
AutomaticRefreshInterval < RefreshInterval ? AutomaticRefreshInterval : RefreshInterval);
80+
}
81+
82+
TelemetryClient.IncrementConfigurationRefreshRequestCounter(
83+
MetadataAddress,
84+
TelemetryConstants.Protocols.FirstRefresh,
85+
ex);
86+
87+
throw LogHelper.LogExceptionMessage(
88+
new InvalidOperationException(
89+
LogHelper.FormatInvariant(LogMessages.IDX20803, LogHelper.MarkAsNonPII(MetadataAddress ?? "null"), LogHelper.MarkAsNonPII(_syncAfter), LogHelper.MarkAsNonPII(ex)), ex));
90+
}
91+
else
92+
{
93+
_syncAfter = DateTimeUtil.Add(
94+
TimeProvider.GetUtcNow().UtcDateTime,
95+
AutomaticRefreshInterval < RefreshInterval ? AutomaticRefreshInterval : RefreshInterval);
96+
97+
var elapsedTime = TimeProvider.GetElapsedTime(startTimestamp);
98+
99+
TelemetryClient.LogConfigurationRetrievalDuration(
100+
MetadataAddress,
101+
elapsedTime,
102+
ex);
103+
104+
LogHelper.LogExceptionMessage(
105+
new InvalidOperationException(
106+
LogHelper.FormatInvariant(LogMessages.IDX20806, LogHelper.MarkAsNonPII(MetadataAddress ?? "null"), LogHelper.MarkAsNonPII(ex)), ex));
107+
}
108+
}
109+
}
110+
111+
// Stale metadata is better than no metadata
112+
if (_currentConfiguration != null)
113+
return _currentConfiguration;
114+
else
115+
throw LogHelper.LogExceptionMessage(
116+
new InvalidOperationException(
117+
LogHelper.FormatInvariant(
118+
LogMessages.IDX20803,
119+
LogHelper.MarkAsNonPII(MetadataAddress ?? "null"),
120+
LogHelper.MarkAsNonPII(_syncAfter),
121+
LogHelper.MarkAsNonPII(_fetchMetadataFailure)),
122+
_fetchMetadataFailure));
123+
}
124+
finally
125+
{
126+
_refreshLock.Release();
127+
}
128+
}
129+
130+
private void RequestRefreshBlocking()
131+
{
132+
DateTime now = TimeProvider.GetUtcNow().UtcDateTime;
133+
134+
if (now >= DateTimeUtil.Add(_lastRequestRefresh.UtcDateTime, RefreshInterval) || _isFirstRefreshRequest)
135+
{
136+
_refreshRequested = true;
137+
_syncAfter = now;
138+
_isFirstRefreshRequest = false;
139+
}
140+
}
141+
142+
private void TelemetryForUpdateBlocking()
143+
{
144+
string updateMode;
145+
146+
if (_currentConfiguration is null)
147+
updateMode = TelemetryConstants.Protocols.FirstRefresh;
148+
else
149+
updateMode = _refreshRequested ? TelemetryConstants.Protocols.Manual : TelemetryConstants.Protocols.Automatic;
150+
151+
try
152+
{
153+
TelemetryClient.IncrementConfigurationRefreshRequestCounter(
154+
MetadataAddress,
155+
updateMode);
156+
}
157+
#pragma warning disable CA1031 // Do not catch general exception types
158+
catch
159+
{ }
160+
#pragma warning restore CA1031 // Do not catch general exception types
161+
}
162+
}
163+
}

src/Microsoft.IdentityModel.Tokens/AppContextSwitches.cs

+8-1
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ internal static class AppContextSwitches
7070

7171
internal static bool UseRfcDefinitionOfEpkAndKid => _useRfcDefinitionOfEpkAndKid ??= (AppContext.TryGetSwitch(UseRfcDefinitionOfEpkAndKidSwitch, out bool isEnabled) && isEnabled);
7272

73-
73+
/// <summary>
74+
/// Enabling this switch will cause the configuration manager to block other requests to GetConfigurationAsync if a request is already in progress.
75+
/// The default configuration refresh behavior is if a request is already in progress, the current configuration will be returned until the ongoing request is completed on
76+
/// a background thread.
77+
/// </summary>
7478
internal const string UpdateConfigAsBlockingSwitch = "Switch.Microsoft.IdentityModel.UpdateConfigAsBlocking";
7579

7680
private static bool? _updateConfigAsBlockingCall;
@@ -96,6 +100,9 @@ internal static void ResetAllSwitches()
96100

97101
_useRfcDefinitionOfEpkAndKid = null;
98102
AppContext.SetSwitch(UseRfcDefinitionOfEpkAndKidSwitch, false);
103+
104+
_updateConfigAsBlockingCall = null;
105+
AppContext.SetSwitch(UpdateConfigAsBlockingSwitch, false);
99106
}
100107
}
101108
}

src/Microsoft.IdentityModel.Tokens/Telemetry/ITelemetryClient.cs

-2
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ internal void IncrementConfigurationRefreshRequestCounter(
2525
string operationStatus,
2626
Exception exception);
2727

28-
// Unused, this was part of a previous release, since it is a friend,
29-
// it cannot be removed.
3028
internal void LogBackgroundConfigurationRefreshFailure(
3129
string metadataAddress,
3230
Exception exception);

src/Microsoft.IdentityModel.Tokens/Telemetry/TelemetryClient.cs

+13-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Collections.Generic;
56
using System.Diagnostics;
67
using Microsoft.IdentityModel.Logging;
78
using Microsoft.IdentityModel.Tokens;
@@ -15,13 +16,19 @@ internal class TelemetryClient : ITelemetryClient
1516
{
1617
public string ClientVer = IdentityModelTelemetryUtil.ClientVer;
1718

19+
private KeyValuePair<string, object> _blockingTagValue = new(
20+
TelemetryConstants.BlockingTypeTag,
21+
AppContextSwitches.UpdateConfigAsBlocking.ToString()
22+
);
23+
1824
public void IncrementConfigurationRefreshRequestCounter(string metadataAddress, string operationStatus)
1925
{
2026
var tagList = new TagList()
2127
{
2228
{ TelemetryConstants.IdentityModelVersionTag, ClientVer },
2329
{ TelemetryConstants.MetadataAddressTag, metadataAddress },
24-
{ TelemetryConstants.OperationStatusTag, operationStatus }
30+
{ TelemetryConstants.OperationStatusTag, operationStatus },
31+
_blockingTagValue
2532
};
2633

2734
TelemetryDataRecorder.IncrementConfigurationRefreshRequestCounter(tagList);
@@ -34,7 +41,8 @@ public void IncrementConfigurationRefreshRequestCounter(string metadataAddress,
3441
{ TelemetryConstants.IdentityModelVersionTag, ClientVer },
3542
{ TelemetryConstants.MetadataAddressTag, metadataAddress },
3643
{ TelemetryConstants.OperationStatusTag, operationStatus },
37-
{ TelemetryConstants.ExceptionTypeTag, exception.GetType().ToString() }
44+
{ TelemetryConstants.ExceptionTypeTag, exception.GetType().ToString() },
45+
_blockingTagValue
3846
};
3947

4048
TelemetryDataRecorder.IncrementConfigurationRefreshRequestCounter(tagList);
@@ -58,7 +66,8 @@ public void LogConfigurationRetrievalDuration(string metadataAddress, TimeSpan o
5866
{
5967
{ TelemetryConstants.IdentityModelVersionTag, ClientVer },
6068
{ TelemetryConstants.MetadataAddressTag, metadataAddress },
61-
{ TelemetryConstants.ExceptionTypeTag, exception.GetType().ToString() }
69+
{ TelemetryConstants.ExceptionTypeTag, exception.GetType().ToString() },
70+
_blockingTagValue
6271
};
6372

6473
long durationInMilliseconds = (long)operationDuration.TotalMilliseconds;
@@ -74,7 +83,7 @@ public void LogBackgroundConfigurationRefreshFailure(
7483
{ TelemetryConstants.IdentityModelVersionTag, ClientVer },
7584
{ TelemetryConstants.MetadataAddressTag, metadataAddress },
7685
{ TelemetryConstants.ExceptionTypeTag, exception.GetType().ToString() },
77-
{ TelemetryConstants.BlockingTypeTag, AppContextSwitches.UpdateConfigAsBlocking.ToString() }
86+
_blockingTagValue
7887
};
7988

8089
TelemetryDataRecorder.IncrementBackgroundConfigurationRefreshFailureCounter(tagList);

0 commit comments

Comments
 (0)