Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions src/main/java/com/contentstack/cms/Contentstack.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}

Expand All @@ -874,5 +879,12 @@ private HttpLoggingInterceptor logger() {
return new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.NONE);
}


public Builder setRetryConfig(RetryConfig retryConfig) {
this.retryConfig = retryConfig;
return this;
}


}
}
25 changes: 23 additions & 2 deletions src/main/java/com/contentstack/cms/core/AuthInterceptor.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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;
}

}
32 changes: 32 additions & 0 deletions src/main/java/com/contentstack/cms/core/CustomBackoff.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.contentstack.cms.core;

import org.jetbrains.annotations.Nullable;

/**
* Functional interface for custom backoff delay calculation.
* <p>
* Allows custom logic to calculate retry delays based on retry count and error information.
* This enables advanced backoff strategies like exponential backoff with jitter.
* </p>
*
* @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:
* <ul>
* <li>0 for network errors</li>
* <li>-1 for unknown errors</li>
* </ul>
* @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);
}
Original file line number Diff line number Diff line change
@@ -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:
* <ul>
* <li>HTTP status codes: 408 (Request Timeout), 429 (Too Many Requests),
* 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable),
* 504 (Gateway Timeout)</li>
* <li>Network errors: IOException, SocketTimeoutException</li>
* </ul>
* <p>
* This matches the default retry behavior of the JavaScript Delivery SDK.
* </p>
*
* @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;
}
}
77 changes: 60 additions & 17 deletions src/main/java/com/contentstack/cms/core/RetryCallback.java
Original file line number Diff line number Diff line change
@@ -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<T> implements Callback<T> {
Expand All @@ -19,9 +24,9 @@ public abstract class RetryCallback<T> implements Callback<T> {
// variables for the
// `RetryCallback` class:
private final Logger log = Logger.getLogger(RetryCallback.class.getName());
private static final int TOTAL_RETRIES = 3;
private final Call<T> call;
private int retryCount = 0;
private final RetryConfig retryConfig;

// The `protected RetryCallback(Call<T> call)` constructor is used to
// instantiate a new `RetryCallback`
Expand All @@ -30,28 +35,48 @@ public abstract class RetryCallback<T> implements Callback<T> {
// The constructor assigns this `Call<T>` object to the `call` instance
// variable.
protected RetryCallback(Call<T> call) {
this(call, null);
}

protected RetryCallback(Call<T> 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<T> 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();
}
}
}

Expand All @@ -61,4 +86,22 @@ public void onFailure(@NotNull Call<T> 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<T> call, Throwable t) {
log.warning("Final failure after " + retryCount + " retries: " + (t != null ? t.getMessage() : ""));
}
}
35 changes: 35 additions & 0 deletions src/main/java/com/contentstack/cms/core/RetryCondition.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.contentstack.cms.core;

import org.jetbrains.annotations.Nullable;

/**
* Interface for determining if an error should be retried.
* <p>
* 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.
* <p>
* Status code conventions:
* <ul>
* <li>0 = Network error (IOException, SocketTimeoutException) - typically retryable</li>
* <li>-1 = Unknown error - typically not retryable</li>
* <li>Other values = HTTP status codes (200-599)</li>
* </ul>
*
*/
@FunctionalInterface
public interface RetryCondition {

/**
* Determines if an error should be retried.
*
* @param statusCode HTTP status code from the response, or:
* <ul>
* <li>0 for network errors (IOException, SocketTimeoutException)</li>
* <li>-1 for unknown errors</li>
* </ul>
* @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);
}
Loading
Loading