Skip to content

Commit efbd817

Browse files
A new retry policy has been created for region discovery (#5336)
1 parent 01bca84 commit efbd817

File tree

13 files changed

+227
-19
lines changed

13 files changed

+227
-19
lines changed

src/client/Microsoft.Identity.Client/Http/Retry/HttpRetryCondition.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ public static bool Imds(HttpResponse response, Exception exception)
4747
};
4848
}
4949

50+
/// <summary>
51+
/// Retry policy specific to Region Discovery.
52+
/// Extends Imds retry policy but excludes 404 and 408 status codes.
53+
/// </summary>
54+
public static bool RegionDiscovery(HttpResponse response, Exception exception)
55+
{
56+
if (!Imds(response, exception))
57+
{
58+
return false;
59+
}
60+
61+
// If Imds would retry but the status code is 404 or 408, don't retry
62+
return (int)response.StatusCode is not (404 or 408);
63+
}
64+
5065
/// <summary>
5166
/// Retry condition for /token and /authorize endpoints
5267
/// </summary>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Threading.Tasks;
6+
using Microsoft.Identity.Client.Core;
7+
8+
namespace Microsoft.Identity.Client.Http.Retry
9+
{
10+
internal class RegionDiscoveryRetryPolicy : IRetryPolicy
11+
{
12+
// Referenced in unit tests
13+
public const int NumRetries = 3;
14+
15+
private const int MinExponentialBackoffMs = 1000;
16+
private const int MaxExponentialBackoffMs = 4000;
17+
private const int ExponentialDeltaBackoffMs = 2000;
18+
19+
private readonly ExponentialRetryStrategy _exponentialRetryStrategy = new ExponentialRetryStrategy(
20+
MinExponentialBackoffMs,
21+
MaxExponentialBackoffMs,
22+
ExponentialDeltaBackoffMs
23+
);
24+
25+
internal virtual Task DelayAsync(int milliseconds)
26+
{
27+
return Task.Delay(milliseconds);
28+
}
29+
30+
public async Task<bool> PauseForRetryAsync(HttpResponse response, Exception exception, int retryCount, ILoggerAdapter logger)
31+
{
32+
// Check if the status code is retriable and if the current retry count is less than max retries
33+
if (HttpRetryConditions.RegionDiscovery(response, exception) &&
34+
retryCount < NumRetries)
35+
{
36+
int retryAfterDelay = _exponentialRetryStrategy.CalculateDelay(retryCount);
37+
38+
logger.Warning($"Retrying request in {retryAfterDelay}ms (retry attempt: {retryCount + 1})");
39+
40+
// Pause execution for the calculated delay
41+
await DelayAsync(retryAfterDelay).ConfigureAwait(false);
42+
43+
return true;
44+
}
45+
46+
// If the status code is not retriable or max retries have been reached, do not retry
47+
return false;
48+
}
49+
}
50+
}

src/client/Microsoft.Identity.Client/Http/Retry/RetryPolicyFactory.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public virtual IRetryPolicy GetRetryPolicy(RequestType requestType)
1616
return new DefaultRetryPolicy(requestType);
1717
case RequestType.Imds:
1818
return new ImdsRetryPolicy();
19+
case RequestType.RegionDiscovery:
20+
return new RegionDiscoveryRetryPolicy();
1921
default:
2022
throw new ArgumentOutOfRangeException(nameof(requestType), requestType, "Unknown request type.");
2123
}

src/client/Microsoft.Identity.Client/Instance/Region/RegionManager.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public async Task<string> GetAzureRegionAsync(RequestContext requestContext)
7878
"Do not call GetAzureRegionAsync outside of a request. This can happen if you perform instance discovery outside a request, for example as part of validating input params.");
7979

8080
IRetryPolicyFactory retryPolicyFactory = requestContext.ServiceBundle.Config.RetryPolicyFactory;
81-
IRetryPolicy retryPolicy = retryPolicyFactory.GetRetryPolicy(RequestType.STS);
81+
IRetryPolicy retryPolicy = retryPolicyFactory.GetRetryPolicy(RequestType.RegionDiscovery);
8282

