Skip to content

Commit e22648e

Browse files
committed
fix: enhance retry mechanism for network-level timeouts in AuthInterceptor and OAuthInterceptor; update version to 1.11.2
1 parent 226b2ef commit e22648e

6 files changed

Lines changed: 254 additions & 15 deletions

File tree

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.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+
39
## v1.11.1
410

511
### 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/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/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");
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.contentstack.cms.oauth;
2+
3+
import com.contentstack.cms.core.RetryConfig;
4+
import com.contentstack.cms.models.OAuthTokens;
5+
import okhttp3.*;
6+
import org.junit.Before;
7+
import org.junit.Test;
8+
import org.junit.runner.RunWith;
9+
import org.mockito.Mock;
10+
import org.mockito.Mockito;
11+
import org.mockito.junit.MockitoJUnitRunner;
12+
13+
import java.io.IOException;
14+
import java.net.SocketTimeoutException;
15+
16+
import static org.junit.Assert.assertEquals;
17+
import static org.junit.Assert.assertThrows;
18+
19+
@RunWith(MockitoJUnitRunner.class)
20+
public class OAuthInterceptorTest {
21+
22+
private OAuthInterceptor interceptor;
23+
24+
@Mock
25+
private OAuthHandler mockHandler;
26+
27+
@Mock
28+
private OAuthTokens mockTokens;
29+
30+
@Before
31+
public void setup() {
32+
Mockito.lenient().when(mockTokens.isExpired()).thenReturn(false);
33+
Mockito.lenient().when(mockTokens.hasAccessToken()).thenReturn(true);
34+
Mockito.lenient().when(mockHandler.getTokens()).thenReturn(mockTokens);
35+
Mockito.lenient().when(mockHandler.getAccessToken()).thenReturn("test-access-token");
36+
37+
interceptor = new OAuthInterceptor(mockHandler);
38+
interceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build());
39+
}
40+
41+
@Test
42+
public void testRetry_onSocketTimeout_thenSuccess_retriesAndReturnsSuccess() throws IOException {
43+
Request request = new Request.Builder()
44+
.url("https://api.contentstack.io/v3/content_types")
45+
.get()
46+
.build();
47+
TimeoutTestChain chain = new TimeoutTestChain(request, 1, 200);
48+
try (Response response = interceptor.intercept(chain)) {
49+
assertEquals(200, response.code());
50+
assertEquals(2, chain.getProceedCount());
51+
}
52+
}
53+
54+
@Test
55+
public void testRetry_onSocketTimeout_exhaustsRetries_throws() {
56+
Request request = new Request.Builder()
57+
.url("https://api.contentstack.io/v3/content_types")
58+
.get()
59+
.build();
60+
TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200);
61+
assertThrows(SocketTimeoutException.class, () -> interceptor.intercept(chain));
62+
assertEquals(4, chain.getProceedCount()); // 1 initial + 3 retries
63+
}
64+
65+
@Test
66+
public void testRetry_onSocketTimeout_zeroRetryLimit_throwsImmediately() {
67+
interceptor.setRetryConfig(RetryConfig.builder().retryLimit(0).retryDelay(10).build());
68+
Request request = new Request.Builder()
69+
.url("https://api.contentstack.io/v3/content_types")
70+
.get()
71+
.build();
72+
TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200);
73+
assertThrows(SocketTimeoutException.class, () -> interceptor.intercept(chain));
74+
assertEquals(1, chain.getProceedCount());
75+
}
76+
77+
private static class TimeoutTestChain implements Interceptor.Chain {
78+
private final Request originalRequest;
79+
private final int timeoutCount;
80+
private final int successCode;
81+
private int proceedCount = 0;
82+
83+
TimeoutTestChain(Request request, int timeoutCount, int successCode) {
84+
this.originalRequest = request;
85+
this.timeoutCount = timeoutCount;
86+
this.successCode = successCode;
87+
}
88+
89+
int getProceedCount() { return proceedCount; }
90+
91+
@Override
92+
public Request request() { return originalRequest; }
93+
94+
@Override
95+
public Response proceed(Request request) throws IOException {
96+
proceedCount++;
97+
if (proceedCount <= timeoutCount) {
98+
throw new SocketTimeoutException("timeout");
99+
}
100+
return new Response.Builder()
101+
.request(request)
102+
.protocol(Protocol.HTTP_1_1)
103+
.code(successCode)
104+
.message("OK")
105+
.body(ResponseBody.create("{}", MediaType.parse("application/json")))
106+
.build();
107+
}
108+
109+
@Override public Connection connection() { return null; }
110+
@Override public int connectTimeoutMillis() { return 0; }
111+
@Override public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
112+
@Override public int readTimeoutMillis() { return 0; }
113+
@Override public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
114+
@Override public int writeTimeoutMillis() { return 0; }
115+
@Override public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
116+
@Override public Call call() { return null; }
117+
}
118+
}

0 commit comments

Comments
 (0)