diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/CredentialsAuthenticator.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/CredentialsAuthenticator.java index 1bfbe6c03..9727335b8 100644 --- a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/CredentialsAuthenticator.java +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/CredentialsAuthenticator.java @@ -7,6 +7,7 @@ import com.microsoft.aad.msal4j.ClientCredentialParameters; import com.microsoft.aad.msal4j.ConfidentialClientApplication; import com.microsoft.aad.msal4j.IAuthenticationResult; +import com.microsoft.aad.msal4j.MsalServiceException; import java.net.MalformedURLException; import java.util.Collections; @@ -28,16 +29,12 @@ public class CredentialsAuthenticator implements Authenticator { * @throws MalformedURLException Invalid endpoint. */ CredentialsAuthenticator(String appId, String appPassword, OAuthConfiguration configuration) - throws MalformedURLException { + throws MalformedURLException { - app = ConfidentialClientApplication - .builder(appId, ClientCredentialFactory.createFromSecret(appPassword)) - .authority(configuration.getAuthority()) - .build(); + app = ConfidentialClientApplication.builder(appId, ClientCredentialFactory.createFromSecret(appPassword)) + .authority(configuration.getAuthority()).build(); - parameters = ClientCredentialParameters - .builder(Collections.singleton(configuration.getScope())) - .build(); + parameters = ClientCredentialParameters.builder(Collections.singleton(configuration.getScope())).build(); } /** @@ -47,12 +44,19 @@ public class CredentialsAuthenticator implements Authenticator { */ @Override public CompletableFuture acquireToken() { - return app.acquireToken(parameters) - .exceptionally( - exception -> { - // wrapping whatever msal throws into our own exception - throw new AuthenticationException(exception); + return Retry.run(() -> app.acquireToken(parameters).exceptionally(exception -> { + // wrapping whatever msal throws into our own exception + throw new AuthenticationException(exception); + }), (exception, count) -> { + if (exception instanceof RetryException && exception.getCause() instanceof MsalServiceException) { + MsalServiceException serviceException = (MsalServiceException) exception.getCause(); + if (serviceException.headers().containsKey("Retry-After")) { + return RetryAfterHelper.processRetry(serviceException.headers().get("Retry-After"), count); + } else { + return RetryParams.defaultBackOff(++count); } - ); + } + return RetryParams.stopRetrying(); + }); } } diff --git a/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/RetryAfterHelper.java b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/RetryAfterHelper.java new file mode 100644 index 000000000..5c7c8db9e --- /dev/null +++ b/libraries/bot-connector/src/main/java/com/microsoft/bot/connector/authentication/RetryAfterHelper.java @@ -0,0 +1,68 @@ +package com.microsoft.bot.connector.authentication; + +import java.time.Duration; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +/** + * Class that contains a helper function to process HTTP 429 Retry-After headers + * for the CredentialsAuthenticator. The reason to extract this was + * CredentialsAuthenticator is an internal class that isn't exposed except + * through other Authentication classes and we wanted a way to test the + * processing of 429 headers without building complicated test harnesses. + */ +public final class RetryAfterHelper { + + private RetryAfterHelper() { + + } + + /** + * Process a RetryException and see if we should wait for a requested amount of + * time before retrying to call the authentication service again. + * + * @param header The header values to process. + * @param count The count of how many times we have retried. + * @return A RetryParams with instructions of when or how many more times to + * retry. + */ + public static RetryParams processRetry(List header, Integer count) { + if (header == null || header.size() == 0) { + return RetryParams.defaultBackOff(++count); + } else { + String headerString = header.get(0); + if (StringUtils.isNotBlank(headerString)) { + // see if it matches a numeric value + if (headerString.matches("^[0-9]+\\.?0*$")) { + headerString = headerString.replaceAll("\\.0*$", ""); + Duration delay = Duration.ofSeconds(Long.parseLong(headerString)); + return new RetryParams(delay.toMillis()); + } else { + // check to see if it's a RFC_1123 format Date/Time + DateTimeFormatter gmtFormat = DateTimeFormatter.RFC_1123_DATE_TIME; + try { + ZonedDateTime zoned = ZonedDateTime.parse(headerString, gmtFormat); + if (zoned != null) { + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + long waitMillis = zoned.toInstant().toEpochMilli() - now.toInstant().toEpochMilli(); + if (waitMillis > 0) { + return new RetryParams(waitMillis); + } else { + return RetryParams.defaultBackOff(++count); + } + } + } catch (DateTimeParseException ex) { + return RetryParams.defaultBackOff(++count); + } + } + } + } + return RetryParams.defaultBackOff(++count); + } + +} diff --git a/libraries/bot-connector/src/test/java/com/microsoft/bot/connector/RetryAfterHelperTests.java b/libraries/bot-connector/src/test/java/com/microsoft/bot/connector/RetryAfterHelperTests.java new file mode 100644 index 000000000..155c96386 --- /dev/null +++ b/libraries/bot-connector/src/test/java/com/microsoft/bot/connector/RetryAfterHelperTests.java @@ -0,0 +1,95 @@ +package com.microsoft.bot.connector; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.microsoft.aad.msal4j.MsalException; +import com.microsoft.aad.msal4j.MsalServiceException; +import com.microsoft.bot.connector.authentication.RetryAfterHelper; +import com.microsoft.bot.connector.authentication.RetryException; +import com.microsoft.bot.connector.authentication.RetryParams; + +import org.junit.Assert; +import org.junit.Test; + +public class RetryAfterHelperTests { + + @Test + public void TestRetryIncrement() { + RetryParams result = RetryAfterHelper.processRetry(new ArrayList(), 8); + Assert.assertTrue(result.getShouldRetry()); + result = RetryAfterHelper.processRetry(new ArrayList(), 9); + Assert.assertFalse(result.getShouldRetry()); + } + + @Test + public void TestRetryDelaySeconds() { + List headers = new ArrayList(); + headers.add("10"); + RetryParams result = RetryAfterHelper.processRetry(headers, 1); + Assert.assertEquals(result.getRetryAfter(), 10000); + } + + @Test + public void TestRetryDelayRFC1123Date() { + Instant instant = Instant.now().plusSeconds(5); + ZonedDateTime dateTime = instant.atZone(ZoneId.of("UTC")); + String dateTimeString = dateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME); + List headers = new ArrayList(); + headers.add(dateTimeString); + RetryParams result = RetryAfterHelper.processRetry(headers, 1); + Assert.assertTrue(result.getShouldRetry()); + Assert.assertTrue(result.getRetryAfter() > 0); + } + + @Test + public void TestRetryDelayRFC1123DateInPast() { + Instant instant = Instant.now().plusSeconds(-5); + ZonedDateTime dateTime = instant.atZone(ZoneId.of("UTC")); + String dateTimeString = dateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME); + List headers = new ArrayList(); + headers.add(dateTimeString); + RetryParams result = RetryAfterHelper.processRetry(headers, 1); + Assert.assertTrue(result.getShouldRetry()); + // default is 50, so since the time was in the past we should be seeing the default 50 here. + Assert.assertTrue(result.getRetryAfter() == 50); + } + + + @Test + public void TestRetryDelayRFC1123DateEmpty() { + List headers = new ArrayList(); + headers.add(""); + RetryParams result = RetryAfterHelper.processRetry(headers, 1); + Assert.assertTrue(result.getShouldRetry()); + // default is 50, so since the time was in the past we should be seeing the default 50 here. + Assert.assertTrue(result.getRetryAfter() == 50); + } + + @Test + public void TestRetryDelayRFC1123DateNull() { + List headers = new ArrayList(); + headers.add(null); + RetryParams result = RetryAfterHelper.processRetry(headers, 1); + Assert.assertTrue(result.getShouldRetry()); + // default is 50, so since the time was in the past we should be seeing the default 50 here. + Assert.assertTrue(result.getRetryAfter() == 50); + } + + @Test + public void TestRetryDelayRFC1123NeaderNull() { + RetryParams result = RetryAfterHelper.processRetry(null, 1); + Assert.assertTrue(result.getShouldRetry()); + // default is 50, so since the time was in the past we should be seeing the default 50 here. + Assert.assertTrue(result.getRetryAfter() == 50); + } + +}