Skip to content
Closed
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
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v1.11.2

### Jun 01, 2026

- 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.

## v1.11.1

### Apr 06, 2026
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<artifactId>cms</artifactId>
<packaging>jar</packaging>
<name>contentstack-management-java</name>
<version>1.11.1</version>
<version>1.11.2</version>
<description>Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an
API-first approach
</description>
Expand Down
41 changes: 28 additions & 13 deletions src/main/java/com/contentstack/cms/core/AuthInterceptor.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.contentstack.cms.core;

import java.io.IOException;
import java.net.SocketTimeoutException;

import org.jetbrains.annotations.NotNull;

Expand Down Expand Up @@ -116,21 +117,35 @@ 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);
private Response executeRequest(Chain chain, Request request, int retryCount) throws IOException {
try {
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 executeRequest(chain, request, retryCount + 1);
return response;
} catch (SocketTimeoutException e) {
if (retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(0, e)) {
long delay = RetryUtil.calculateDelay(retryConfig, retryCount + 1, 0);
try {
Thread.sleep(delay);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IOException("Retry interrupted", ex);
}
return executeRequest(chain, request, retryCount + 1);
}
throw e;
}
return response;
}

}
18 changes: 17 additions & 1 deletion src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.contentstack.cms.oauth;

import java.io.IOException;
import java.net.SocketTimeoutException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
Expand Down Expand Up @@ -112,7 +113,22 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th
}

// Execute request
Response response = chain.proceed(request);
Response response;
try {
response = chain.proceed(request);
} catch (SocketTimeoutException e) {
if (retryCount < retryConfig.getRetryLimit() && retryConfig.getRetryCondition().shouldRetry(0, e)) {
long delay = RetryUtil.calculateDelay(retryConfig, retryCount + 1, 0);
try {
Thread.sleep(delay);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IOException("Retry interrupted", ex);
}
return executeRequest(chain, request, retryCount + 1);
}
throw e;
}

// Handle error responses
if (!response.isSuccessful() && retryCount < retryConfig.getRetryLimit()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.SocketTimeoutException;

public class AuthInterceptorTest {

Expand Down Expand Up @@ -166,6 +167,89 @@ public Call call() {
}
}

@Test
@Tag("unit")
public void testRetry_onSocketTimeout_thenSuccess_retriesAndReturnsSuccess() throws IOException {
authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build());
Request request = new Request.Builder()
.url("https://api.contentstack.io/v3/user")
.get()
.build();
TimeoutTestChain chain = new TimeoutTestChain(request, 1, 200);
try (Response response = authInterceptor.intercept(chain)) {
Assertions.assertEquals(200, response.code());
Assertions.assertEquals(2, chain.getProceedCount());
}
}

@Test
@Tag("unit")
public void testRetry_onSocketTimeout_exhaustsRetries_throws() {
authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(2).retryDelay(10).build());
Request request = new Request.Builder()
.url("https://api.contentstack.io/v3/user")
.get()
.build();
TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200);
Assertions.assertThrows(SocketTimeoutException.class, () -> authInterceptor.intercept(chain));
Assertions.assertEquals(3, chain.getProceedCount());
}

@Test
@Tag("unit")
public void testRetry_onSocketTimeout_zeroRetryLimit_throwsImmediately() {
authInterceptor.setRetryConfig(RetryConfig.builder().retryLimit(0).retryDelay(10).build());
Request request = new Request.Builder()
.url("https://api.contentstack.io/v3/user")
.get()
.build();
TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200);
Assertions.assertThrows(SocketTimeoutException.class, () -> authInterceptor.intercept(chain));
Assertions.assertEquals(1, chain.getProceedCount());
}

private static class TimeoutTestChain implements Interceptor.Chain {
private final Request originalRequest;
private final int timeoutCount;
private final int successCode;
private int proceedCount = 0;

TimeoutTestChain(Request request, int timeoutCount, int successCode) {
this.originalRequest = request;
this.timeoutCount = timeoutCount;
this.successCode = successCode;
}

int getProceedCount() { return proceedCount; }

@Override
public Request request() { return originalRequest; }

@Override
public Response proceed(Request request) throws IOException {
proceedCount++;
if (proceedCount <= timeoutCount) {
throw new SocketTimeoutException("timeout");
}
return new Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(successCode)
.message("OK")
.body(ResponseBody.create("{}", MediaType.parse("application/json")))
.build();
}

@Override public Connection connection() { return null; }
@Override public int connectTimeoutMillis() { return 0; }
@Override public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
@Override public int readTimeoutMillis() { return 0; }
@Override public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
@Override public int writeTimeoutMillis() { return 0; }
@Override public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
@Override public Call call() { return null; }
}

