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: + * + * @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: + * + *

+ * 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): + *

    + *
  1. Custom Backoff: If {@code customBackoff} is set, it will be used
  2. + *
  3. Linear Backoff: If {@code base} is set, delay = base * retryCount
  4. + *
  5. Fixed Delay: Falls back to {@code retryDelay} from RetryConfig
  6. + *
+ * + */ +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: + *
    + *
  1. If {@code retryDelayOptions.customBackoff} is set, use it
  2. + *
  3. If {@code retryDelayOptions.base} is set, use linear backoff: base * retryCount
  4. + *
  5. Otherwise, use fixed {@code retryDelay} from RetryConfig
  6. + *
+ * + * @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); + } +}