diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java index f514f519..cc256f52 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java @@ -6,38 +6,97 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.util.*; import static com.microsoft.aad.msal4j.Constants.POINT_DELIMITER; +/** + * Helper class for handling HTTP requests and responses with retry and throttling logic. + */ class HttpHelper implements IHttpHelper { private static final Logger log = LoggerFactory.getLogger(HttpHelper.class); + + /** + * Header name for specifying retry-after duration. + */ public static final String RETRY_AFTER_HEADER = "Retry-After"; - public static final int RETRY_NUM = 2; - public static final int RETRY_DELAY_MS = 1000; + /** + * Set of exception types that are considered acceptable for retry. + */ + private static final HashSet> ACCEPTABLE_EXCEPTIONS = new HashSet<>(); + + /** + * Number of retry attempts for HTTP requests. + */ + private static final int RETRY_NUM = 2; + + /** + * Delay in milliseconds between retry attempts. + */ + private static final int RETRY_DELAY_MS = 1000; + + static { + ACCEPTABLE_EXCEPTIONS.add(ConnectException.class); + ACCEPTABLE_EXCEPTIONS.add(SocketTimeoutException.class); + ACCEPTABLE_EXCEPTIONS.add(IOException.class); + } + + /** + * RetryableCall instance for executing HTTP requests with retry logic. + */ + private static final RetryableCall RETRYABLE_CALL = + new RetryableCall<>(ACCEPTABLE_EXCEPTIONS, RETRY_NUM, RETRY_DELAY_MS); + + /** + * HTTP status code for OK. + */ public static final int HTTP_STATUS_200 = 200; + + /** + * HTTP status code for Bad Request. + */ public static final int HTTP_STATUS_400 = 400; + + /** + * HTTP status code for Too Many Requests. + */ public static final int HTTP_STATUS_429 = 429; + + /** + * HTTP status code for Internal Server Error. + */ public static final int HTTP_STATUS_500 = 500; private IHttpClient httpClient; + /** + * Constructs an instance of HttpHelper with the specified HTTP client. + * + * @param httpClient The HTTP client to use for sending requests. + */ HttpHelper(IHttpClient httpClient) { this.httpClient = httpClient; } + /** + * Executes an HTTP request with retry and telemetry logic. + * + * @param httpRequest The HTTP request to execute. + * @param requestContext The context of the request, including telemetry and client information. + * @param serviceBundle The service bundle containing application-level configurations. + * @return The HTTP response received from the server. + */ public IHttpResponse executeHttpRequest(HttpRequest httpRequest, RequestContext requestContext, ServiceBundle serviceBundle) { checkForThrottling(requestContext); - HttpEvent httpEvent = new HttpEvent(); // for tracking http telemetry + HttpEvent httpEvent = new HttpEvent(); // for tracking HTTP telemetry IHttpResponse httpResponse; try (TelemetryHelper telemetryHelper = serviceBundle.getTelemetryManager().createTelemetryHelper( @@ -67,15 +126,22 @@ public IHttpResponse executeHttpRequest(HttpRequest httpRequest, return httpResponse; } - //Overloaded version of the more commonly used HTTP executor. It does not use ServiceBundle, allowing an HTTP call to be - // made only with more bespoke request-level parameters rather than those from the app-level ServiceBundle + /** + * Overloaded version of the HTTP executor that does not use ServiceBundle. + * + * @param httpRequest The HTTP request to execute. + * @param requestContext The context of the request, including telemetry and client information. + * @param telemetryManager The telemetry manager for tracking request telemetry. + * @param httpClient The HTTP client to use for sending requests. + * @return The HTTP response received from the server. + */ IHttpResponse executeHttpRequest(HttpRequest httpRequest, - RequestContext requestContext, - TelemetryManager telemetryManager, - IHttpClient httpClient) { + RequestContext requestContext, + TelemetryManager telemetryManager, + IHttpClient httpClient) { checkForThrottling(requestContext); - HttpEvent httpEvent = new HttpEvent(); // for tracking http telemetry + HttpEvent httpEvent = new HttpEvent(); // for tracking HTTP telemetry IHttpResponse httpResponse; try (TelemetryHelper telemetryHelper = telemetryManager.createTelemetryHelper( @@ -105,6 +171,12 @@ IHttpResponse executeHttpRequest(HttpRequest httpRequest, return httpResponse; } + /** + * Executes an HTTP request without additional context or telemetry. + * + * @param httpRequest The HTTP request to execute. + * @return The HTTP response received from the server. + */ IHttpResponse executeHttpRequest(HttpRequest httpRequest) { IHttpResponse httpResponse; @@ -121,6 +193,12 @@ IHttpResponse executeHttpRequest(HttpRequest httpRequest) { return httpResponse; } + /** + * Generates a unique request thumbprint for throttling purposes. + * + * @param requestContext The context of the request. + * @return A SHA-256 hash representing the request thumbprint. + */ private String getRequestThumbprint(RequestContext requestContext) { StringBuilder sb = new StringBuilder(); sb.append(requestContext.clientId() + POINT_DELIMITER); @@ -141,16 +219,30 @@ private String getRequestThumbprint(RequestContext requestContext) { return StringHelper.createSha256Hash(sb.toString()); } + /** + * Determines if the HTTP response is retryable based on its status code. + * + * @param httpResponse The HTTP response to evaluate. + * @return True if the response is retryable, false otherwise. + */ boolean isRetryable(IHttpResponse httpResponse) { return httpResponse.statusCode() >= HTTP_STATUS_500 && getRetryAfterHeader(httpResponse) == null; } + /** + * Executes an HTTP request with retry logic. + * + * @param httpRequest The HTTP request to execute. + * @param httpClient The HTTP client to use for sending requests. + * @return The HTTP response received from the server. + * @throws Exception If the request fails after all retry attempts. + */ IHttpResponse executeHttpRequestWithRetries(HttpRequest httpRequest, IHttpClient httpClient) throws Exception { IHttpResponse httpResponse = null; for (int i = 0; i < RETRY_NUM; i++) { - httpResponse = httpClient.send(httpRequest); + httpResponse = RETRYABLE_CALL.callWithRetry(() -> httpClient.send(httpRequest)); if (!isRetryable(httpResponse)) { break; } @@ -160,6 +252,11 @@ IHttpResponse executeHttpRequestWithRetries(HttpRequest httpRequest, IHttpClient return httpResponse; } + /** + * Checks if the request is throttled and throws an exception if necessary. + * + * @param requestContext The context of the request. + */ private void checkForThrottling(RequestContext requestContext) { if (requestContext.clientApplication() instanceof PublicClientApplication && requestContext.apiParameters() != null) { @@ -173,6 +270,12 @@ private void checkForThrottling(RequestContext requestContext) { } } + /** + * Processes throttling instructions based on the HTTP response. + * + * @param httpResponse The HTTP response received. + * @param requestContext The context of the request. + */ private void processThrottlingInstructions(IHttpResponse httpResponse, RequestContext requestContext) { if (requestContext.clientApplication() instanceof PublicClientApplication) { Long expirationTimestamp = null; @@ -191,6 +294,12 @@ private void processThrottlingInstructions(IHttpResponse httpResponse, RequestCo } } + /** + * Retrieves the Retry-After header value from the HTTP response. + * + * @param httpResponse The HTTP response to evaluate. + * @return The Retry-After value in seconds, or null if not present or invalid. + */ private Integer getRetryAfterHeader(IHttpResponse httpResponse) { if (httpResponse.headers() != null) { @@ -212,6 +321,12 @@ private Integer getRetryAfterHeader(IHttpResponse httpResponse) { return null; } + /** + * Adds request information to the telemetry event. + * + * @param httpRequest The HTTP request being executed. + * @param httpEvent The telemetry event to update. + */ private void addRequestInfoToTelemetry(final HttpRequest httpRequest, HttpEvent httpEvent) { try { httpEvent.setHttpPath(httpRequest.url().toURI()); @@ -229,6 +344,12 @@ private void addRequestInfoToTelemetry(final HttpRequest httpRequest, HttpEvent } } + /** + * Adds response information to the telemetry event. + * + * @param httpResponse The HTTP response received. + * @param httpEvent The telemetry event to update. + */ private void addResponseInfoToTelemetry(IHttpResponse httpResponse, HttpEvent httpEvent) { httpEvent.setHttpResponseStatus(httpResponse.statusCode()); @@ -256,6 +377,12 @@ private void addResponseInfoToTelemetry(IHttpResponse httpResponse, HttpEvent ht } } + /** + * Verifies that the correlation ID returned in the HTTP response matches the one sent in the request. + * + * @param httpRequest The HTTP request sent. + * @param httpResponse The HTTP response received. + */ private static void verifyReturnedCorrelationId(final HttpRequest httpRequest, IHttpResponse httpResponse) { @@ -279,4 +406,4 @@ private static void verifyReturnedCorrelationId(final HttpRequest httpRequest, log.info(msg); } } -} +} \ No newline at end of file diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/RetryableCall.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/RetryableCall.java new file mode 100644 index 00000000..e2f3264a --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/RetryableCall.java @@ -0,0 +1,94 @@ +package com.microsoft.aad.msal4j; + +import java.util.Set; +import java.util.concurrent.Callable; + +/** + * A utility class that provides retry functionality for a callable operation. + * + * @param The type of the result returned by the callable operation. + */ +public class RetryableCall { + private final Set> acceptableExceptions; // Set of exception types that are acceptable for retry. + private final int retryCount; // Maximum number of retry attempts. + private int initialDelayMs; // Initial delay between retries in milliseconds. + + /** + * Constructs a RetryableCall with a default retry count of 3 and an initial delay of 500ms. + * + * @param acceptableExceptions A set of exception types that are acceptable for retry. + */ + public RetryableCall(Set> acceptableExceptions) { + this(acceptableExceptions, 3); + } + + /** + * Constructs a RetryableCall with a specified retry count and a default initial delay of 500ms. + * + * @param acceptableExceptions A set of exception types that are acceptable for retry. + * @param retryCount The maximum number of retry attempts. + */ + public RetryableCall(Set> acceptableExceptions, int retryCount) { + this(acceptableExceptions, retryCount, 500); + } + + /** + * Constructs a RetryableCall with specified retry count and initial delay. + * + * @param acceptableExceptions A set of exception types that are acceptable for retry. + * @param retryCount The maximum number of retry attempts. + * @param initialDelayMs The initial delay between retries in milliseconds. + */ + public RetryableCall(Set> acceptableExceptions, int retryCount, int initialDelayMs) { + this.acceptableExceptions = acceptableExceptions; + this.retryCount = retryCount; + this.initialDelayMs = initialDelayMs; + } + + /** + * Executes the given callable operation with retry logic. + * + * @param t The callable operation to execute. + * @return The result of the callable operation. + * @throws Exception If the operation fails after the maximum number of retries or if an exception + * not in the acceptableExceptions set is thrown. + */ + public T callWithRetry(Callable t) throws Exception { + Exception lastException; + int attempts = 0; + do { + try { + return t.call(); + } catch (Exception e) { + lastException = e; + if (!acceptableExceptions.contains(e.getClass())) { + throw e; + } + } + Thread.sleep(initialDelayMs); + } while (++attempts != retryCount); + throw lastException; + } + + /** + * Gets the maximum number of retry attempts. + * + * @return The retry count. + */ + public int getRetryCount() { + return retryCount; + } + + /** + * Returns a string representation of the RetryableCall object. + * + * @return A string containing the acceptable exceptions and retry count. + */ + @Override + public String toString() { + return "RetryableCall{" + + "acceptableExceptions=" + acceptableExceptions + + ", retryCount=" + retryCount + + '}'; + } +} \ No newline at end of file