Skip to content

Commit faa5b5d

Browse files
committed
Implement retry mechanism across SDK with configurable options
1 parent 7637bb9 commit faa5b5d

15 files changed

+1172
-35
lines changed

src/main/java/com/contentstack/cms/Contentstack.java

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,14 @@
2020
import com.contentstack.cms.models.LoginDetails;
2121
import com.contentstack.cms.models.OAuthConfig;
2222
import com.contentstack.cms.models.OAuthTokens;
23-
import com.contentstack.cms.oauth.TokenCallback;
2423
import com.contentstack.cms.oauth.OAuthHandler;
2524
import com.contentstack.cms.oauth.OAuthInterceptor;
25+
import com.contentstack.cms.oauth.TokenCallback;
2626
import com.contentstack.cms.organization.Organization;
2727
import com.contentstack.cms.stack.Stack;
2828
import com.contentstack.cms.user.User;
2929
import com.google.gson.Gson;
30-
import com.warrenstrange.googleauth.GoogleAuthenticator;
31-
30+
import com.contentstack.cms.core.RetryConfig;
3231
import okhttp3.ConnectionPool;
3332
import okhttp3.OkHttpClient;
3433
import okhttp3.ResponseBody;
@@ -63,6 +62,7 @@ public class Contentstack {
6362
protected OAuthHandler oauthHandler;
6463
protected String[] earlyAccess;
6564
protected User user;
65+
protected RetryConfig retryConfig;
6666

6767
/**
6868
* All accounts registered with Contentstack are known as Users. A stack can
@@ -571,6 +571,11 @@ public Contentstack(Builder builder) {
571571
this.oauthInterceptor = builder.oauthInterceptor;
572572
this.oauthHandler = builder.oauthHandler;
573573
this.earlyAccess = builder.earlyAccess;
574+
this.retryConfig = builder.retryConfig;
575+
}
576+
577+
public RetryConfig getRetryConfig() {
578+
return retryConfig;
574579
}
575580

576581
/**
@@ -595,7 +600,7 @@ public static class Builder {
595600
private String version = Util.VERSION; // Default Version for Contentstack API
596601
private int timeout = Util.TIMEOUT; // Default timeout 30 seconds
597602
private Boolean retry = Util.RETRY_ON_FAILURE;// Default base url for contentstack
598-
603+
private RetryConfig retryConfig = RetryConfig.defaultConfig();
599604
/**
600605
* Default ConnectionPool holds up to 5 idle connections which will be
601606
* evicted after 5 minutes of inactivity.
@@ -853,7 +858,7 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur
853858
if (this.earlyAccess != null) {
854859
this.oauthInterceptor.setEarlyAccess(this.earlyAccess);
855860
}
856-
861+
this.oauthInterceptor.setRetryConfig(this.retryConfig);
857862
// Add interceptor to handle OAuth, token refresh, and retries
858863
builder.addInterceptor(this.oauthInterceptor);
859864
} else {
@@ -863,7 +868,7 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur
863868
if (this.earlyAccess != null) {
864869
this.authInterceptor.setEarlyAccess(this.earlyAccess);
865870
}
866-
871+
this.authInterceptor.setRetryConfig(this.retryConfig);
867872
builder.addInterceptor(this.authInterceptor);
868873
}
869874

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

882+
883+
public Builder setRetryConfig(RetryConfig retryConfig) {
884+
this.retryConfig = retryConfig;
885+
return this;
886+
}
887+
888+
877889
}
878890
}

src/main/java/com/contentstack/cms/core/AuthInterceptor.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class AuthInterceptor implements Interceptor {
2727

2828
protected String authtoken;
2929
protected String[] earlyAccess;
30-
30+
protected RetryConfig retryConfig = RetryConfig.defaultConfig();
3131
// The `public AuthInterceptor() {}` is a default constructor for the
3232
// `AuthInterceptor` class. It is
3333
// used to create an instance of the `AuthInterceptor` class without passing any
@@ -93,7 +93,7 @@ public Response intercept(Chain chain) throws IOException {
9393
String commaSeparated = String.join(", ", earlyAccess);
9494
request.addHeader(Util.EARLY_ACCESS_HEADER, commaSeparated);
9595
}
96-
return chain.proceed(request.build());
96+
return executeRequest(chain, request.build(), 0);
9797
}
9898

9999
/**
@@ -112,4 +112,25 @@ private boolean isDeleteReleaseRequest(Request request) {
112112
return path.matches(".*/releases/[^/]+$");
113113
}
114114