8383
// MSAL always performs region auto-discovery, even if the user configured an actual region
8484
// in order to detect inconsistencies and report via telemetry

src/client/Microsoft.Identity.Client/RequestType.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ internal enum RequestType
2121
/// <summary>
2222
/// Instance Metadata Service (IMDS) request, used for obtaining tokens from the Azure VM metadata endpoint.
2323
/// </summary>
24-
Imds
24+
Imds,
25+
26+
/// <summary>
27+
/// Region Discovery request, used for region discovery operations with exponential backoff retry strategy.
28+
/// </summary>
29+
RegionDiscovery
2530
}
2631
}

tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHttpManagerExtensions.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,8 +493,9 @@ public static void AddManagedIdentityWSTrustMockHandler(
493493
});
494494
}
495495

496-
public static void AddRegionDiscoveryMockHandlerNotFound(
497-
this MockHttpManager httpManager)
496+
public static void AddRegionDiscoveryMockHandlerWithError(
497+
this MockHttpManager httpManager,
498+
HttpStatusCode statusCode)
498499
{
499500
httpManager.AddMockHandler(
500501
new MockHttpMessageHandler
@@ -505,7 +506,7 @@ public static void AddRegionDiscoveryMockHandlerNotFound(
505506
{
506507
{"Metadata", "true"}
507508
},
508-
ResponseMessage = MockHelpers.CreateFailureMessage(HttpStatusCode.NotFound, "")
509+
ResponseMessage = MockHelpers.CreateFailureMessage(statusCode, "")
509510
});
510511
}
511512
}

tests/Microsoft.Identity.Test.Common/TestConstants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ public static MsalTokenResponse CreateMsalTokenResponseWithTokenSource()
419419
//Region Discovery Failures
420420
public const string RegionAutoDetectOkFailureMessage = "Call to local IMDS failed with status code OK or an empty response.";
421421
public const string RegionAutoDetectNotFoundFailureMessage = "Call to local IMDS failed with status code NotFound or an empty response.";
422+
public const string RegionAutoDetectInternalServerErrorFailureMessage = "Service is unavailable to process the request";
422423
public const string RegionDiscoveryNotSupportedErrorMessage = "Region discovery can only be made if the service resides in Azure function or Azure VM";
423424
public const string RegionDiscoveryIMDSCallFailedMessage = "IMDS call failed";
424425

tests/Microsoft.Identity.Test.Unit/CoreTests/RegionDiscoveryProviderTests.cs

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@
44
using System;
55
using System.Collections.Generic;
66
using System.IO;
7+
using System.Net;
78
using System.Net.Http;
89
using System.Threading;
910
using System.Threading.Tasks;
1011
using Microsoft.Identity.Client;
12+
using Microsoft.Identity.Client.Http.Retry;
1113
using Microsoft.Identity.Client.Instance.Discovery;
1214
using Microsoft.Identity.Client.Internal;
1315
using Microsoft.Identity.Client.Region;
1416
using Microsoft.Identity.Client.TelemetryCore.Internal.Events;
1517
using Microsoft.Identity.Test.Common.Core.Mocks;
18+
using Microsoft.Identity.Test.Unit.Helpers;
1619
using Microsoft.VisualStudio.TestTools.UnitTesting;
1720

1821
namespace Microsoft.Identity.Test.Unit.CoreTests
@@ -28,13 +31,15 @@ public class RegionDiscoveryProviderTests : TestBase
2831
private ApiEvent _apiEvent;
2932
private CancellationTokenSource _userCancellationTokenSource;
3033
private IRegionDiscoveryProvider _regionDiscoveryProvider;
34+
private readonly TestRetryPolicyFactory _testRetryPolicyFactory = new TestRetryPolicyFactory();
3135

