Skip to content
This repository was archived by the owner on Dec 4, 2023. It is now read-only.

Commit 42edeb3

Browse files
Add retry fallback for 429 responses (#1121)
1 parent 6f41bfa commit 42edeb3

File tree

3 files changed

+181
-14
lines changed

3 files changed

+181
-14
lines changed

libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/CredentialsAuthenticator.java

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.microsoft.aad.msal4j.ClientCredentialParameters;
88
import com.microsoft.aad.msal4j.ConfidentialClientApplication;
99
import com.microsoft.aad.msal4j.IAuthenticationResult;
10+
import com.microsoft.aad.msal4j.MsalServiceException;
1011

1112
import java.net.MalformedURLException;
1213
import java.util.Collections;
@@ -28,16 +29,12 @@ public class CredentialsAuthenticator implements Authenticator {
2829
* @throws MalformedURLException Invalid endpoint.
2930
*/
3031
CredentialsAuthenticator(String appId, String appPassword, OAuthConfiguration configuration)
31-
throws MalformedURLException {
32+
throws MalformedURLException {
3233

33-
app = ConfidentialClientApplication
34-
.builder(appId, ClientCredentialFactory.createFromSecret(appPassword))
35-
.authority(configuration.getAuthority())
36-
.build();
34+
app = ConfidentialClientApplication.builder(appId, ClientCredentialFactory.createFromSecret(appPassword))
35+
.authority(configuration.getAuthority()).build();
3736

38-
parameters = ClientCredentialParameters
39-
.builder(Collections.singleton(configuration.getScope()))
40-
.build();
37+
parameters = ClientCredentialParameters.builder(Collections.singleton(configuration.getScope())).build();
4138
}
4239

4340
/**
@@ -47,12 +44,19 @@ public class CredentialsAuthenticator implements Authenticator {
4744
*/
4845
@Override
4946
public CompletableFuture<IAuthenticationResult> acquireToken() {
50-
return app.acquireToken(parameters)
51-
.exceptionally(
52-
exception -> {
53-
// wrapping whatever msal throws into our own exception
54-
throw new AuthenticationException(exception);
47+
return Retry.run(() -> app.acquireToken(parameters).exceptionally(exception -> {
48+
// wrapping whatever msal throws into our own exception
49+
throw new AuthenticationException(exception);
50+
}), (exception, count) -> {
51+
if (exception instanceof RetryException && exception.getCause() instanceof MsalServiceException) {
52+
MsalServiceException serviceException = (MsalServiceException) exception.getCause();
53+
if (serviceException.headers().containsKey("Retry-After")) {
54+
return RetryAfterHelper.processRetry(serviceException.headers().get("Retry-After"), count);
55+
} else {
56+
return RetryParams.defaultBackOff(++count);
5557
}
56-
);
58+
}
59+
return RetryParams.stopRetrying();
60+
});
5761
}
5862
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.microsoft.bot.connector.authentication;
2+
3+
import java.time.Duration;
4+
import java.time.ZoneOffset;
5+
import java.time.ZonedDateTime;
6+
import java.time.format.DateTimeFormatter;
7+
import java.time.format.DateTimeParseException;
8+
import java.util.List;
9+
10+
import org.apache.commons.lang3.StringUtils;
11+
12+
/**
13+
* Class that contains a helper function to process HTTP 429 Retry-After headers
14+
* for the CredentialsAuthenticator. The reason to extract this was
15+
* CredentialsAuthenticator is an internal class that isn't exposed except
16+
* through other Authentication classes and we wanted a way to test the
17+
* processing of 429 headers without building complicated test harnesses.
18+
*/
19+
public final class RetryAfterHelper {
20+
21+
private RetryAfterHelper() {
22+
23+
}
24+
25+
/**
26+
* Process a RetryException and see if we should wait for a requested amount of
27+
* time before retrying to call the authentication service again.
28+
*
29+
* @param header The header values to process.
30+
* @param count The count of how many times we have retried.
31+
* @return A RetryParams with instructions of when or how many more times to
32+
* retry.
33+
*/
34+
public static RetryParams processRetry(List<String> header, Integer count) {
35+
if (header == null || header.size() == 0) {
36+
return RetryParams.defaultBackOff(++count);
37+
} else {
38+
String headerString = header.get(0);
39+
if (StringUtils.isNotBlank(headerString)) {
40+
// see if it matches a numeric value
41+
if (headerString.matches("^[0-9]+\\.?0*$")) {
42+
headerString = headerString.replaceAll("\\.0*$", "");
43+
Duration delay = Duration.ofSeconds(Long.parseLong(headerString));
44+
return new RetryParams(delay.toMillis());
45+
} else {
46+
// check to see if it's a RFC_1123 format Date/Time
47+
DateTimeFormatter gmtFormat = DateTimeFormatter.RFC_1123_DATE_TIME;
48+
try {
49+
ZonedDateTime zoned = ZonedDateTime.parse(headerString, gmtFormat);
50+
if (zoned != null) {
51+
ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
52+
long waitMillis = zoned.toInstant().toEpochMilli() - now.toInstant().toEpochMilli();
53+
if (waitMillis > 0) {
54+
return new RetryParams(waitMillis);
55+
} else {
56+
return RetryParams.defaultBackOff(++count);
57+
}
58+
}
59+
} catch (DateTimeParseException ex) {
60+
return RetryParams.defaultBackOff(++count);
61+
}
62+
}
63+
}
64+
}
65+
return RetryParams.defaultBackOff(++count);
66+
}
67+
68+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.microsoft.bot.connector;
2+
3+
import java.time.Instant;
4+
import java.time.LocalDateTime;
5+
import java.time.ZoneId;
6+
import java.time.ZonedDateTime;
7+
import java.time.format.DateTimeFormatter;
8+
import java.time.temporal.TemporalUnit;
9+
import java.util.ArrayList;
10+
import java.util.HashMap;
11+
import java.util.List;
12+
import java.util.Map;
13+
14+
import com.microsoft.aad.msal4j.MsalException;
15+
import com.microsoft.aad.msal4j.MsalServiceException;
16+
import com.microsoft.bot.connector.authentication.RetryAfterHelper;
17+
import com.microsoft.bot.connector.authentication.RetryException;
18+
import com.microsoft.bot.connector.authentication.RetryParams;
19+
20+
import org.junit.Assert;
21+
import org.junit.Test;
22+
23+
public class RetryAfterHelperTests {
24+
25+
@Test
26+
public void TestRetryIncrement() {
27+
RetryParams result = RetryAfterHelper.processRetry(new ArrayList<String>(), 8);
28+
Assert.assertTrue(result.getShouldRetry());
29+
result = RetryAfterHelper.processRetry(new ArrayList<String>(), 9);
30+
Assert.assertFalse(result.getShouldRetry());
31+
}
32+
33+
@Test
34+
public void TestRetryDelaySeconds() {
35+
List<String> headers = new ArrayList<String>();
36+
headers.add("10");
37+
RetryParams result = RetryAfterHelper.processRetry(headers, 1);
38+
Assert.assertEquals(result.getRetryAfter(), 10000);
39+
}
40+
41+
@Test
42+
public void TestRetryDelayRFC1123Date() {
43+
Instant instant = Instant.now().plusSeconds(5);
44+
ZonedDateTime dateTime = instant.atZone(ZoneId.of("UTC"));
45+
String dateTimeString = dateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME);
46+
List<String> headers = new ArrayList<String>();
47+
headers.add(dateTimeString);
48+
RetryParams result = RetryAfterHelper.processRetry(headers, 1);
49+
Assert.assertTrue(result.getShouldRetry());
50+
Assert.assertTrue(result.getRetryAfter() > 0);
51+
}
52+
53+
@Test
54+
public void TestRetryDelayRFC1123DateInPast() {
55+
Instant instant = Instant.now().plusSeconds(-5);
56+
ZonedDateTime dateTime = instant.atZone(ZoneId.of("UTC"));
57+
String dateTimeString = dateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME);
58+
List<String> headers = new ArrayList<String>();
59+
headers.add(dateTimeString);
60+
RetryParams result = RetryAfterHelper.processRetry(headers, 1);
61+
Assert.assertTrue(result.getShouldRetry());
62+
// default is 50, so since the time was in the past we should be seeing the default 50 here.
63+
Assert.assertTrue(result.getRetryAfter() == 50);
64+
}
65+
66+
67+
@Test
68+
public void TestRetryDelayRFC1123DateEmpty() {
69+
List<String> headers = new ArrayList<String>();
70+
headers.add("");
71+
RetryParams result = RetryAfterHelper.processRetry(headers, 1);
72+
Assert.assertTrue(result.getShouldRetry());
73+
// default is 50, so since the time was in the past we should be seeing the default 50 here.
74+
Assert.assertTrue(result.getRetryAfter() == 50);
75+
}
76+
77+
@Test
78+
public void TestRetryDelayRFC1123DateNull() {
79+
List<String> headers = new ArrayList<String>();
80+
headers.add(null);
81+
RetryParams result = RetryAfterHelper.processRetry(headers, 1);
82+
Assert.assertTrue(result.getShouldRetry());
83+
// default is 50, so since the time was in the past we should be seeing the default 50 here.
84+
Assert.assertTrue(result.getRetryAfter() == 50);
85+
}
86+
87+
@Test
88+
public void TestRetryDelayRFC1123NeaderNull() {
89+
RetryParams result = RetryAfterHelper.processRetry(null, 1);
90+
Assert.assertTrue(result.getShouldRetry());
91+
// default is 50, so since the time was in the past we should be seeing the default 50 here.
92+
Assert.assertTrue(result.getRetryAfter() == 50);
93+
}
94+
95+
}

0 commit comments

Comments
 (0)