diff --git a/changelog.md b/changelog.md
index 5eda11b..c98e8d7 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,11 @@
# Changelog
+## v1.11.0
+
+### Feb 09, 2026
+
+- Enhancement: Retry Mechanism
+
## v1.10.2
### Jan 27, 2026
diff --git a/pom.xml b/pom.xml
index 7cd0899..b978879 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
cms
jar
contentstack-management-java
- 1.10.2
+ 1.11.0
Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an
API-first approach
diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java
index 84b9358..1bcad23 100644
--- a/src/main/java/com/contentstack/cms/Contentstack.java
+++ b/src/main/java/com/contentstack/cms/Contentstack.java
@@ -20,15 +20,14 @@
import com.contentstack.cms.models.LoginDetails;
import com.contentstack.cms.models.OAuthConfig;
import com.contentstack.cms.models.OAuthTokens;
-import com.contentstack.cms.oauth.TokenCallback;
import com.contentstack.cms.oauth.OAuthHandler;
import com.contentstack.cms.oauth.OAuthInterceptor;
+import com.contentstack.cms.oauth.TokenCallback;
import com.contentstack.cms.organization.Organization;
import com.contentstack.cms.stack.Stack;
import com.contentstack.cms.user.User;
import com.google.gson.Gson;
-import com.warrenstrange.googleauth.GoogleAuthenticator;
-
+import com.contentstack.cms.core.RetryConfig;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import okhttp3.ResponseBody;
@@ -63,6 +62,7 @@ public class Contentstack {
protected OAuthHandler oauthHandler;
protected String[] earlyAccess;
protected User user;
+ protected RetryConfig retryConfig;
/**
* All accounts registered with Contentstack are known as Users. A stack can
@@ -571,6 +571,11 @@ public Contentstack(Builder builder) {
this.oauthInterceptor = builder.oauthInterceptor;
this.oauthHandler = builder.oauthHandler;
this.earlyAccess = builder.earlyAccess;
+ this.retryConfig = builder.retryConfig;
+ }
+
+ public RetryConfig getRetryConfig() {
+ return retryConfig;
}
/**
@@ -595,7 +600,7 @@ public static class Builder {
private String version = Util.VERSION; // Default Version for Contentstack API
private int timeout = Util.TIMEOUT; // Default timeout 30 seconds
private Boolean retry = Util.RETRY_ON_FAILURE;// Default base url for contentstack
-
+ private RetryConfig retryConfig = RetryConfig.defaultConfig();
/**
* Default ConnectionPool holds up to 5 idle connections which will be
* evicted after 5 minutes of inactivity.
@@ -853,7 +858,7 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur
if (this.earlyAccess != null) {
this.oauthInterceptor.setEarlyAccess(this.earlyAccess);
}
-
+ this.oauthInterceptor.setRetryConfig(this.retryConfig);
// Add interceptor to handle OAuth, token refresh, and retries
builder.addInterceptor(this.oauthInterceptor);
} else {
@@ -863,7 +868,7 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur
if (this.earlyAccess != null) {
this.authInterceptor.setEarlyAccess(this.earlyAccess);
}
-
+ this.authInterceptor.setRetryConfig(this.retryConfig);
builder.addInterceptor(this.authInterceptor);
}
@@ -874,5 +879,12 @@ private HttpLoggingInterceptor logger() {
return new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.NONE);
}
+
+ public Builder setRetryConfig(RetryConfig retryConfig) {
+ this.retryConfig = retryConfig;
+ return this;
+ }
+
+
}
}
diff --git a/src/main/java/com/contentstack/cms/core/AuthInterceptor.java b/src/main/java/com/contentstack/cms/core/AuthInterceptor.java
index 93802a9..80ee0f9 100644
--- a/src/main/java/com/contentstack/cms/core/AuthInterceptor.java
+++ b/src/main/java/com/contentstack/cms/core/AuthInterceptor.java
@@ -27,7 +27,7 @@ public class AuthInterceptor implements Interceptor {
protected String authtoken;
protected String[] earlyAccess;
-
+ protected RetryConfig retryConfig = RetryConfig.defaultConfig();
// The `public AuthInterceptor() {}` is a default constructor for the
// `AuthInterceptor` class. It is
// used to create an instance of the `AuthInterceptor` class without passing any
@@ -93,7 +93,7 @@ public Response intercept(Chain chain) throws IOException {
String commaSeparated = String.join(", ", earlyAccess);
request.addHeader(Util.EARLY_ACCESS_HEADER, commaSeparated);
}
- return chain.proceed(request.build());
+ return executeRequest(chain, request.build(), 0);
}
/**
@@ -112,4 +112,25 @@ private boolean isDeleteReleaseRequest(Request request) {
return path.matches(".*/releases/[^/]+$");
}
+ public void setRetryConfig(RetryConfig retryConfig) {
+ this.retryConfig = retryConfig != null ? retryConfig : RetryConfig.defaultConfig();
+ }
+
+ private Response executeRequest(Chain chain, Request request, int retryCount) throws IOException{
+ Response response = chain.proceed(request);
+ int code = response.code();
+ if(retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(code, null)){
+ response.close();
+ long delay = RetryUtil.calculateDelay(retryConfig, retryCount+1, code);
+ try {
+ Thread.sleep(delay);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Retry interrupted", ex);
+ }
+ return executeRequest(chain, request, retryCount + 1);
+ }
+ return response;
+ }
+
}
diff --git a/src/main/java/com/contentstack/cms/core/CustomBackoff.java b/src/main/java/com/contentstack/cms/core/CustomBackoff.java
new file mode 100644
index 0000000..081a60e
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/core/CustomBackoff.java
@@ -0,0 +1,32 @@
+package com.contentstack.cms.core;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Functional interface for custom backoff delay calculation.
+ *
+ * Allows custom logic to calculate retry delays based on retry count and error information.
+ * This enables advanced backoff strategies like exponential backoff with jitter.
+ *
+ *
+ * @author Contentstack
+ * @version v1.0.0
+ * @since 2026-01-28
+ */
+@FunctionalInterface
+public interface CustomBackoff {
+
+ /**
+ * Calculates the delay in milliseconds before the next retry attempt.
+ *
+ * @param retryCount The current retry attempt number (1-based: 1st retry, 2nd retry, etc.)
+ * @param statusCode HTTP status code from the response, or:
+ *
+ * - 0 for network errors
+ * - -1 for unknown errors
+ *
+ * @param error The throwable that caused the failure (may be null)
+ * @return The delay in milliseconds before the next retry
+ */
+ long calculate(int retryCount, int statusCode, @Nullable Throwable error);
+}
diff --git a/src/main/java/com/contentstack/cms/core/DefaultRetryCondition.java b/src/main/java/com/contentstack/cms/core/DefaultRetryCondition.java
new file mode 100644
index 0000000..18d6431
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/core/DefaultRetryCondition.java
@@ -0,0 +1,84 @@
+package com.contentstack.cms.core;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.io.IOException;
+import java.net.SocketTimeoutException;
+
+/**
+ * Default implementation of RetryCondition that retries on:
+ *
+ * - HTTP status codes: 408 (Request Timeout), 429 (Too Many Requests),
+ * 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable),
+ * 504 (Gateway Timeout)
+ * - Network errors: IOException, SocketTimeoutException
+ *
+ *
+ * This matches the default retry behavior of the JavaScript Delivery SDK.
+ *
+ *
+ * @author Contentstack
+ * @version v1.0.0
+ * @since 2026-01-28
+ */
+public class DefaultRetryCondition implements RetryCondition {
+
+ /**
+ * Default retryable HTTP status codes.
+ * Matches JS SDK default: [408, 429, 500, 502, 503, 504]
+ */
+ private static final int[] RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504};
+
+ /**
+ * Singleton instance for reuse.
+ */
+ private static final DefaultRetryCondition INSTANCE = new DefaultRetryCondition();
+
+ /**
+ * Private constructor to enforce singleton pattern.
+ */
+ private DefaultRetryCondition() {
+ }
+
+ /**
+ * Gets the singleton instance of DefaultRetryCondition.
+ *
+ * @return the singleton instance
+ */
+ public static DefaultRetryCondition getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Determines if an error should be retried based on status code and exception type.
+ *
+ * @param statusCode HTTP status code (0 = network error, -1 = unknown)
+ * @param error The throwable that caused the failure (may be null)
+ * @return true if the error should be retried, false otherwise
+ */
+ @Override
+ public boolean shouldRetry(int statusCode, @Nullable Throwable error) {
+ // Network errors (statusCode = 0) are always retryable
+ if (statusCode == 0) {
+ return true;
+ }
+
+ // Unknown errors (statusCode = -1) are not retryable by default
+ if (statusCode == -1) {
+ // However, if it's a network-related exception, we should retry
+ if (error != null && (error instanceof IOException || error instanceof SocketTimeoutException)) {
+ return true;
+ }
+ return false;
+ }
+
+ // Check if status code is in the retryable list
+ for (int code : RETRYABLE_STATUS_CODES) {
+ if (statusCode == code) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/main/java/com/contentstack/cms/core/RetryCallback.java b/src/main/java/com/contentstack/cms/core/RetryCallback.java
index f75a55e..2fa6fcb 100644
--- a/src/main/java/com/contentstack/cms/core/RetryCallback.java
+++ b/src/main/java/com/contentstack/cms/core/RetryCallback.java
@@ -1,16 +1,21 @@
package com.contentstack.cms.core;
import org.jetbrains.annotations.NotNull;
+
import retrofit2.Call;
import retrofit2.Callback;
+import retrofit2.HttpException;
import java.util.logging.Logger;
+import retrofit2.Response;
+
+import java.io.IOException;
+import java.net.SocketTimeoutException;
+
/**
* The Contentstack RetryCallback
*
- * @author ***REMOVED***
- * @version v0.1.0
* @since 2022-10-20
*/
public abstract class RetryCallback implements Callback {
@@ -19,9 +24,9 @@ public abstract class RetryCallback implements Callback {
// variables for the
// `RetryCallback` class:
private final Logger log = Logger.getLogger(RetryCallback.class.getName());
- private static final int TOTAL_RETRIES = 3;
private final Call call;
private int retryCount = 0;
+ private final RetryConfig retryConfig;
// The `protected RetryCallback(Call call)` constructor is used to
// instantiate a new `RetryCallback`
@@ -30,28 +35,48 @@ public abstract class RetryCallback implements Callback {
// The constructor assigns this `Call` object to the `call` instance
// variable.
protected RetryCallback(Call call) {
+ this(call, null);
+ }
+
+ protected RetryCallback(Call call, RetryConfig retryConfig) {
this.call = call;
+ this.retryConfig = retryConfig != null ? retryConfig : RetryConfig.defaultConfig();
}
/**
- * The function logs the localized message of the thrown exception and retries
- * the API call if the
- * retry count is less than the total number of retries allowed.
+ * The function logs the localized message of the thrown exception and
+ * retries the API call if the retry count is less than the total number of
+ * retries allowed.
*
- * @param call The `Call` object represents the network call that was made. It
- * contains information
- * about the request and response.
- * @param t The parameter `t` is the `Throwable` object that represents the
- * exception or error that
- * occurred during the execution of the network call. It contains
- * information about the error, such as
- * the error message and stack trace.
+ * @param call The `Call` object represents the network call that was made.
+ * It contains information about the request and response.
+ * @param t The parameter `t` is the `Throwable` object that represents the
+ * exception or error that occurred during the execution of the network
+ * call. It contains information about the error, such as the error message
+ * and stack trace.
*/
@Override
public void onFailure(@NotNull Call call, Throwable t) {
- log.info(t.getLocalizedMessage());
- if (retryCount++ < TOTAL_RETRIES) {
- retry();
+ int statusCode = extractStatusCode(t);
+
+ if (!retryConfig.getRetryCondition().shouldRetry(statusCode, t)) {
+ onFinalFailure(call, t);
+ } else {
+ if (retryCount >= retryConfig.getRetryLimit()) {
+ onFinalFailure(call,t);
+ } else {
+ retryCount++;
+ long delay = RetryUtil.calculateDelay(retryConfig, retryCount, statusCode);
+ try {
+ Thread.sleep(delay);
+ } catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ log.log(java.util.logging.Level.WARNING, "Retry interrupted", ex);
+ onFinalFailure(call, t);
+ return;
+ }
+ retry();
+ }
}
}
@@ -61,4 +86,22 @@ public void onFailure(@NotNull Call call, Throwable t) {
private void retry() {
call.clone().enqueue(this);
}
+
+ private int extractStatusCode(Throwable t) {
+ if (t instanceof HttpException) {
+ Response> response = ((HttpException) t).response();
+ if (response != null) {
+ return response.code();
+ } else {
+ return -1;
+ }
+ } else if (t instanceof IOException || t instanceof SocketTimeoutException) {
+ return 0;
+ }
+ return -1;
+ }
+
+ protected void onFinalFailure(Call call, Throwable t) {
+ log.warning("Final failure after " + retryCount + " retries: " + (t != null ? t.getMessage() : ""));
+ }
}
diff --git a/src/main/java/com/contentstack/cms/core/RetryCondition.java b/src/main/java/com/contentstack/cms/core/RetryCondition.java
new file mode 100644
index 0000000..3f3aa2c
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/core/RetryCondition.java
@@ -0,0 +1,35 @@
+package com.contentstack.cms.core;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Interface for determining if an error should be retried.
+ *
+ * This interface allows custom logic to determine whether a failed request
+ * should be retried based on the HTTP status code and/or the exception that occurred.
+ *
+ * Status code conventions:
+ *
+ * - 0 = Network error (IOException, SocketTimeoutException) - typically retryable
+ * - -1 = Unknown error - typically not retryable
+ * - Other values = HTTP status codes (200-599)
+ *
+ *
+ */
+@FunctionalInterface
+public interface RetryCondition {
+
+ /**
+ * Determines if an error should be retried.
+ *
+ * @param statusCode HTTP status code from the response, or:
+ *
+ * - 0 for network errors (IOException, SocketTimeoutException)
+ * - -1 for unknown errors
+ *
+ * @param error The throwable that caused the failure. May be null if statusCode
+ * is available from the response.
+ * @return true if the error should be retried, false otherwise
+ */
+ boolean shouldRetry(int statusCode, @Nullable Throwable error);
+}
diff --git a/src/main/java/com/contentstack/cms/core/RetryConfig.java b/src/main/java/com/contentstack/cms/core/RetryConfig.java
new file mode 100644
index 0000000..68b8d40
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/core/RetryConfig.java
@@ -0,0 +1,210 @@
+package com.contentstack.cms.core;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Configuration for retry behavior across the SDK.
+ *
+ * This class centralizes retry configuration similar to the JavaScript Delivery SDK's
+ * {@code fetchOptions.retry} parameters. It controls:
+ *
+ * - How many times to retry (retryLimit)
+ * - How long to wait between retries (retryDelay, retryDelayOptions)
+ * - Which errors should be retried (retryCondition)
+ *
+ *
+ * This configuration is used by:
+ *
+ * - AuthInterceptor (non-OAuth synchronous calls)
+ * - OAuthInterceptor (OAuth synchronous calls)
+ * - RetryCallback (asynchronous calls)
+ *
+ *
+ */
+public class RetryConfig {
+
+ /**
+ * Default retry limit (matches current SDK behavior for backward compatibility).
+ * Can be changed to 5 to match JS SDK default.
+ */
+ private static final int DEFAULT_RETRY_LIMIT = 3;
+
+ /**
+ * Default retry delay in milliseconds (fallback when no delay options specified).
+ */
+ private static final long DEFAULT_RETRY_DELAY = 300;
+
+ /**
+ * Maximum number of retry attempts before giving up.
+ */
+ private final int retryLimit;
+
+ /**
+ * Fallback delay in milliseconds (used when retryDelayOptions are not configured).
+ */
+ private final long retryDelay;
+
+ /**
+ * Condition function to determine if an error should be retried.
+ */
+ @NotNull
+ private final RetryCondition retryCondition;
+
+ /**
+ * Options for calculating retry delays (base, custom backoff).
+ */
+ @Nullable
+ private final RetryDelayOptions retryDelayOptions;
+
+ /**
+ * Private constructor. Use Builder to create instances.
+ */
+ private RetryConfig(Builder builder) {
+ this.retryLimit = builder.retryLimit;
+ this.retryDelay = builder.retryDelay;
+ this.retryCondition = builder.retryCondition != null
+ ? builder.retryCondition
+ : DefaultRetryCondition.getInstance();
+ this.retryDelayOptions = builder.retryDelayOptions;
+ }
+
+ /**
+ * Gets the maximum number of retry attempts.
+ *
+ * @return the retry limit
+ */
+ public int getRetryLimit() {
+ return retryLimit;
+ }
+
+ /**
+ * Gets the fallback delay in milliseconds.
+ *
+ * @return the retry delay
+ */
+ public long getRetryDelay() {
+ return retryDelay;
+ }
+
+ /**
+ * Gets the retry condition function.
+ *
+ * @return the retry condition (never null)
+ */
+ @NotNull
+ public RetryCondition getRetryCondition() {
+ return retryCondition;
+ }
+
+ /**
+ * Gets the retry delay options.
+ *
+ * @return the retry delay options, or null if not set
+ */
+ @Nullable
+ public RetryDelayOptions getRetryDelayOptions() {
+ return retryDelayOptions;
+ }
+
+ /**
+ * Creates a default RetryConfig with sensible defaults.
+ *
+ * Default values:
+ *
+ * - retryLimit: 3 (for backward compatibility with current SDK)
+ * - retryDelay: 300ms
+ * - retryCondition: DefaultRetryCondition (retries on 408, 429, 5xx, network errors)
+ * - retryDelayOptions: null (uses fixed retryDelay)
+ *
+ *
+ * @return a default RetryConfig instance
+ */
+ public static RetryConfig defaultConfig() {
+ return new Builder().build();
+ }
+
+ /**
+ * Builder for creating RetryConfig instances.
+ */
+ public static class Builder {
+ private int retryLimit = DEFAULT_RETRY_LIMIT;
+ private long retryDelay = DEFAULT_RETRY_DELAY;
+ private RetryCondition retryCondition;
+ private RetryDelayOptions retryDelayOptions;
+
+ /**
+ * Sets the maximum number of retry attempts.
+ * Default: 3 (for backward compatibility)
+ *
+ * @param retryLimit the retry limit (must be >= 0)
+ * @return this builder instance
+ * @throws IllegalArgumentException if retryLimit is negative
+ */
+ public Builder retryLimit(int retryLimit) {
+ if (retryLimit < 0) {
+ throw new IllegalArgumentException("Retry limit must be >= 0");
+ }
+ this.retryLimit = retryLimit;
+ return this;
+ }
+
+ /**
+ * Sets the fallback delay in milliseconds.
+ * This is used when retryDelayOptions are not configured.
+ * Default: 300ms
+ *
+ * @param retryDelay the delay in milliseconds (must be >= 0)
+ * @return this builder instance
+ * @throws IllegalArgumentException if retryDelay is negative
+ */
+ public Builder retryDelay(long retryDelay) {
+ if (retryDelay < 0) {
+ throw new IllegalArgumentException("Retry delay must be >= 0");
+ }
+ this.retryDelay = retryDelay;
+ return this;
+ }
+
+ /**
+ * Sets a custom retry condition function.
+ * If not set, DefaultRetryCondition will be used.
+ *
+ * @param retryCondition the retry condition function
+ * @return this builder instance
+ */
+ public Builder retryCondition(@NotNull RetryCondition retryCondition) {
+ this.retryCondition = retryCondition;
+ return this;
+ }
+
+ /**
+ * Sets the retry delay options (base, custom backoff).
+ *
+ * @param retryDelayOptions the delay options
+ * @return this builder instance
+ */
+ public Builder retryDelayOptions(@Nullable RetryDelayOptions retryDelayOptions) {
+ this.retryDelayOptions = retryDelayOptions;
+ return this;
+ }
+
+ /**
+ * Builds the RetryConfig instance.
+ *
+ * @return a new RetryConfig instance
+ */
+ public RetryConfig build() {
+ return new RetryConfig(this);
+ }
+ }
+
+ /**
+ * Creates a new builder for RetryConfig.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+}
diff --git a/src/main/java/com/contentstack/cms/core/RetryDelayOptions.java b/src/main/java/com/contentstack/cms/core/RetryDelayOptions.java
new file mode 100644
index 0000000..7676d9f
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/core/RetryDelayOptions.java
@@ -0,0 +1,108 @@
+package com.contentstack.cms.core;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Configuration options for retry delay calculation.
+ *
+ * Supports three delay strategies (in order of precedence):
+ *
+ * - Custom Backoff: If {@code customBackoff} is set, it will be used
+ * - Linear Backoff: If {@code base} is set, delay = base * retryCount
+ * - Fixed Delay: Falls back to {@code retryDelay} from RetryConfig
+ *
+ *
+ */
+public class RetryDelayOptions {
+
+ /**
+ * Base multiplier for linear backoff.
+ * Delay becomes: base * retryCount
+ * Example: base = 1000, retryCount = 1 → 1000ms, retryCount = 2 → 2000ms
+ */
+ private final Long base;
+
+ /**
+ * Custom backoff function for advanced delay calculation.
+ * Takes precedence over base if both are set.
+ */
+ private final CustomBackoff customBackoff;
+
+ /**
+ * Private constructor. Use Builder to create instances.
+ */
+ private RetryDelayOptions(Builder builder) {
+ this.base = builder.base;
+ this.customBackoff = builder.customBackoff;
+ }
+
+ /**
+ * Gets the base multiplier for linear backoff.
+ *
+ * @return the base multiplier, or null if not set
+ */
+ @Nullable
+ public Long getBase() {
+ return base;
+ }
+
+ /**
+ * Gets the custom backoff function.
+ *
+ * @return the custom backoff function, or null if not set
+ */
+ @Nullable
+ public CustomBackoff getCustomBackoff() {
+ return customBackoff;
+ }
+
+ /**
+ * Builder for creating RetryDelayOptions instances.
+ */
+ public static class Builder {
+ private Long base;
+ private CustomBackoff customBackoff;
+
+ /**
+ * Sets the base multiplier for linear backoff.
+ * Delay calculation: base * retryCount
+ *
+ * @param base the base multiplier in milliseconds
+ * @return this builder instance
+ */
+ public Builder base(long base) {
+ this.base = base;
+ return this;
+ }
+
+ /**
+ * Sets a custom backoff function for advanced delay calculation.
+ * This takes precedence over base if both are set.
+ *
+ * @param customBackoff the custom backoff function
+ * @return this builder instance
+ */
+ public Builder customBackoff(CustomBackoff customBackoff) {
+ this.customBackoff = customBackoff;
+ return this;
+ }
+
+ /**
+ * Builds the RetryDelayOptions instance.
+ *
+ * @return a new RetryDelayOptions instance
+ */
+ public RetryDelayOptions build() {
+ return new RetryDelayOptions(this);
+ }
+ }
+
+ /**
+ * Creates a new builder for RetryDelayOptions.
+ *
+ * @return a new builder instance
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+}
diff --git a/src/main/java/com/contentstack/cms/core/RetryUtil.java b/src/main/java/com/contentstack/cms/core/RetryUtil.java
new file mode 100644
index 0000000..229cfcb
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/core/RetryUtil.java
@@ -0,0 +1,76 @@
+package com.contentstack.cms.core;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Utility class for retry-related calculations.
+ *
+ * Provides helper methods for calculating retry delays based on RetryConfig.
+ *
+ *
+ */
+public class RetryUtil {
+
+ /**
+ * Private constructor to prevent instantiation.
+ */
+ private RetryUtil() {
+ throw new AssertionError("RetryUtil should not be instantiated");
+ }
+
+ /**
+ * Calculates the delay in milliseconds before the next retry attempt.
+ * Priority order:
+ *
+ * - If {@code retryDelayOptions.customBackoff} is set, use it
+ * - If {@code retryDelayOptions.base} is set, use linear backoff: base * retryCount
+ * - Otherwise, use fixed {@code retryDelay} from RetryConfig
+ *
+ *
+ * @param config the RetryConfig containing delay settings
+ * @param retryCount the current retry attempt number (1-based: 1st retry, 2nd retry, etc.)
+ * @param statusCode HTTP status code from the response, or:
+ *
+ * - 0 for network errors
+ * - -1 for unknown errors
+ *
+ * @param error the throwable that caused the failure (may be null)
+ * @return the delay in milliseconds before the next retry
+ * @throws IllegalArgumentException if config is null
+ */
+ public static long calculateDelay(@NotNull RetryConfig config, int retryCount, int statusCode,
+ @Nullable Throwable error) {
+ if (config == null) {
+ throw new IllegalArgumentException("RetryConfig cannot be null");
+ }
+
+ RetryDelayOptions delayOptions = config.getRetryDelayOptions();
+
+ // Priority 1: Custom backoff function
+ if (delayOptions != null && delayOptions.getCustomBackoff() != null) {
+ return delayOptions.getCustomBackoff().calculate(retryCount, statusCode, error);
+ }
+
+ // Priority 2: Linear backoff (base * retryCount)
+ if (delayOptions != null && delayOptions.getBase() != null && delayOptions.getBase() > 0) {
+ return delayOptions.getBase() * retryCount;
+ }
+
+ // Priority 3: Fixed delay (fallback)
+ return config.getRetryDelay();
+ }
+
+ /**
+ * Calculates the delay in milliseconds before the next retry attempt.
+ * Convenience method that passes null for the error parameter.
+ *
+ * @param config the RetryConfig containing delay settings
+ * @param retryCount the current retry attempt number (1-based)
+ * @param statusCode HTTP status code from the response
+ * @return the delay in milliseconds before the next retry
+ */
+ public static long calculateDelay(@NotNull RetryConfig config, int retryCount, int statusCode) {
+ return calculateDelay(config, retryCount, statusCode, null);
+ }
+}
diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java
index dcae769..56f110a 100644
--- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java
+++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java
@@ -6,17 +6,18 @@
import java.util.concurrent.TimeoutException;
import com.contentstack.cms.core.Util;
-
+import com.contentstack.cms.core.RetryConfig;
+import com.contentstack.cms.core.RetryUtil;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
public class OAuthInterceptor implements Interceptor {
- private static final int MAX_RETRIES = 3;
private final OAuthHandler oauthHandler;
private String[] earlyAccess;
private final Object refreshLock = new Object();
+ private RetryConfig retryConfig = RetryConfig.defaultConfig();
public OAuthInterceptor(OAuthHandler oauthHandler) {
this.oauthHandler = oauthHandler;
@@ -114,7 +115,7 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th
Response response = chain.proceed(request);
// Handle error responses
- if (!response.isSuccessful() && retryCount < MAX_RETRIES) {
+ if (!response.isSuccessful() && retryCount < retryConfig.getRetryLimit()) {
int code = response.code();
response.close();
@@ -140,9 +141,9 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th
}
// Handle other retryable errors (429, 5xx)
- if ((code == 429 || code >= 500) && code != 501) {
+ if (retryConfig.getRetryCondition().shouldRetry(code, null)) {
try {
- long delay = Math.min(1000 * (1 << retryCount), 30000);
+ long delay = RetryUtil.calculateDelay(retryConfig, retryCount+1, code);
Thread.sleep(delay);
return executeRequest(chain, request, retryCount + 1);
} catch (InterruptedException e) {
@@ -154,4 +155,8 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th
return response;
}
+
+ public void setRetryConfig(RetryConfig retryConfig) {
+ this.retryConfig = retryConfig != null ? retryConfig : RetryConfig.defaultConfig();
+ }
}
diff --git a/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java b/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java
index 029d4c1..310f964 100644
--- a/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java
+++ b/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java
@@ -3,6 +3,7 @@
import okhttp3.*;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.io.IOException;
@@ -16,6 +17,155 @@ public void setup() {
authInterceptor = new AuthInterceptor("test-authtoken");
}
+ // --- Retry tests (RetryConfig) ---
+
+ @Test
+ @Tag("unit")
+ public void testRetryConfig_setRetryConfig() {
+ RetryConfig config = RetryConfig.builder().retryLimit(5).build();
+ authInterceptor.setRetryConfig(config);
+ Assertions.assertNotNull(authInterceptor.retryConfig);
+ Assertions.assertEquals(5, authInterceptor.retryConfig.getRetryLimit());
+ }
+
+ @Test
+ @Tag("unit")
+ public void testRetry_on429_thenSuccess_retriesAndReturnsSuccess() throws IOException {
+ authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build());
+ Request request = new Request.Builder()
+ .url("https://api.contentstack.io/v3/user")
+ .get()
+ .build();
+ RetryTestChain chain = new RetryTestChain(request, 429, 200);
+ try (Response response = authInterceptor.intercept(chain)) {
+ Assertions.assertEquals(200, response.code());
+ Assertions.assertEquals(2, chain.getProceedCount());
+ }
+ }
+
+ @Test
+ @Tag("unit")
+ public void testRetry_on503_thenSuccess_retriesAndReturnsSuccess() throws IOException {
+ authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build());
+ Request request = new Request.Builder()
+ .url("https://api.contentstack.io/v3/user")
+ .get()
+ .build();
+ RetryTestChain chain = new RetryTestChain(request, 503, 200);
+ try (Response response = authInterceptor.intercept(chain)) {
+ Assertions.assertEquals(200, response.code());
+ Assertions.assertEquals(2, chain.getProceedCount());
+ }
+ }
+
+ @Test
+ @Tag("unit")
+ public void testRetry_on404_doesNotRetry() throws IOException {
+ authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build());
+ Request request = new Request.Builder()
+ .url("https://api.contentstack.io/v3/user")
+ .get()
+ .build();
+ RetryTestChain chain = new RetryTestChain(request, 404, 200);
+ try (Response response = authInterceptor.intercept(chain)) {
+ Assertions.assertEquals(404, response.code());
+ Assertions.assertEquals(1, chain.getProceedCount());
+ }
+ }
+
+ @Test
+ @Tag("unit")
+ public void testRetry_on200_doesNotRetry() throws IOException {
+ authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build());
+ Request request = new Request.Builder()
+ .url("https://api.contentstack.io/v3/user")
+ .get()
+ .build();
+ RetryTestChain chain = new RetryTestChain(request, 200, 200);
+ try (Response response = authInterceptor.intercept(chain)) {
+ Assertions.assertEquals(200, response.code());
+ Assertions.assertEquals(1, chain.getProceedCount());
+ }
+ }
+
+ /**
+ * Chain that returns a configurable response code on first proceed, then successCode on subsequent calls.
+ */
+ private static class RetryTestChain implements Interceptor.Chain {
+ private final Request originalRequest;
+ private final int firstResponseCode;
+ private final int successCode;
+ private int proceedCount = 0;
+
+ RetryTestChain(Request request, int firstResponseCode, int successCode) {
+ this.originalRequest = request;
+ this.firstResponseCode = firstResponseCode;
+ this.successCode = successCode;
+ }
+
+ int getProceedCount() {
+ return proceedCount;
+ }
+
+ @Override
+ public Request request() {
+ return originalRequest;
+ }
+
+ @Override
+ public Response proceed(Request request) throws IOException {
+ proceedCount++;
+ int code = proceedCount == 1 ? firstResponseCode : successCode;
+ return new Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(code)
+ .message(code == 200 ? "OK" : "Error")
+ .body(ResponseBody.create("{}", MediaType.parse("application/json")))
+ .build();
+ }
+
+ @Override
+ public Connection connection() {
+ return null;
+ }
+
+ @Override
+ public int connectTimeoutMillis() {
+ return 0;
+ }
+
+ @Override
+ public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) {
+ return this;
+ }
+
+ @Override
+ public int readTimeoutMillis() {
+ return 0;
+ }
+
+ @Override
+ public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) {
+ return this;
+ }
+
+ @Override
+ public int writeTimeoutMillis() {
+ return 0;
+ }
+
+ @Override
+ public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) {
+ return this;
+ }
+
+ @Override
+ public Call call() {
+ return null;
+ }
+ }
+
@Test
public void AuthInterceptor() {
AuthInterceptor expected = new AuthInterceptor("abc");
@@ -35,7 +185,7 @@ public void testSetAuthtoken() {
public void testBadArgumentException() {
BadArgumentException exception = new BadArgumentException("Invalid Argument");
String message = exception.getLocalizedMessage();
- Assertions.assertEquals("Invalid Argument", message.toString());
+ Assertions.assertEquals("Invalid Argument", message);
}
@Test
diff --git a/src/test/java/com/contentstack/cms/core/DefaultRetryConditionTest.java b/src/test/java/com/contentstack/cms/core/DefaultRetryConditionTest.java
new file mode 100644
index 0000000..096ae2d
--- /dev/null
+++ b/src/test/java/com/contentstack/cms/core/DefaultRetryConditionTest.java
@@ -0,0 +1,91 @@
+package com.contentstack.cms.core;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.SocketTimeoutException;
+
+@Tag("unit")
+class DefaultRetryConditionTest {
+
+ private final RetryCondition condition = DefaultRetryCondition.getInstance();
+
+ @Test
+ void getInstance_returnsSingleton() {
+ RetryCondition a = DefaultRetryCondition.getInstance();
+ RetryCondition b = DefaultRetryCondition.getInstance();
+ Assertions.assertSame(a, b);
+ }
+
+ @Test
+ void shouldRetry_status408_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(408, null));
+ }
+
+ @Test
+ void shouldRetry_status429_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(429, null));
+ }
+
+ @Test
+ void shouldRetry_status500_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(500, null));
+ }
+
+ @Test
+ void shouldRetry_status502_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(502, null));
+ }
+
+ @Test
+ void shouldRetry_status503_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(503, null));
+ }
+
+ @Test
+ void shouldRetry_status504_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(504, null));
+ }
+
+ @Test
+ void shouldRetry_status404_returnsFalse() {
+ Assertions.assertFalse(condition.shouldRetry(404, null));
+ }
+
+ @Test
+ void shouldRetry_status400_returnsFalse() {
+ Assertions.assertFalse(condition.shouldRetry(400, null));
+ }
+
+ @Test
+ void shouldRetry_status200_returnsFalse() {
+ Assertions.assertFalse(condition.shouldRetry(200, null));
+ }
+
+ @Test
+ void shouldRetry_status0_networkError_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(0, null));
+ }
+
+ @Test
+ void shouldRetry_statusMinus1_unknownNoError_returnsFalse() {
+ Assertions.assertFalse(condition.shouldRetry(-1, null));
+ }
+
+ @Test
+ void shouldRetry_statusMinus1_withIOException_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(-1, new IOException("network")));
+ }
+
+ @Test
+ void shouldRetry_statusMinus1_withSocketTimeoutException_returnsTrue() {
+ Assertions.assertTrue(condition.shouldRetry(-1, new SocketTimeoutException("timeout")));
+ }
+
+ @Test
+ void shouldRetry_statusMinus1_withOtherException_returnsFalse() {
+ Assertions.assertFalse(condition.shouldRetry(-1, new RuntimeException("other")));
+ }
+}
diff --git a/src/test/java/com/contentstack/cms/core/RetryConfigTest.java b/src/test/java/com/contentstack/cms/core/RetryConfigTest.java
new file mode 100644
index 0000000..1363268
--- /dev/null
+++ b/src/test/java/com/contentstack/cms/core/RetryConfigTest.java
@@ -0,0 +1,91 @@
+package com.contentstack.cms.core;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("unit")
+class RetryConfigTest {
+
+ @Test
+ void defaultConfig_returnsNonNull() {
+ RetryConfig config = RetryConfig.defaultConfig();
+ Assertions.assertNotNull(config);
+ }
+
+ @Test
+ void defaultConfig_hasExpectedDefaults() {
+ RetryConfig config = RetryConfig.defaultConfig();
+ Assertions.assertEquals(3, config.getRetryLimit());
+ Assertions.assertEquals(300L, config.getRetryDelay());
+ Assertions.assertNotNull(config.getRetryCondition());
+ Assertions.assertTrue(config.getRetryCondition() instanceof DefaultRetryCondition);
+ }
+
+ @Test
+ void builder_withCustomLimit() {
+ RetryConfig config = RetryConfig.builder()
+ .retryLimit(5)
+ .build();
+ Assertions.assertEquals(5, config.getRetryLimit());
+ Assertions.assertEquals(300L, config.getRetryDelay());
+ }
+
+ @Test
+ void builder_withCustomDelay() {
+ RetryConfig config = RetryConfig.builder()
+ .retryDelay(500)
+ .build();
+ Assertions.assertEquals(3, config.getRetryLimit());
+ Assertions.assertEquals(500L, config.getRetryDelay());
+ }
+
+ @Test
+ void builder_withCustomCondition() {
+ RetryCondition customCondition = (statusCode, error) -> statusCode == 503;
+ RetryConfig config = RetryConfig.builder()
+ .retryCondition(customCondition)
+ .build();
+ Assertions.assertSame(customCondition, config.getRetryCondition());
+ }
+
+ @Test
+ void builder_withNullCondition_usesDefault() {
+ RetryConfig config = RetryConfig.builder().build();
+ Assertions.assertNotNull(config.getRetryCondition());
+ Assertions.assertTrue(config.getRetryCondition() instanceof DefaultRetryCondition);
+ }
+
+ @Test
+ void builder_withRetryDelayOptions() {
+ RetryDelayOptions options = RetryDelayOptions.builder().base(1000).build();
+ RetryConfig config = RetryConfig.builder()
+ .retryDelayOptions(options)
+ .build();
+ RetryDelayOptions delayOptions = config.getRetryDelayOptions();
+ Assertions.assertNotNull(delayOptions);
+ Long base = delayOptions.getBase();
+ Assertions.assertNotNull(base);
+ Assertions.assertEquals(1000L, base.longValue());
+ }
+
+ @Test
+ void builder_retryLimitNegative_throws() {
+ IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, () ->
+ RetryConfig.builder().retryLimit(-1).build());
+ Assertions.assertNotNull(ex.getMessage());
+ }
+
+ @Test
+ void builder_retryDelayNegative_throws() {
+ IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, () ->
+ RetryConfig.builder().retryDelay(-1).build());
+ Assertions.assertNotNull(ex.getMessage());
+ }
+
+ @Test
+ void builder_retryLimitZero_allowed() {
+ RetryConfig config = RetryConfig.builder().retryLimit(0).build();
+ Assertions.assertEquals(0, config.getRetryLimit());
+ }
+}
diff --git a/src/test/java/com/contentstack/cms/core/RetryTest.java b/src/test/java/com/contentstack/cms/core/RetryTest.java
index 95c68bf..091504b 100644
--- a/src/test/java/com/contentstack/cms/core/RetryTest.java
+++ b/src/test/java/com/contentstack/cms/core/RetryTest.java
@@ -8,18 +8,20 @@
import retrofit2.Call;
import retrofit2.Response;
+import java.util.concurrent.atomic.AtomicBoolean;
+
@Tag("unit")
class RetryTest {
@Test
- void testRetryPolicy() {
+ void testRetryPolicy_backwardCompatibleConstructor() {
Contentstack client = new Contentstack.Builder()
.setAuthtoken("fake@authtoken")
.build();
Call userModelCall = client.user().getUser();
- RetryCallback callback = new RetryCallback(userModelCall) {
+ RetryCallback callback = new RetryCallback(userModelCall) {
@Override
- public void onResponse(Call call, Response response) {
+ public void onResponse(Call call, Response response) {
Assertions.assertNotNull(call);
Assertions.assertNotNull(response);
}
@@ -28,4 +30,90 @@ public void onResponse(Call call, Response response) {
Throwable throwable = new Throwable();
callback.onFailure(userModelCall, throwable);
}
-}
\ No newline at end of file
+
+ @Test
+ void testRetryCallback_withRetryConfig() {
+ Contentstack client = new Contentstack.Builder()
+ .setAuthtoken("fake@authtoken")
+ .build();
+ Call call = client.user().getUser();
+ RetryConfig config = RetryConfig.builder().retryLimit(2).build();
+ RetryCallback callback = new RetryCallback(call, config) {
+ @Override
+ public void onResponse(Call c, Response response) {
+ Assertions.fail("Unexpected success");
+ }
+ };
+ callback.onFailure(call, new Throwable("fail"));
+ }
+
+ @Test
+ void testOnFinalFailure_calledWhenErrorNotRetryable() {
+ Contentstack client = new Contentstack.Builder()
+ .setAuthtoken("fake@authtoken")
+ .build();
+ Call call = client.user().getUser();
+ RetryConfig noRetryConfig = RetryConfig.builder()
+ .retryCondition((statusCode, error) -> false)
+ .retryLimit(3)
+ .build();
+ AtomicBoolean finalFailureCalled = new AtomicBoolean(false);
+ RetryCallback callback = new RetryCallback(call, noRetryConfig) {
+ @Override
+ public void onResponse(Call c, Response response) {
+ Assertions.fail("Unexpected success");
+ }
+
+ @Override
+ protected void onFinalFailure(Call c, Throwable t) {
+ finalFailureCalled.set(true);
+ }
+ };
+ callback.onFailure(call, new RuntimeException("not retryable"));
+ Assertions.assertTrue(finalFailureCalled.get(), "onFinalFailure should be called when error is not retryable");
+ }
+
+ @Test
+ void testOnFinalFailure_calledWhenRetryLimitReached() {
+ Contentstack client = new Contentstack.Builder()
+ .setAuthtoken("fake@authtoken")
+ .build();
+ Call call = client.user().getUser();
+ RetryConfig limitZeroConfig = RetryConfig.builder()
+ .retryLimit(0)
+ .retryCondition((statusCode, error) -> true)
+ .build();
+ AtomicBoolean finalFailureCalled = new AtomicBoolean(false);
+ RetryCallback callback = new RetryCallback(call, limitZeroConfig) {
+ @Override
+ public void onResponse(Call c, Response response) {
+ Assertions.fail("Unexpected success");
+ }
+
+ @Override
+ protected void onFinalFailure(Call c, Throwable t) {
+ finalFailureCalled.set(true);
+ }
+ };
+ callback.onFailure(call, new java.io.IOException("network error"));
+ Assertions.assertTrue(finalFailureCalled.get(), "onFinalFailure should be called when retry limit is 0");
+ }
+
+ @Test
+ void testRetryCallback_withContentstackGetRetryConfig() {
+ Contentstack client = new Contentstack.Builder()
+ .setAuthtoken("fake@authtoken")
+ .setRetryConfig(RetryConfig.builder().retryLimit(5).build())
+ .build();
+ Assertions.assertNotNull(client.getRetryConfig());
+ Assertions.assertEquals(5, client.getRetryConfig().getRetryLimit());
+ Call call = client.user().getUser();
+ RetryCallback callback = new RetryCallback(call, client.getRetryConfig()) {
+ @Override
+ public void onResponse(Call c, Response response) {
+ Assertions.assertNotNull(response);
+ }
+ };
+ callback.onFailure(call, new Throwable("test"));
+ }
+}
diff --git a/src/test/java/com/contentstack/cms/core/RetryUtilTest.java b/src/test/java/com/contentstack/cms/core/RetryUtilTest.java
new file mode 100644
index 0000000..2dd89f9
--- /dev/null
+++ b/src/test/java/com/contentstack/cms/core/RetryUtilTest.java
@@ -0,0 +1,70 @@
+package com.contentstack.cms.core;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("unit")
+class RetryUtilTest {
+
+ @Test
+ void calculateDelay_nullConfig_throws() {
+ IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class, () ->
+ RetryUtil.calculateDelay(null, 1, 429));
+ Assertions.assertNotNull(ex.getMessage());
+ }
+
+ @Test
+ void calculateDelay_defaultConfig_returnsFixedDelay() {
+ RetryConfig config = RetryConfig.defaultConfig();
+ long delay = RetryUtil.calculateDelay(config, 1, 429);
+ Assertions.assertEquals(300L, delay);
+ }
+
+ @Test
+ void calculateDelay_customRetryDelay_returnsThatDelay() {
+ RetryConfig config = RetryConfig.builder().retryDelay(500).build();
+ long delay = RetryUtil.calculateDelay(config, 1, 429);
+ Assertions.assertEquals(500L, delay);
+ }
+
+ @Test
+ void calculateDelay_withBase_returnsLinearBackoff() {
+ RetryConfig config = RetryConfig.builder()
+ .retryDelayOptions(RetryDelayOptions.builder().base(1000).build())
+ .build();
+ Assertions.assertEquals(1000L, RetryUtil.calculateDelay(config, 1, 429));
+ Assertions.assertEquals(2000L, RetryUtil.calculateDelay(config, 2, 429));
+ Assertions.assertEquals(3000L, RetryUtil.calculateDelay(config, 3, 429));
+ }
+
+ @Test
+ void calculateDelay_withCustomBackoff_returnsCustomValue() {
+ RetryConfig config = RetryConfig.builder()
+ .retryDelayOptions(RetryDelayOptions.builder()
+ .customBackoff((retryCount, statusCode, error) -> retryCount * 500L)
+ .build())
+ .build();
+ Assertions.assertEquals(500L, RetryUtil.calculateDelay(config, 1, 429));
+ Assertions.assertEquals(1000L, RetryUtil.calculateDelay(config, 2, 503));
+ }
+
+ @Test
+ void calculateDelay_customBackoffTakesPrecedenceOverBase() {
+ RetryConfig config = RetryConfig.builder()
+ .retryDelayOptions(RetryDelayOptions.builder()
+ .base(100)
+ .customBackoff((retryCount, statusCode, error) -> 999L)
+ .build())
+ .build();
+ long delay = RetryUtil.calculateDelay(config, 2, 429);
+ Assertions.assertEquals(999L, delay);
+ }
+
+ @Test
+ void calculateDelay_threeArgVersion_passesNullError() {
+ RetryConfig config = RetryConfig.defaultConfig();
+ long delay = RetryUtil.calculateDelay(config, 1, 429);
+ Assertions.assertEquals(300L, delay);
+ }
+}