3236
[TestInitialize]
3337
public override void TestInitialize()
3438
{
3539
base.TestInitialize();
3640

3741
_harness = base.CreateTestHarness();
42+
_harness.ServiceBundle.Config.RetryPolicyFactory = _testRetryPolicyFactory;
3843
_httpManager = _harness.HttpManager;
3944
_userCancellationTokenSource = new CancellationTokenSource();
4045
_testRequestContext = new RequestContext(
@@ -145,10 +150,18 @@ public async Task FetchRegionFromLocalImdsThenGetMetadataFromCacheAsync()
145150
ValidateInstanceMetadata(regionalMetadata);
146151
}
147152

148-
[TestMethod]
149-
public async Task SuccessfulResponseFromUserProvidedRegionAsync()
153+
[DataTestMethod]
154+
[DataRow(HttpStatusCode.NotFound, 0, TestConstants.RegionAutoDetectNotFoundFailureMessage)] // No retries for 404 errors
155+
[DataRow(HttpStatusCode.InternalServerError, TestRegionDiscoveryRetryPolicy.NumRetries, TestConstants.RegionAutoDetectInternalServerErrorFailureMessage)]
156+
public async Task SuccessfulResponseFromUserProvidedRegionAsync(
157+
HttpStatusCode statusCode,
158+
int expectedRetries,
159+
string expectedFailureMessage)
150160
{
151-
AddMockedResponse(MockHelpers.CreateNullMessage(System.Net.HttpStatusCode.NotFound));
161+
for (int i = 0; i < (1 + expectedRetries); i++)
162+
{
163+
AddMockedResponse(MockHelpers.CreateNullMessage(statusCode));
164+
}
152165

153166
_testRequestContext.ServiceBundle.Config.AzureRegion = TestConstants.Region;
154167

@@ -162,7 +175,10 @@ public async Task SuccessfulResponseFromUserProvidedRegionAsync()
162175
Assert.AreEqual(TestConstants.Region, _testRequestContext.ApiEvent.RegionUsed);
163176
Assert.AreEqual(RegionAutodetectionSource.FailedAutoDiscovery, _testRequestContext.ApiEvent.RegionAutodetectionSource);
164177
Assert.AreEqual(RegionOutcome.UserProvidedAutodetectionFailed, _testRequestContext.ApiEvent.RegionOutcome);
165-
Assert.IsTrue(_testRequestContext.ApiEvent.RegionDiscoveryFailureReason.Contains(TestConstants.RegionAutoDetectNotFoundFailureMessage));
178+
Assert.IsTrue(_testRequestContext.ApiEvent.RegionDiscoveryFailureReason.Contains(expectedFailureMessage));
179+
180+
// Verify all mock responses were consumed
181+
Assert.AreEqual(0, _httpManager.QueueSize);
166182
}
167183

168184
[TestMethod]
@@ -303,10 +319,18 @@ public async Task ResponseMissingRegionFromLocalImdsAsync()
303319
Assert.IsTrue(_testRequestContext.ApiEvent.RegionDiscoveryFailureReason.Contains(TestConstants.RegionAutoDetectOkFailureMessage));
304320
}
305321

