com.okta.commons.http.RetryRequestExecutor Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of okta-http-api Show documentation
Show all versions of okta-http-api Show documentation
Provides a common HTTP Interface for Okta's Java libraries
/*
* Copyright 2018-Present Okta, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.okta.commons.http;
import com.okta.commons.http.config.HttpClientConfiguration;
import com.okta.commons.lang.Assert;
import com.okta.commons.lang.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.InputStream;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
public final class RetryRequestExecutor implements RequestExecutor {
private static final Logger log = LoggerFactory.getLogger(RetryRequestExecutor.class);
/**
* Maximum exponential back-off time before retrying a request
*/
private static final int DEFAULT_MAX_BACKOFF_IN_MILLISECONDS = 20 * 1000;
private static final int DEFAULT_MAX_RETRIES = 4;
private int maxRetries = DEFAULT_MAX_RETRIES;
private int maxElapsedMillis = 0;
private final RequestExecutor delegate;
public RetryRequestExecutor(HttpClientConfiguration clientConfiguration, RequestExecutor delegate) {
this.delegate = delegate;
if (clientConfiguration.getRetryMaxElapsed() >= 0) {
maxElapsedMillis = clientConfiguration.getRetryMaxElapsed() * 1000;
}
if (clientConfiguration.getRetryMaxAttempts() > 0) {
maxRetries = clientConfiguration.getRetryMaxAttempts();
}
}
@Override
public Response executeRequest(Request request) throws HttpException {
Assert.notNull(request, "Request argument cannot be null.");
int retryCount = 0;
Response response = null;
String requestId = null;
Timer timer = new Timer();
// Make a copy of the original request params and headers so that we can
// permute them in the loop and start over with the original every time.
QueryString originalQuery = new QueryString();
originalQuery.putAll(request.getQueryString());
HttpHeaders originalHeaders = new HttpHeaders();
originalHeaders.putAll(request.getHeaders());
while (true) {
try {
if (retryCount > 0) {
request.setQueryString(originalQuery);
request.setHeaders(originalHeaders);
// remember the request-id header if we need to retry
if (requestId == null) {
requestId = getRequestId(response);
}
InputStream content = request.getBody();
if (content != null && content.markSupported()) {
content.reset();
}
try {
// if we cannot pause, then return the original response
pauseBeforeRetry(retryCount, response, timer.split());
} catch (HttpException e) {
if (log.isDebugEnabled()) {
log.warn("Unable to pause for retry: {}", e.getMessage(), e);
} else {
log.warn("Unable to pause for retry: {}", e.getMessage());
}
// First attempt failed, and we were not able to retry
if (response == null) {
throw new HttpException("Unable to execute HTTP request: " + e.getMessage(), e);
}
return response;
}
}
retryCount++;
// include X-Okta headers when retrying
setOktaHeaders(request, requestId, retryCount);
response = doExecuteRequest(request);
//allow the loop to continue to execute a retry request
if (!shouldRetry(response, retryCount, timer.split())) {
return response;
}
} catch (SocketException | SocketTimeoutException e) { // known generic retryable exceptions
if (!shouldRetry(retryCount, timer.split())) {
throw new HttpException("Unable to execute HTTP request: " + e.getMessage(), e);
}
log.debug("Retrying on {}: {}", e.getClass().getName(), e.getMessage());
} catch (HttpException e) {
// exceptions from delegate marked as retrHttpClientRequestExecutoryable
if (!e.isRetryable() || !shouldRetry(retryCount, timer.split())) {
throw e;
}
} catch (Exception e) {
throw new HttpException("Unable to execute HTTP request: " + e.getMessage(), e);
}
}
}
// exposed to allow HttpClientRequestExecutor to be backward compatible
// do NOT use directly
protected Response doExecuteRequest(Request request) {
return delegate.executeRequest(request);
}
/**
* Exponential sleep on failed request to avoid flooding a service with
* retries.
*
* @param retries Current retry count.
*/
private void pauseBeforeRetry(int retries, Response response, long timeElapsed) throws HttpException {
long delay = -1;
long timeElapsedLeft = maxElapsedMillis - timeElapsed;
// check before continuing
if (!shouldRetry(retries, timeElapsed)) {
throw failedToRetry();
}
if (response != null && response.getHttpStatus() == 429) {
delay = get429DelayMillis(response);
if (!shouldRetry(retries, timeElapsed + delay)) {
throw failedToRetry();
}
log.debug("429 detected, will retry in {}ms, attempt number: {}", delay, retries);
}
// default / fallback strategy (backwards compatible implementation)
if (delay < 0) {
delay = Math.min(getDefaultDelayMillis(retries), timeElapsedLeft);
}
// this shouldn't happen, but guard against a negative delay at this point
if (delay < 0) {
throw failedToRetry();
}
log.debug("Retryable condition detected, will retry in {}ms, attempt number: {}", delay, retries);
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new HttpException(e.getMessage(), e);
}
}
private long get429DelayMillis(Response response) {
// the time at which the rate limit will reset, specified in UTC epoch time.
long resetLimit = getRateLimitResetValue(response);
if (resetLimit == -1L) {
return -1;
}
// If the Date header is not set, do not continue
Date requestDate = dateFromHeader(response);
if (requestDate == null) {
return -1;
}
long waitUntil = resetLimit * 1000L;
long requestTime = requestDate.getTime();
long delay = Math.max(waitUntil - requestTime + 1000, 1000);
log.debug("429 wait: Math.max({} - {} + 1s), 1s = {})", waitUntil, requestTime, delay);
return delay;
}
private Date dateFromHeader(Response response) {
Date result = null;
long dateLong = response.getHeaders().getDate();
if (dateLong > 0) {
result = new Date(dateLong);
}
return result;
}
private long getDefaultDelayMillis(int retries) {
long scaleFactor = 300;
long result = (long) (Math.pow(2, retries) * scaleFactor);
return Math.min(result, DEFAULT_MAX_BACKOFF_IN_MILLISECONDS);
}
private boolean shouldRetry(int retryCount, long timeElapsed) {
// either maxRetries or maxElapsedMillis is enabled
return (maxRetries > 0 || maxElapsedMillis > 0)
// maxRetries count is disabled OR if set check it
&& (maxRetries <= 0 || retryCount <= this.maxRetries)
// maxElapsedMillis is disabled OR if set check it
&& (maxElapsedMillis <= 0 || timeElapsed < maxElapsedMillis);
}
private boolean shouldRetry(Response response, int retryCount, long timeElapsed) {
int httpStatus = response.getHttpStatus();
// supported status codes
return shouldRetry(retryCount, timeElapsed)
&& (httpStatus == 429
|| httpStatus == 503
|| httpStatus == 504);
}
private HttpException failedToRetry() {
return new HttpException("Cannot retry request, next request will exceed retry configuration.");
}
private long getRateLimitResetValue(Response response) {
return response.getHeaders().getOrDefault("X-Rate-Limit-Reset", Collections.emptyList()).stream()
.filter(value -> !Strings.isEmpty(value))
.filter(value -> value.chars().allMatch(Character::isDigit))
.map(Long::parseLong)
.filter(value -> value > 0)
.min(Comparator.naturalOrder())
.orElse(-1L);
}
private String getRequestId(Response response) {
if (response != null) {
return response.getHeaders().getFirst("X-Okta-Request-Id");
}
return null;
}
/**
* Adds {@code X-Okta-Retry-For} and {@code X-Okta-Retry-Count} headers to request if not null/empty or zero.
*
* @param request the request to add headers too
* @param requestId request ID of the original request that failed
* @param retryCount the number of times the request has been retried
*/
private void setOktaHeaders(Request request, String requestId, int retryCount) {
if (Strings.hasText(requestId)) {
request.getHeaders().add("X-Okta-Retry-For", requestId);
}
if (retryCount > 1) {
request.getHeaders().add("X-Okta-Retry-Count", Integer.toString(retryCount));
}
}
@Deprecated
public int getNumRetries() {
return maxRetries;
}
@Deprecated
public void setNumRetries(int numRetries) {
this.maxRetries = numRetries;
}
@Deprecated
int getMaxElapsedMillis() {
return maxElapsedMillis;
}
@Deprecated
void setMaxElapsedMillis(int maxElapsedMillis) {
this.maxElapsedMillis = maxElapsedMillis;
}
private static class Timer {
private long startTime = System.currentTimeMillis();
long split() {
return System.currentTimeMillis() - startTime;
}
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy