diff --git a/changelog.md b/changelog.md index 80f1373b..b69d012c 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/pom.xml b/pom.xml index c227e9ba..1ff53c24 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.11.1 + 1.11.2 Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an API-first approach diff --git a/src/main/java/com/contentstack/cms/core/AuthInterceptor.java b/src/main/java/com/contentstack/cms/core/AuthInterceptor.java index 80ee0f97..873f5912 100644 --- a/src/main/java/com/contentstack/cms/core/AuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/core/AuthInterceptor.java @@ -1,6 +1,7 @@ package com.contentstack.cms.core; import java.io.IOException; +import java.net.SocketTimeoutException; import org.jetbrains.annotations.NotNull; @@ -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; } } diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java index 56f110a9..8feb5c63 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -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; @@ -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()) { diff --git a/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java b/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java index 310f964d..efe1b867 100644 --- a/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java +++ b/src/test/java/com/contentstack/cms/core/AuthInterceptorTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.net.SocketTimeoutException; public class AuthInterceptorTest { @@ -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"); diff --git a/src/test/java/com/contentstack/cms/oauth/OAuthInterceptorTest.java b/src/test/java/com/contentstack/cms/oauth/OAuthInterceptorTest.java new file mode 100644 index 00000000..580f9268 --- /dev/null +++ b/src/test/java/com/contentstack/cms/oauth/OAuthInterceptorTest.java @@ -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; } + } +}