@Test
public void AuthInterceptor() {
AuthInterceptor expected = new AuthInterceptor("abc");
Expand Down
118 changes: 118 additions & 0 deletions src/test/java/com/contentstack/cms/oauth/OAuthInterceptorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.contentstack.cms.oauth;

import com.contentstack.cms.core.RetryConfig;
import com.contentstack.cms.models.OAuthTokens;
import okhttp3.*;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;

import java.io.IOException;
import java.net.SocketTimeoutException;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;

@RunWith(MockitoJUnitRunner.class)
public class OAuthInterceptorTest {

private OAuthInterceptor interceptor;

@Mock
private OAuthHandler mockHandler;

@Mock
private OAuthTokens mockTokens;

@Before
public void setup() {
Mockito.lenient().when(mockTokens.isExpired()).thenReturn(false);
Mockito.lenient().when(mockTokens.hasAccessToken()).thenReturn(true);
Mockito.lenient().when(mockHandler.getTokens()).thenReturn(mockTokens);
Mockito.lenient().when(mockHandler.getAccessToken()).thenReturn("test-access-token");

interceptor = new OAuthInterceptor(mockHandler);
interceptor.setRetryConfig(RetryConfig.builder().retryLimit(3).retryDelay(10).build());
}

@Test
public void testRetry_onSocketTimeout_thenSuccess_retriesAndReturnsSuccess() throws IOException {
Request request = new Request.Builder()
.url("https://api.contentstack.io/v3/content_types")
.get()
.build();
TimeoutTestChain chain = new TimeoutTestChain(request, 1, 200);
try (Response response = interceptor.intercept(chain)) {
assertEquals(200, response.code());
assertEquals(2, chain.getProceedCount());
}
}

@Test
public void testRetry_onSocketTimeout_exhaustsRetries_throws() {
Request request = new Request.Builder()
.url("https://api.contentstack.io/v3/content_types")
.get()
.build();
TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200);
assertThrows(SocketTimeoutException.class, () -> interceptor.intercept(chain));
assertEquals(4, chain.getProceedCount()); // 1 initial + 3 retries
}

@Test
public void testRetry_onSocketTimeout_zeroRetryLimit_throwsImmediately() {
interceptor.setRetryConfig(RetryConfig.builder().retryLimit(0).retryDelay(10).build());
Request request = new Request.Builder()
.url("https://api.contentstack.io/v3/content_types")
.get()
.build();
TimeoutTestChain chain = new TimeoutTestChain(request, 5, 200);
assertThrows(SocketTimeoutException.class, () -> interceptor.intercept(chain));
assertEquals(1, chain.getProceedCount());
}

private static class TimeoutTestChain implements Interceptor.Chain {
private final Request originalRequest;
private final int timeoutCount;
private final int successCode;
private int proceedCount = 0;

TimeoutTestChain(Request request, int timeoutCount, int successCode) {
this.originalRequest = request;
this.timeoutCount = timeoutCount;
this.successCode = successCode;
}

int getProceedCount() { return proceedCount; }

@Override
public Request request() { return originalRequest; }

@Override
public Response proceed(Request request) throws IOException {
proceedCount++;
if (proceedCount <= timeoutCount) {
throw new SocketTimeoutException("timeout");
}
return new Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(successCode)
.message("OK")
.body(ResponseBody.create("{}", MediaType.parse("application/json")))
.build();
}

@Override public Connection connection() { return null; }
@Override public int connectTimeoutMillis() { return 0; }
@Override public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
@Override public int readTimeoutMillis() { return 0; }
@Override public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
@Override public int writeTimeoutMillis() { return 0; }
@Override public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; }
@Override public Call call() { return null; }
}
}
Loading