306-
[TestMethod]
307-
public async Task ErrorResponseFromLocalImdsAsync()
322+
[DataTestMethod]
323+
[DataRow(HttpStatusCode.NotFound, 0, TestConstants.RegionAutoDetectNotFoundFailureMessage)] // No retries for 404 errors
324+
[DataRow(HttpStatusCode.InternalServerError, TestRegionDiscoveryRetryPolicy.NumRetries, TestConstants.RegionAutoDetectInternalServerErrorFailureMessage)]
325+
public async Task ErrorResponseFromLocalImdsAsync(
326+
HttpStatusCode statusCode,
327+
int expectedRetries,
328+
string expectedFailureMessage)
308329
{
309-
AddMockedResponse(MockHelpers.CreateNullMessage(System.Net.HttpStatusCode.NotFound));
330+
for (int i = 0; i < (1 + expectedRetries); i++)
331+
{
332+
AddMockedResponse(MockHelpers.CreateNullMessage(statusCode));
333+
}
310334
_testRequestContext.ServiceBundle.Config.AzureRegion = ConfidentialClientApplication.AttemptRegionDiscovery;
311335

312336
InstanceDiscoveryMetadataEntry regionalMetadata = await _regionDiscoveryProvider.
@@ -318,7 +342,10 @@ public async Task ErrorResponseFromLocalImdsAsync()
318342
Assert.AreEqual(null, _testRequestContext.ApiEvent.RegionUsed);
319343
Assert.AreEqual(RegionAutodetectionSource.FailedAutoDiscovery, _testRequestContext.ApiEvent.RegionAutodetectionSource);
320344
Assert.AreEqual(RegionOutcome.FallbackToGlobal, _testRequestContext.ApiEvent.RegionOutcome);
321-
Assert.IsTrue(_testRequestContext.ApiEvent.RegionDiscoveryFailureReason.Contains(TestConstants.RegionAutoDetectNotFoundFailureMessage));
345+
Assert.IsTrue(_testRequestContext.ApiEvent.RegionDiscoveryFailureReason.Contains(expectedFailureMessage));
346+
347+
// Verify all mock responses were consumed
348+
Assert.AreEqual(0, _httpManager.QueueSize);
322349
}
323350

324351
[TestMethod]
@@ -382,6 +409,75 @@ public async Task UpdateApiversionFailsWithNoNewestVersionsAsync()
382409
Assert.IsTrue(_testRequestContext.ApiEvent.RegionDiscoveryFailureReason.Contains(TestConstants.RegionDiscoveryNotSupportedErrorMessage));
383410
}
384411