115+
public void setRetryConfig(RetryConfig retryConfig) {
116+
this.retryConfig = retryConfig != null ? retryConfig : RetryConfig.defaultConfig();
117+
}
118+
119+
private Response executeRequest(Chain chain, Request request, int retryCount) throws IOException{
120+
Response response = chain.proceed(request);
121+
int code = response.code();
122+
if(retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(code, null)){
123+
response.close();
124+
long delay = RetryUtil.calculateDelay(retryConfig, retryCount+1, code);
125+
try {
126+
Thread.sleep(delay);
127+
} catch (InterruptedException ex) {
128+
Thread.currentThread().interrupt();
129+
throw new IOException("Retry interrupted", ex);
130+
}
131+
return executeRequest(chain, request, retryCount + 1);
132+
}
133+
return response;
134+
}
135+
115136
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.contentstack.cms.core;
2+
3+
import org.jetbrains.annotations.Nullable;
4+
5+
/**
6+
* Functional interface for custom backoff delay calculation.
7+
* <p>
8+
* Allows custom logic to calculate retry delays based on retry count and error information.
9+
* This enables advanced backoff strategies like exponential backoff with jitter.
10+
* </p>
11+
*
12+
* @author Contentstack
13+
* @version v1.0.0
14+
* @since 2026-01-28
15+
*/
16+
@FunctionalInterface
17+
public interface CustomBackoff {
18+
19+
/**
20+
* Calculates the delay in milliseconds before the next retry attempt.
21+
*
22+
* @param retryCount The current retry attempt number (1-based: 1st retry, 2nd retry, etc.)
23+
* @param statusCode HTTP status code from the response, or:
24+
* <ul>
25+
* <li>0 for network errors</li>
26+
* <li>-1 for unknown errors</li>
27+
* </ul>
28+
* @param error The throwable that caused the failure (may be null)
29+
* @return The delay in milliseconds before the next retry
30+
*/
31+
long calculate(int retryCount, int statusCode, @Nullable Throwable error);
32+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.contentstack.cms.core;
2+
3+
import org.jetbrains.annotations.Nullable;
4+
5+
import java.io.IOException;
6+
import java.net.SocketTimeoutException;
7+
8+
/**
9+
* Default implementation of RetryCondition that retries on:
10+
* <ul>
11+
* <li>HTTP status codes: 408 (Request Timeout), 429 (Too Many Requests),
12+
* 500 (Internal Server Error), 502 (Bad Gateway), 503 (Service Unavailable),
13+
* 504 (Gateway Timeout)</li>
14+
* <li>Network errors: IOException, SocketTimeoutException</li>
15+
* </ul>
16+
* <p>
17+
* This matches the default retry behavior of the JavaScript Delivery SDK.
18+
* </p>
19+
*
20+
* @author Contentstack
21+
* @version v1.0.0
22+
* @since 2026-01-28
23+
*/
24+
public class DefaultRetryCondition implements RetryCondition {
25+
26+
/**
27+
* Default retryable HTTP status codes.
28+
* Matches JS SDK default: [408, 429, 500, 502, 503, 504]
29+
*/
30+
private static final int[] RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504};
31+
32+
/**
33+
* Singleton instance for reuse.
34+
*/
35+
private static final DefaultRetryCondition INSTANCE = new DefaultRetryCondition();
36+
37+
/**
38+
* Private constructor to enforce singleton pattern.
39+
*/
40+
private DefaultRetryCondition() {
41+
}
42+
43+
/**
44+
* Gets the singleton instance of DefaultRetryCondition.
45+
*
46+
* @return the singleton instance
47+
*/
48+
public static DefaultRetryCondition getInstance() {
49+
return INSTANCE;
50+
}
51+
52+
/**
53+
* Determines if an error should be retried based on status code and exception type.
54+
*
55+
* @param statusCode HTTP status code (0 = network error, -1 = unknown)
56+
* @param error The throwable that caused the failure (may be null)
57+
* @return true if the error should be retried, false otherwise
58+
*/
59+
@Override
60+
public boolean shouldRetry(int statusCode, @Nullable Throwable error) {
61+
// Network errors (statusCode = 0) are always retryable
62+
if (statusCode == 0) {
63+
return true;
64+
}
65+
66+
// Unknown errors (statusCode = -1) are not retryable by default
67+
if (statusCode == -1) {
68+
// However, if it's a network-related exception, we should retry
69+
if (error != null && (error instanceof IOException || error instanceof SocketTimeoutException)) {
70+
return true;
71+
}
72+
return false;
73+
}
74+
75+
// Check if status code is in the retryable list
76+
for (int code : RETRYABLE_STATUS_CODES) {
77+
if (statusCode == code) {
78+
return true;
79+
}
80+
}
81+
82+
return false;
83+
}
84+
}
Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
package com.contentstack.cms.core;
22

