diff --git a/changelog.md b/changelog.md index 80f1373b..74e2cc78 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # 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. +- Enhancement: Added `setProtocols(List)` 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. + ## 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/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index ebe847b3..9c497a9e 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -3,7 +3,9 @@ import java.io.IOException; import java.net.Proxy; import java.time.Duration; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -30,6 +32,7 @@ import com.contentstack.cms.core.RetryConfig; import okhttp3.ConnectionPool; import okhttp3.OkHttpClient; +import okhttp3.Protocol; import okhttp3.ResponseBody; import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Response; @@ -609,6 +612,7 @@ public static class Builder { * evicted after 5 minutes of inactivity. */ private ConnectionPool connectionPool = new ConnectionPool(); // Connection + private List protocols = null; /** * Instantiates a new Builder. @@ -713,6 +717,11 @@ public Builder setConnectTimeout(int connectTimeoutSeconds) { return this; } + public Builder setProtocols(@NotNull List protocols) { + this.protocols = protocols; + return this; + } + private static void validateTimeoutSeconds(int seconds, String name) { if (seconds <= 0) { throw new IllegalArgumentException(name + " must be positive."); @@ -881,6 +890,9 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur .readTimeout(Duration.ofSeconds(readSec)) .writeTimeout(Duration.ofSeconds(writeSec)) .retryOnConnectionFailure(retryOnFailure); + if (this.protocols != null && !this.protocols.isEmpty()) { + builder.protocols(this.protocols); + } // Add either OAuth or traditional auth interceptor if (this.oauthConfig != null) { 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/ContentstackUnitTest.java b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java index 9e84c9b8..1da49474 100644 --- a/src/test/java/com/contentstack/cms/ContentstackUnitTest.java +++ b/src/test/java/com/contentstack/cms/ContentstackUnitTest.java @@ -5,6 +5,7 @@ import com.contentstack.cms.stack.Stack; import okhttp3.Headers; import okhttp3.OkHttpClient; +import okhttp3.Protocol; import okhttp3.Request; import okhttp3.ResponseBody; import okhttp3.mockwebserver.MockResponse; @@ -17,7 +18,9 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.net.Proxy; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -160,6 +163,24 @@ void setVersion() { Assertions.assertEquals("v8", contentstack.version); } + @Test + void setProtocols_http1Only_buildsSuccessfully() { + List protocols = Collections.singletonList(Protocol.HTTP_1_1); + Contentstack contentstack = new Contentstack.Builder() + .setProtocols(protocols) + .build(); + Assertions.assertNotNull(contentstack); + } + + @Test + void setProtocols_http2AndHttp1_buildsSuccessfully() { + List protocols = List.of(Protocol.HTTP_2, Protocol.HTTP_1_1); + Contentstack contentstack = new Contentstack.Builder() + .setProtocols(protocols) + .build(); + Assertions.assertNotNull(contentstack); + } + @Test void setTimeout() { Contentstack contentstack = new Contentstack.Builder() 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; } + } +}