412+
[TestMethod]
413+
public async Task RegionDiscoveryFails500OnceThenSucceeds200Async()
414+
{
415+
AddMockedResponse(MockHelpers.CreateNullMessage(HttpStatusCode.InternalServerError));
416+
AddMockedResponse(MockHelpers.CreateSuccessResponseMessage(TestConstants.Region));
417+
418+
_testRequestContext.ServiceBundle.Config.AzureRegion = ConfidentialClientApplication.AttemptRegionDiscovery;
419+
420+
InstanceDiscoveryMetadataEntry regionalMetadata = await _regionDiscoveryProvider.GetMetadataAsync(
421+
new Uri("https://login.microsoftonline.com/common/"), _testRequestContext).ConfigureAwait(false);
422+
423+
ValidateInstanceMetadata(regionalMetadata);
424+
Assert.AreEqual(TestConstants.Region, _testRequestContext.ApiEvent.RegionUsed);
425+
Assert.AreEqual(RegionAutodetectionSource.Imds, _testRequestContext.ApiEvent.RegionAutodetectionSource);
426+
Assert.AreEqual(RegionOutcome.AutodetectSuccess, _testRequestContext.ApiEvent.RegionOutcome);
427+
Assert.IsNull(_testRequestContext.ApiEvent.RegionDiscoveryFailureReason);
428+
429+
const int NumRequests = 2; // initial request + one retry
430+
int requestsMade = NumRequests - _httpManager.QueueSize;
431+
Assert.AreEqual(NumRequests, requestsMade);
432+
}
433+
434+
[TestMethod]
435+
public async Task RegionDiscoveryFails500PermanentlyAsync()
436+
{
437+
// Simulate permanent 500s (to trigger the maximum number of retries)
438+
const int Num500Errors = 1 + RegionDiscoveryRetryPolicy.NumRetries; // initial request + maximum number of retries
439+
for (int i = 0; i < Num500Errors; i++)
440+
{
441+
AddMockedResponse(MockHelpers.CreateNullMessage(HttpStatusCode.InternalServerError));
442+
}
443+
444+
_testRequestContext.ServiceBundle.Config.AzureRegion = ConfidentialClientApplication.AttemptRegionDiscovery;
445+
446+
InstanceDiscoveryMetadataEntry regionalMetadata = await _regionDiscoveryProvider.GetMetadataAsync(
447+
new Uri("https://login.microsoftonline.com/common/"), _testRequestContext).ConfigureAwait(false);
448+
449+
Assert.IsNull(regionalMetadata, "Discovery should fail after max retries");
450+
Assert.AreEqual(null, _testRequestContext.ApiEvent.RegionUsed);
451+
Assert.AreEqual(RegionAutodetectionSource.FailedAutoDiscovery, _testRequestContext.ApiEvent.RegionAutodetectionSource);
452+
Assert.AreEqual(RegionOutcome.FallbackToGlobal, _testRequestContext.ApiEvent.RegionOutcome);
453+
454+
const int NumRequests = Num500Errors; // initial request + three retries
455+
int requestsMade = NumRequests - _httpManager.QueueSize;
456+
Assert.AreEqual(NumRequests, requestsMade);
457+
}
458+
459+
[DataTestMethod]
460+
[DataRow(HttpStatusCode.NotFound)]
461+
[DataRow(HttpStatusCode.RequestTimeout)]
462+
public async Task RegionDiscoveryDoesNotRetryOnNonRetryableStatusCodesAsync(HttpStatusCode statusCode)
463+
{
464+
AddMockedResponse(MockHelpers.CreateNullMessage(statusCode));
465+
466+
_testRequestContext.ServiceBundle.Config.AzureRegion = ConfidentialClientApplication.AttemptRegionDiscovery;
467+
468+
InstanceDiscoveryMetadataEntry regionalMetadata = await _regionDiscoveryProvider.GetMetadataAsync(
469+
new Uri("https://login.microsoftonline.com/common/"), _testRequestContext).ConfigureAwait(false);
470+
471+
Assert.IsNull(regionalMetadata, "Discovery should fail and not retry");
472+
Assert.AreEqual(null, _testRequestContext.ApiEvent.RegionUsed);
473+
Assert.AreEqual(RegionAutodetectionSource.FailedAutoDiscovery, _testRequestContext.ApiEvent.RegionAutodetectionSource);
474+
Assert.AreEqual(RegionOutcome.FallbackToGlobal, _testRequestContext.ApiEvent.RegionOutcome);
475+
476+
const int NumRequests = 1; // initial request + 0 retries (non-retryable status codes should not trigger retry)
477+
int requestsMade = NumRequests - _httpManager.QueueSize;
478+
Assert.AreEqual(NumRequests, requestsMade);
479+
}
480+
385481
private void AddMockedResponse(HttpResponseMessage responseMessage, string apiVersion = "2020-06-01", bool expectedParams = true)
386482
{
387483
var queryParams = new Dictionary<string, string>();

tests/Microsoft.Identity.Test.Unit/Helpers/TestRetryPolicies.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,15 @@ internal override Task DelayAsync(int milliseconds)
2828
return Task.CompletedTask;
2929
}
3030
}
31+
32+
internal class TestRegionDiscoveryRetryPolicy : RegionDiscoveryRetryPolicy
33+
{
34+
public TestRegionDiscoveryRetryPolicy() : base() { }
35+
36+
internal override Task DelayAsync(int milliseconds)
37+
{
38+
// No delay for tests
39+
return Task.CompletedTask;
40+
}
41+
}
3142
}

tests/Microsoft.Identity.Test.Unit/Helpers/TestRetryPolicyFactory.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public virtual IRetryPolicy GetRetryPolicy(RequestType requestType)
1818
return new TestDefaultRetryPolicy(requestType);
1919
case RequestType.Imds:
2020
return new TestImdsRetryPolicy();
21+
case RequestType.RegionDiscovery:
22+
return new TestRegionDiscoveryRetryPolicy();
2123
default:
2224
throw new ArgumentOutOfRangeException(nameof(requestType), requestType, "Unknown request type.");
2325
}

0 commit comments

Comments
 (0)