33
import org.jetbrains.annotations.NotNull;
4+
45
import retrofit2.Call;
56
import retrofit2.Callback;
7+
import retrofit2.HttpException;
68

79
import java.util.logging.Logger;
810

11+
import retrofit2.Response;
12+
13+
import java.io.IOException;
14+
import java.net.SocketTimeoutException;
15+
916
/**
1017
* The Contentstack RetryCallback
1118
*
12-
* @author ***REMOVED***
13-
* @version v0.1.0
19+
* @author ***REMOVED ** @version v0.1.0
1420
* @since 2022-10-20
1521
*/
1622
public abstract class RetryCallback<T> implements Callback<T> {
@@ -19,9 +25,9 @@ public abstract class RetryCallback<T> implements Callback<T> {
1925
// variables for the
2026
// `RetryCallback` class:
2127
private final Logger log = Logger.getLogger(RetryCallback.class.getName());
22-
private static final int TOTAL_RETRIES = 3;
2328
private final Call<T> call;
2429
private int retryCount = 0;
30+
private final RetryConfig retryConfig;
2531

2632
// The `protected RetryCallback(Call<T> call)` constructor is used to
2733
// instantiate a new `RetryCallback`
@@ -30,28 +36,48 @@ public abstract class RetryCallback<T> implements Callback<T> {
3036
// The constructor assigns this `Call<T>` object to the `call` instance
3137
// variable.
3238
protected RetryCallback(Call<T> call) {
39+
this(call, null);
40+
}
41+
42+
protected RetryCallback(Call<T> call, RetryConfig retryConfig) {
3343
this.call = call;
44+
this.retryConfig = retryConfig != null ? retryConfig : RetryConfig.defaultConfig();
3445
}
3546

3647
/**
37-
* The function logs the localized message of the thrown exception and retries
38-
* the API call if the
39-
* retry count is less than the total number of retries allowed.
48+
* The function logs the localized message of the thrown exception and
49+
* retries the API call if the retry count is less than the total number of
50+
* retries allowed.
4051
*
41-
* @param call The `Call` object represents the network call that was made. It
42-
* contains information
43-
* about the request and response.
44-
* @param t The parameter `t` is the `Throwable` object that represents the
45-
* exception or error that
46-
* occurred during the execution of the network call. It contains
47-
* information about the error, such as
48-
* the error message and stack trace.
52+
* @param call The `Call` object represents the network call that was made.
53+
* It contains information about the request and response.
54+
* @param t The parameter `t` is the `Throwable` object that represents the
55+
* exception or error that occurred during the execution of the network
56+
* call. It contains information about the error, such as the error message
57+
* and stack trace.
4958
*/
5059
@Override
5160
public void onFailure(@NotNull Call<T> call, Throwable t) {
52-
log.info(t.getLocalizedMessage());
53-
if (retryCount++ < TOTAL_RETRIES) {
54-
retry();
61+
int statusCode = extractStatusCode(t);
62+
63+
if (!retryConfig.getRetryCondition().shouldRetry(statusCode, t)) {
64+
onFinalFailure(call, t);
65+
} else {
66+
if (retryCount >= retryConfig.getRetryLimit()) {
67+
onFinalFailure(call,t);
68+
} else {
69+
retryCount++;
70+
long delay = RetryUtil.calculateDelay(retryConfig, retryCount, statusCode);
71+
try {
72+
Thread.sleep(delay);
73+
} catch (InterruptedException ex) {
74+
Thread.currentThread().interrupt();
75+
log.log(java.util.logging.Level.WARNING, "Retry interrupted", ex);
76+
onFinalFailure(call, t);
77+
return;
78+
}
79+
retry();
80+
}
5581
}
5682
}
5783

@@ -61,4 +87,22 @@ public void onFailure(@NotNull Call<T> call, Throwable t) {
6187
private void retry() {
6288
call.clone().enqueue(this);
6389
}
90+
91+
private int extractStatusCode(Throwable t) {
92+
if (t instanceof HttpException) {
93+
Response<?> response = ((HttpException) t).response();
94+
if (response != null) {
95+
return response.code();
96+
} else {
97+
return -1;
98+
}
99+
} else if (t instanceof IOException || t instanceof SocketTimeoutException) {
100+
return 0;
101+
}
102+
return -1;
103+
}
104+
105+
protected void onFinalFailure(Call<T> call, Throwable t) {
106+
log.warning("Final failure after " + retryCount + " retries: " + (t != null ? t.getMessage() : ""));
107+
}
64108
}

0 commit comments

Comments
 (0)