Skip to content

Commit 4adad03

Browse files
Merge pull request #243 from contentstack/fix/DX-8600-network-level-timeouts
Enhance retry mechanism for network timeouts in interceptors
2 parents 226b2ef + 606752a commit 4adad03

8 files changed

Lines changed: 288 additions & 15 deletions

File tree

changelog.md

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

3+
## v1.11.2
4+
5+
### Jun 01, 2026
6+
7+
- Fix: `SocketTimeoutException` now correctly triggers the retry mechanism in `AuthInterceptor` and `OAuthInterceptor`. Previously, network-level timeouts bypassed retry logic entirely, causing `.setRetry(true)` to have no effect on timeout errors.
8+
- Enhancement: Added `setProtocols(List<Protocol>)` to the Builder, allowing callers to restrict the HTTP protocol (e.g. force HTTP/1.1 via `Collections.singletonList(Protocol.HTTP_1_1)`) for environments where proxies or intermediaries have issues with HTTP/2.
9+
310
## v1.11.1
411

512
### Apr 06, 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.11.1</version>
10+
<version>1.11.2</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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import java.io.IOException;
44
import java.net.Proxy;
55
import java.time.Duration;
6+
import java.util.Collections;
67
import java.util.HashMap;
8+
import java.util.List;
79
import java.util.Map;
810
import java.util.concurrent.CompletableFuture;
911
import java.util.concurrent.TimeUnit;
@@ -30,6 +32,7 @@
3032
import com.contentstack.cms.core.RetryConfig;
3133
import okhttp3.ConnectionPool;
3234
import okhttp3.OkHttpClient;
35+
import okhttp3.Protocol;
3336
import okhttp3.ResponseBody;
3437
import okhttp3.logging.HttpLoggingInterceptor;
3538
import retrofit2.Response;
@@ -609,6 +612,7 @@ public static class Builder {
609612
* evicted after 5 minutes of inactivity.
610613
*/
611614
private ConnectionPool connectionPool = new ConnectionPool(); // Connection
615+
private List<Protocol> protocols = null;
612616

613617
/**
614618
* Instantiates a new Builder.
@@ -713,6 +717,11 @@ public Builder setConnectTimeout(int connectTimeoutSeconds) {
713717
return this;
714718
}
715719

720+
public Builder setProtocols(@NotNull List<Protocol> protocols) {
721+
this.protocols = protocols;
722+
return this;
723+
}
724+
716725
private static void validateTimeoutSeconds(int seconds, String name) {
717726
if (seconds <= 0) {
718727
throw new IllegalArgumentException(name + " must be positive.");
@@ -881,6 +890,9 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur
881890
.readTimeout(Duration.ofSeconds(readSec))
882891
.writeTimeout(Duration.ofSeconds(writeSec))
883892
.retryOnConnectionFailure(retryOnFailure);
893+
if (this.protocols != null && !this.protocols.isEmpty()) {
894+
builder.protocols(this.protocols);
895+
}
884896

885897
// Add either OAuth or traditional auth interceptor
886898
if (this.oauthConfig != null) {

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

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.contentstack.cms.core;
22

33
import java.io.IOException;
4+
import java.net.SocketTimeoutException;
45

56
import org.jetbrains.annotations.NotNull;
67

@@ -116,21 +117,35 @@ public void setRetryConfig(RetryConfig retryConfig) {
116117
this.retryConfig = retryConfig != null ? retryConfig : RetryConfig.defaultConfig();
117118
}
118119

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);
120+
private Response executeRequest(Chain chain, Request request, int retryCount) throws IOException {
121+
try {
122+
Response response = chain.proceed(request);
123+
int code = response.code();
124+
if (retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(code, null)) {
125+
response.close();
126+
long delay = RetryUtil.calculateDelay(retryConfig, retryCount + 1, code);
127+
try {
128+
Thread.sleep(delay);
129+
} catch (InterruptedException ex) {
130+
Thread.currentThread().interrupt();
131+
throw new IOException("Retry interrupted", ex);
132+
}
133+
return executeRequest(chain, request, retryCount + 1);
130134
}
131-
return executeRequest(chain, request, retryCount + 1);
135+
return response;
136+
} catch (SocketTimeoutException e) {
137+
if (retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(0, e)) {
138+
long delay = RetryUtil.calculateDelay(retryConfig, retryCount + 1, 0);
139+
try {
140+
Thread.sleep(delay);
141+
} catch (InterruptedException ex) {
142+
Thread.currentThread().interrupt();
143+
throw new IOException("Retry interrupted", ex);
144+
}
145+
return executeRequest(chain, request, retryCount + 1);
146+
}
147+
throw e;
132148
}
133-
return response;
134149
}
135150

136151
}

src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.contentstack.cms.oauth;
22

33
import java.io.IOException;
4+
import java.net.SocketTimeoutException;
45
import java.util.concurrent.ExecutionException;
56
import java.util.concurrent.TimeUnit;
67
import java.util.concurrent.TimeoutException;
@@ -112,7 +113,22 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th
112113
}
113114

114115
// Execute request
115-
Response response = chain.proceed(request);
116+
Response response;
117+
try {
118+
response = chain.proceed(request);
119+
} catch (SocketTimeoutException e) {
120+
if (retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(0, e)) {
121+
long delay = RetryUtil.calculateDelay(retryConfig, retryCount + 1, 0);
122+
try {
123+
Thread.sleep(delay);
124+
} catch (InterruptedException ex) {
125+
Thread.currentThread().interrupt();
126+
throw new IOException("Retry interrupted", ex);
127+
}
128+
return executeRequest(chain, request, retryCount + 1);
129+
}
130+
throw e;
131+
}
116132

117133
// Handle error responses
118134
if (!response.isSuccessful() && retryCount < retryConfig.getRetryLimit()) {

src/test/java/com/contentstack/cms/ContentstackUnitTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.contentstack.cms.stack.Stack;
66
import okhttp3.Headers;
77
import okhttp3.OkHttpClient;
8+
import okhttp3.Protocol;
89
import okhttp3.Request;
910
import okhttp3.ResponseBody;
1011
import okhttp3.mockwebserver.MockResponse;
@@ -17,7 +18,9 @@
1718
import java.io.IOException;
1819
import java.net.InetSocketAddress;
1920
import java.net.Proxy;
21+
import java.util.Collections;
2022
import java.util.HashMap;
23+
import java.util.List;
2124
import java.util.Map;
2225
import java.util.concurrent.TimeUnit;
2326

@@ -160,6 +163,24 @@ void setVersion() {
160163
Assertions.assertEquals("v8", contentstack.version);
161164
}
162165

166+
@Test
167+
void setProtocols_http1Only_buildsSuccessfully() {
168+
List<Protocol> protocols = Collections.singletonList(Protocol.HTTP_1_1);
169+
Contentstack contentstack = new Contentstack.Builder()
170+
.setProtocols(protocols)
171+
.build();
172+
Assertions.assertNotNull(contentstack);
173+
}
174+
175+
@Test
176+
void setProtocols_http2AndHttp1_buildsSuccessfully() {
177+
List<Protocol> protocols = List.of(Protocol.HTTP_2, Protocol.HTTP_1_1);
178+
Contentstack contentstack = new Contentstack.Builder()
179+
.setProtocols(protocols)
180+
.build();
181+
Assertions.assertNotNull(contentstack);
182+
}
183+
163184
@Test
164185
void setTimeout() {
165186
Contentstack contentstack = new Contentstack.Builder()

src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.junit.jupiter.api.Test;
88

99
import java.io.IOException;
10+
import java.net.SocketTimeoutException;
1011

1112
public class AuthInterceptorTest {
1213

@@ -166,6 +167,89 @@ public Call call() {
166167
}
167168
}
168169

170+
@Test
171+
@Tag("unit")
172+
public void testRetry_onSocketTimeout_thenSuccess_retriesAndReturnsSuccess() throws IOException {
173+
authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build());
174+
Request request = new Request.Builder()
175+
.url("https://api.contentstack.io/v3/user")
176+
.get()
177+
.build();
178+
TimeoutTestChain chain = new TimeoutTestChain(request, 1, 200);
179+
try (Response response = authInterceptor.intercept(chain)) {
180+
Assertions.assertEquals(200, response.code());
181+
Assertions.assertEquals(2, chain.getProceedCount());
182+
}
183+
}
184+
185+
@Test
186+
@Tag("unit")
187+
public void testRetry_onSocketTimeout_exhaustsRetries_throws() {
188+
authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(2).retryDelay(10).build());
189+
Request request = new Request.Builder()
190+
.url("https://api.contentstack.io/v3/user")
191+
.get()
192+
.build();
193+
TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200);
194+
Assertions.assertThrows(SocketTimeoutException.class, () -> authInterceptor.intercept(chain));
195+
Assertions.assertEquals(3, chain.getProceedCount());
196+
}
197+
198+
@Test
199+
@Tag("unit")
200+
public void testRetry_onSocketTimeout_zeroRetryLimit_throwsImmediately() {
201+
authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(0).retryDelay(10).build());
202+
Request request = new Request.Builder()
203+
.url("https://api.contentstack.io/v3/user")
204+
.get()
205+
.build();
206+
TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200);
207+
Assertions.assertThrows(SocketTimeoutException.class, () -> authInterceptor.intercept(chain));
208+
Assertions.assertEquals(1, chain.getProceedCount());
209+
}
210+
211+
private static class TimeoutTestChain implements Interceptor.Chain {
212+
private final Request originalRequest;
213+
private final int timeoutCount;
214+
private final int successCode;
215+
private int proceedCount = 0;
216+
217+
TimeoutTestChain(Request request, int timeoutCount, int successCode) {
218+
this.originalRequest = request;
219+
this.timeoutCount = timeoutCount;
220+
this.successCode = successCode;
221+
}
222+
223+
int getProceedCount() { return proceedCount; }
224+
225+
@Override
226+
public Request request() { return originalRequest; }
227+
228+
@Override
229+
public Response proceed(Request request) throws IOException {
230+
proceedCount++;
231+
if (proceedCount <= timeoutCount) {
232+
throw new SocketTimeoutException("timeout");
233+
}
234+
return new Response.Builder()
235+
.request(request)
236+
.protocol(Protocol.HTTP_1_1)
237+
.code(successCode)
238+
.message("OK")
239+
.body(ResponseBody.create("{}", MediaType.parse("application/json")))
240+
.build();
241+
}
242+
243+
@Override public Connection connection() { return null; }
244+
@Override public int connectTimeoutMillis() { return 0; }
245+
@Override public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
246+
@Override public int readTimeoutMillis() { return 0; }
247+
@Override public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
248+
@Override public int writeTimeoutMillis() { return 0; }
249+
@Override public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
250+
@Override public Call call() { return null; }
251+
}
252+
169253
@Test
170254
public void AuthInterceptor() {
171255
AuthInterceptor expected = new AuthInterceptor("abc");

0 commit comments

Comments
 (0)