Skip to content

Commit ec571de

Browse files
Merge pull request #225 from contentstack/development
Staging PR
2 parents e3dabc1 + 873ed00 commit ec571de

17 files changed

+1158
-36
lines changed

changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v1.11.0
4+
5+
### Feb 09, 2026
6+
7+
- Enhancement: Retry Mechanism
8+
39
## v1.10.2
410

511
### Jan 27, 2026

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<artifactId>cms</artifactId>
88
<packaging>jar</packaging>
99
<name>contentstack-management-java</name>
10-
<version>1.10.2</version>
10+
<version>1.11.0</version>
1111
<description>Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an
1212
API-first approach
1313
</description>

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: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
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
1419
* @since 2022-10-20
1520
*/
1621
public abstract class RetryCallback<T> implements Callback<T> {
@@ -19,9 +24,9 @@ public abstract class RetryCallback<T> implements Callback<T> {
1924
// variables for the
2025
// `RetryCallback` class:
2126
private final Logger log = Logger.getLogger(RetryCallback.class.getName());
22-
private static final int TOTAL_RETRIES = 3;
2327
private final Call<T> call;
2428
private int retryCount = 0;
29+
private final RetryConfig retryConfig;
2530

2631
// The `protected RetryCallback(Call<T> call)` constructor is used to
2732
// instantiate a new `RetryCallback`
@@ -30,28 +35,48 @@ public abstract class RetryCallback<T> implements Callback<T> {
3035
// The constructor assigns this `Call<T>` object to the `call` instance
3136
// variable.
3237
protected RetryCallback(Call<T> call) {
38+
this(call, null);
39+
}
40+
41+
protected RetryCallback(Call<T> call, RetryConfig retryConfig) {
3342
this.call = call;
43+
this.retryConfig = retryConfig != null ? retryConfig : RetryConfig.defaultConfig();
3444
}
3545

3646
/**
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.
47+
* The function logs the localized message of the thrown exception and
48+
* retries the API call if the retry count is less than the total number of
49+
* retries allowed.
4050
*
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.
51+
* @param call The `Call` object represents the network call that was made.
52+
* It contains information about the request and response.
53+
* @param t The parameter `t` is the `Throwable` object that represents the
54+
* exception or error that occurred during the execution of the network
55+
* call. It contains information about the error, such as the error message
56+
* and stack trace.
4957
*/
5058
@Override
5159
public void onFailure(@NotNull Call<T> call, Throwable t) {
52-
log.info(t.getLocalizedMessage());
53-
if (retryCount++ < TOTAL_RETRIES) {
54-
retry();
60+
int statusCode = extractStatusCode(t);
61+
62+
if (!retryConfig.getRetryCondition().shouldRetry(statusCode, t)) {
63+
onFinalFailure(call, t);
64+
} else {
65+
if (retryCount >= retryConfig.getRetryLimit()) {
66+
onFinalFailure(call,t);
67+
} else {
68+
retryCount++;
69+
long delay = RetryUtil.calculateDelay(retryConfig, retryCount, statusCode);
70+
try {
71+
Thread.sleep(delay);
72+
} catch (InterruptedException ex) {
73+
Thread.currentThread().interrupt();
74+
log.log(java.util.logging.Level.WARNING, "Retry interrupted", ex);
75+
onFinalFailure(call, t);
76+
return;
77+
}
78+
retry();
79+
}
5580
}
5681
}
5782

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

0 commit comments

Comments
 (0)