From 50112e41f74e3385c0d1f40221f2572be24c3ba5 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 23 Jan 2026 11:43:51 +0530 Subject: [PATCH 1/5] feat: Add retry options to Config and integrate with OkHttpClient in Stack --- .../java/com/contentstack/sdk/Config.java | 21 + .../sdk/CustomBackoffStrategy.java | 16 + .../contentstack/sdk/RetryInterceptor.java | 137 +++ .../com/contentstack/sdk/RetryOptions.java | 318 +++++ src/main/java/com/contentstack/sdk/Stack.java | 16 +- .../sdk/RetryInterceptorTest.java | 1026 +++++++++++++++++ .../contentstack/sdk/RetryOptionsTest.java | 490 ++++++++ 7 files changed, 2021 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/contentstack/sdk/CustomBackoffStrategy.java create mode 100644 src/main/java/com/contentstack/sdk/RetryInterceptor.java create mode 100644 src/main/java/com/contentstack/sdk/RetryOptions.java create mode 100644 src/test/java/com/contentstack/sdk/RetryInterceptorTest.java create mode 100644 src/test/java/com/contentstack/sdk/RetryOptionsTest.java diff --git a/src/main/java/com/contentstack/sdk/Config.java b/src/main/java/com/contentstack/sdk/Config.java index bc0de25b..5c3eb101 100644 --- a/src/main/java/com/contentstack/sdk/Config.java +++ b/src/main/java/com/contentstack/sdk/Config.java @@ -31,6 +31,7 @@ public class Config { protected Proxy proxy = null; protected String[] earlyAccess = null; protected ConnectionPool connectionPool = new ConnectionPool(); + protected RetryOptions retryOptions = new RetryOptions(); public String releaseId; public String previewTimestamp; @@ -129,6 +130,26 @@ public void setPlugins(List plugins) { this.plugins = plugins; } + /** + * Sets the retry options. + * + * @param retryOptions the retry options + * @return the config + */ + public Config setRetryOptions(RetryOptions retryOptions) { + this.retryOptions = retryOptions; + return this; + } + + /** + * Gets the retry options. + * + * @return the retry options + */ + public RetryOptions getRetryOptions() { + return this.retryOptions; + } + /** * Gets host. * diff --git a/src/main/java/com/contentstack/sdk/CustomBackoffStrategy.java b/src/main/java/com/contentstack/sdk/CustomBackoffStrategy.java new file mode 100644 index 00000000..70f28127 --- /dev/null +++ b/src/main/java/com/contentstack/sdk/CustomBackoffStrategy.java @@ -0,0 +1,16 @@ +package com.contentstack.sdk; + +import java.io.IOException; + +@FunctionalInterface +public interface CustomBackoffStrategy { + /** + * Calculates delay before next retry. + * + * @param attempt current attempt number (0-based) + * @param statusCode HTTP status code (or -1 for network errors) + * @param exception the exception that occurred (may be null) + * @return delay in milliseconds before next retry + */ + long calculateDelay(int attempt, int statusCode, IOException exception); +} diff --git a/src/main/java/com/contentstack/sdk/RetryInterceptor.java b/src/main/java/com/contentstack/sdk/RetryInterceptor.java new file mode 100644 index 00000000..c50b4097 --- /dev/null +++ b/src/main/java/com/contentstack/sdk/RetryInterceptor.java @@ -0,0 +1,137 @@ +package com.contentstack.sdk; + +import java.io.IOException; +import java.util.Arrays; +import java.util.logging.Logger; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +public class RetryInterceptor implements Interceptor { + + private static final Logger logger = Logger.getLogger(RetryInterceptor.class.getName()); + private final RetryOptions retryOptions; + + + public RetryInterceptor(RetryOptions retryOptions) { + if (retryOptions == null) { + throw new NullPointerException("RetryOptions cannot be null"); + } + this.retryOptions = retryOptions; + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + Response response = null; + IOException lastException = null; + + // If retry is disabled, just proceed with the request once + if (!retryOptions.isRetryEnabled()) { + return chain.proceed(request); + } + + int attempt = 0; + // retryLimit means number of retries, so total attempts = 1 initial + retryLimit retries + int maxAttempts = retryOptions.getRetryLimit() + 1; + + while (attempt < maxAttempts) { + + try { + if(response != null) { + response.close(); + } + response = chain.proceed(request); + + if (shouldRetry(response.code()) && (attempt + 1) < maxAttempts) { + logger.fine("Retry attempt " + (attempt + 1) + " for status " + response.code() + " on " + request.url()); + + long delay = calculateDelay(attempt, response.code(), null); + Thread.sleep(delay); + attempt++; + continue; + } + return response; + + } catch (IOException e) { + // Network error occurred + lastException = e; + + if ((attempt + 1) < maxAttempts) { + try { + long delay = calculateDelay(attempt, -1, e); + Thread.sleep(delay); + attempt++; + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("Retry interrupted", ie); + } + continue; + } else { + // No more retries, throw the exception + throw e; + } + + } catch (InterruptedException e) { + // Thread was interrupted during sleep + Thread.currentThread().interrupt(); + if (response != null) response.close(); + throw new IOException("Retry interrupted", e); + } + } + + // Should not reach here normally + if (lastException != null) { + throw lastException; + } + return response; + } + + /** + * Determines if a status code should trigger a retry. + * + * @param statusCode HTTP status code + * @return true if this status code is retryable + */ + private boolean shouldRetry(int statusCode) { + return Arrays.stream(retryOptions.getRetryableStatusCodes()).anyMatch(code -> code == statusCode); + } + + /** + * Calculates the delay before the next retry attempt. + * + * @param attempt current attempt number (0-based) + * @param statusCode HTTP status code (-1 for network errors) + * @param exception the IOException that occurred (may be null) + * @return delay in milliseconds + */ + private long calculateDelay(int attempt, int statusCode, IOException exception) { + + if(retryOptions.hasCustomBackoff()) { + return retryOptions.getCustomBackoffStrategy().calculateDelay(attempt, statusCode, exception); + } + long baseDelay = retryOptions.getRetryDelay(); + + switch (retryOptions.getBackoffStrategy()) { + case FIXED: + // baseDelay already set above + break; + + case LINEAR: + baseDelay = retryOptions.getRetryDelay() * (attempt + 1); + break; + + case EXPONENTIAL: + baseDelay = (long) (retryOptions.getRetryDelay() * Math.pow(2,attempt)); + break; + + default: + // baseDelay already set above + break; + } + + return baseDelay; + } + +} diff --git a/src/main/java/com/contentstack/sdk/RetryOptions.java b/src/main/java/com/contentstack/sdk/RetryOptions.java new file mode 100644 index 00000000..755e62d0 --- /dev/null +++ b/src/main/java/com/contentstack/sdk/RetryOptions.java @@ -0,0 +1,318 @@ +package com.contentstack.sdk; + +import java.util.Objects; + +/** + * Configuration options for HTTP request retry mechanism. + * + *

This class allows customization of retry behavior for failed HTTP requests. + * By default, retries are enabled with exponential backoff strategy. + * + *

Default Configuration: + *

+ * + *

Example Usage: + *

{@code
+ * // Custom retry configuration
+ * RetryOptions options = new RetryOptions()
+ *     .setRetryLimit(5)
+ *     .setRetryDelay(2000)
+ *     .setBackoffStrategy(BackoffStrategy.LINEAR)
+ *     .setRetryableStatusCodes(429, 502, 503);
+ * 
+ * Config config = new Config();
+ * config.setRetryOptions(options);
+ * 
+ * // Disable retries
+ * RetryOptions noRetry = new RetryOptions().setRetryEnabled(false);
+ * }
+ * + * @since 2.0.0 + */ +public class RetryOptions { + + /** Default number of retry attempts */ + private static final int DEFAULT_RETRY_LIMIT = 3; + + /** Default base delay in milliseconds */ + private static final long DEFAULT_RETRY_DELAY_MS = 1000; + + /** Maximum allowed retry attempts to prevent infinite retries */ + private static final int MAX_RETRY_LIMIT = 10; + + /** Default retryable HTTP status codes (transient errors) */ + private static final int[] DEFAULT_RETRYABLE_STATUS_CODES = { + 408, // Request Timeout + 429, // Too Many Requests (rate limiting) + 502, // Bad Gateway + 503, // Service Unavailable + 504 // Gateway Timeout + }; + + /** + * Maximum number of retry attempts. + * 0 means no retries (but retry can still be enabled for other reasons). + */ + private int retryLimit = DEFAULT_RETRY_LIMIT; + + /** + * Base delay in milliseconds between retry attempts. + * Actual delay depends on backoff strategy. + */ + private long retryDelayMs = DEFAULT_RETRY_DELAY_MS; + + /** + * Strategy for calculating delay between retries. + */ + private BackoffStrategy backoffStrategy = BackoffStrategy.EXPONENTIAL; + + /** + * HTTP status codes that should trigger a retry. + * Only these codes will be retried; others fail immediately. + */ + private int[] retryableStatusCodes = DEFAULT_RETRYABLE_STATUS_CODES.clone(); + + /** + * Master switch to enable/disable retry mechanism entirely. + * When false, no retries occur regardless of other settings. + */ + private boolean retryEnabled = true; + + private CustomBackoffStrategy customBackoffStrategy = null; + + /** + * Defines how delay between retries is calculated. + */ + public enum BackoffStrategy { + /** + * Fixed delay - same wait time for each retry. + *

Example with 1000ms base delay: + *

+ */ + FIXED, + + /** + * Linear backoff - delay increases linearly with attempt number. + *

Example with 1000ms base delay: + *

+ */ + LINEAR, + + /** + * Exponential backoff - delay doubles with each attempt. + *

Example with 1000ms base delay: + *

+ *

Recommended for most use cases as it quickly backs off + * to prevent overwhelming the server while allowing fast recovery. + */ + EXPONENTIAL, + + /** + * Custom backoff strategy - delay is calculated by the user. + *

Example with 1000ms base delay: + *

+ */ + CUSTOM, + } + + /** + * Creates RetryOptions with default configuration. + *

Defaults: 3 retries, 1000ms delay, exponential backoff, + * retries on [408, 429, 502, 503, 504], enabled. + */ + public RetryOptions() { + } + + /** + * Sets the maximum number of retry attempts. + * + * @param limit maximum retry attempts (0-10) + * @return this RetryOptions instance for method chaining + * @throws IllegalArgumentException if limit is negative or exceeds maximum + */ + public RetryOptions setRetryLimit(int limit) { + if (limit < 0) { + throw new IllegalArgumentException( + "Retry limit cannot be negative. Provided: " + limit); + } + if (limit > MAX_RETRY_LIMIT) { + throw new IllegalArgumentException( + "Retry limit cannot exceed " + MAX_RETRY_LIMIT + ". Provided: " + limit); + } + this.retryLimit = limit; + return this; + } + + /** + * Sets the base delay between retry attempts in milliseconds. + * + * @param delayMs base delay in milliseconds (must be non-negative) + * @return this RetryOptions instance for method chaining + * @throws IllegalArgumentException if delay is negative + */ + public RetryOptions setRetryDelay(long delayMs) { + if (delayMs < 0) { + throw new IllegalArgumentException( + "Retry delay cannot be negative. Provided: " + delayMs); + } + this.retryDelayMs = delayMs; + return this; + } + + /** + * Sets the backoff strategy for calculating retry delays. + * + * @param strategy backoff strategy (FIXED, LINEAR, or EXPONENTIAL) + * @return this RetryOptions instance for method chaining + * @throws NullPointerException if strategy is null + */ + public RetryOptions setBackoffStrategy(BackoffStrategy strategy) { + this.backoffStrategy = Objects.requireNonNull(strategy, "Backoff strategy cannot be null"); + return this; + } + + /** + * Sets the HTTP status codes that should trigger a retry. + *

Only requests that fail with these status codes will be retried. + * Other status codes will fail immediately. + *

If null or empty array is provided, no status code-based retries will occur. + * + * @param codes HTTP status codes to retry (e.g., 429, 502, 503) + * @return this RetryOptions instance for method chaining + * @throws IllegalArgumentException if any status code is outside valid HTTP range (100-599) + */ + public RetryOptions setRetryableStatusCodes(int... codes) { + if (codes == null || codes.length == 0) { + this.retryableStatusCodes = new int[0]; + return this; + } + // Validate each status code is in valid HTTP range (100-599) + for (int code : codes) { + if (code < 100 || code > 599) { + throw new IllegalArgumentException( + "Invalid HTTP status code: " + code + ". Must be between 100 and 599."); + } + } + this.retryableStatusCodes = codes.clone(); + return this; + } + + /** + * Enables or disables the retry mechanism. + *

When disabled, no retries will occur regardless of other settings. + * + * @param enabled true to enable retries, false to disable + * @return this RetryOptions instance for method chaining + */ + public RetryOptions setRetryEnabled(boolean enabled) { + this.retryEnabled = enabled; + return this; + } + /** + * Sets the custom backoff strategy. + */ + public RetryOptions setCustomBackoffStrategy(CustomBackoffStrategy customStrategy) { + this.customBackoffStrategy = Objects.requireNonNull(customStrategy, "Custom backoff strategy cannot be null"); + this.backoffStrategy = BackoffStrategy.CUSTOM; + return this; + } + + /** + * Returns the custom backoff strategy. + */ + public CustomBackoffStrategy getCustomBackoffStrategy() { + return customBackoffStrategy; + } + + /** + * Checks if custom backoff is configured. + */ + public boolean hasCustomBackoff() { + return customBackoffStrategy != null; + } + /** + * Returns the maximum number of retry attempts. + * + * @return retry limit (0-10) + */ + public int getRetryLimit() { + return retryLimit; + } + + /** + * Returns the base delay in milliseconds between retry attempts. + * + * @return retry delay in milliseconds + */ + public long getRetryDelay() { + return retryDelayMs; + } + + /** + * Returns the backoff strategy used for calculating retry delays. + * + * @return backoff strategy (FIXED, LINEAR, or EXPONENTIAL) + */ + public BackoffStrategy getBackoffStrategy() { + return backoffStrategy; + } + + /** + * Returns the HTTP status codes that trigger retries. + *

Returns a copy to prevent external modification. + * + * @return array of retryable HTTP status codes + */ + public int[] getRetryableStatusCodes() { + return retryableStatusCodes.clone(); + } + + /** + * Returns whether the retry mechanism is enabled. + * + * @return true if retry is enabled, false otherwise + */ + public boolean isRetryEnabled() { + return retryEnabled; + } + + /** + * Returns a string representation of the retry configuration. + * Useful for debugging and logging. + * + * @return string representation of this configuration + */ + @Override + public String toString() { + return "RetryOptions{" + + "enabled=" + retryEnabled + + ", limit=" + retryLimit + + ", delay=" + retryDelayMs + "ms" + + ", strategy=" + backoffStrategy + + ", retryableCodes=" + java.util.Arrays.toString(retryableStatusCodes) + + '}'; + } +} + diff --git a/src/main/java/com/contentstack/sdk/Stack.java b/src/main/java/com/contentstack/sdk/Stack.java index 1de85031..cea8be0d 100644 --- a/src/main/java/com/contentstack/sdk/Stack.java +++ b/src/main/java/com/contentstack/sdk/Stack.java @@ -97,10 +97,20 @@ protected void setConfig(Config config) { private void client(String endpoint) { Proxy proxy = this.config.getProxy(); ConnectionPool pool = this.config.connectionPool; - OkHttpClient client = new OkHttpClient.Builder() + + // Build OkHttpClient with optional retry interceptor + OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder() .proxy(proxy) - .connectionPool(pool) - .build(); + .connectionPool(pool); + + // Add retry interceptor if enabled + RetryOptions retryOptions = this.config.getRetryOptions(); + if (retryOptions != null && retryOptions.isRetryEnabled()) { + clientBuilder.addInterceptor(new RetryInterceptor(retryOptions)); + logger.fine("Retry interceptor added with options: " + retryOptions); + } + + OkHttpClient client = clientBuilder.build(); Retrofit retrofit = new Retrofit.Builder().baseUrl(endpoint) .client(client) diff --git a/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java b/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java new file mode 100644 index 00000000..92bab52f --- /dev/null +++ b/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java @@ -0,0 +1,1026 @@ +package com.contentstack.sdk; + +import okhttp3.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for RetryInterceptor class. + * Tests retry logic, backoff strategies, and error handling. + */ +class RetryInterceptorTest { + + private RetryOptions retryOptions; + private RetryInterceptor interceptor; + + @BeforeEach + void setUp() { + retryOptions = new RetryOptions(); + interceptor = new RetryInterceptor(retryOptions); + } + + // =========================== + // Helper Classes - Manual Mocks + // =========================== + + /** + * Mock Chain that allows us to control responses and track invocations + */ + private static class MockChain implements Interceptor.Chain { + private final Request request; + private Response response; + private IOException exceptionToThrow; + private final AtomicInteger callCount = new AtomicInteger(0); + + MockChain(Request request) { + this.request = request; + } + + void setResponse(Response response) { + this.response = response; + } + + void setException(IOException exception) { + this.exceptionToThrow = exception; + } + + int getCallCount() { + return callCount.get(); + } + + @Override + public Request request() { + return request; + } + + @Override + public Response proceed(Request request) throws IOException { + callCount.incrementAndGet(); + + if (exceptionToThrow != null) { + throw exceptionToThrow; + } + + if (response == null) { + throw new IllegalStateException("No response configured for mock chain"); + } + + return response; + } + + @Override + public Connection connection() { + return null; + } + + @Override + public Call call() { + return null; + } + + @Override + public int connectTimeoutMillis() { + return 10000; + } + + @Override + public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { + return this; + } + + @Override + public int readTimeoutMillis() { + return 10000; + } + + @Override + public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { + return this; + } + + @Override + public int writeTimeoutMillis() { + return 10000; + } + + @Override + public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { + return this; + } + } + + /** + * Mock Chain that changes response on each call + */ + private static class DynamicMockChain implements Interceptor.Chain { + private final Request request; + private final Response[] responses; + private final AtomicInteger callIndex = new AtomicInteger(0); + + DynamicMockChain(Request request, Response... responses) { + this.request = request; + this.responses = responses; + } + + int getCallCount() { + return callIndex.get(); + } + + @Override + public Request request() { + return request; + } + + @Override + public Response proceed(Request request) throws IOException { + int index = callIndex.getAndIncrement(); + + if (index >= responses.length) { + throw new IllegalStateException("No more responses available"); + } + + return responses[index]; + } + + @Override + public Connection connection() { + return null; + } + + @Override + public Call call() { + return null; + } + + @Override + public int connectTimeoutMillis() { + return 10000; + } + + @Override + public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { + return this; + } + + @Override + public int readTimeoutMillis() { + return 10000; + } + + @Override + public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { + return this; + } + + @Override + public int writeTimeoutMillis() { + return 10000; + } + + @Override + public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { + return this; + } + } + + // =========================== + // Helper Methods + // =========================== + + private Request createTestRequest() { + return new Request.Builder() + .url("https://api.contentstack.io/v3/test") + .build(); + } + + private Response createMockResponse(int statusCode) { + return new Response.Builder() + .request(createTestRequest()) + .protocol(Protocol.HTTP_1_1) + .code(statusCode) + .message("Test Response") + .body(ResponseBody.create("", MediaType.get("application/json"))) + .build(); + } + + // =========================== + // Successful Request Tests (No Retry) + // =========================== + + @Test + @DisplayName("Test successful request with 200 status - no retry") + void testSuccessfulRequest() throws IOException { + Request request = createTestRequest(); + Response successResponse = createMockResponse(200); + + MockChain chain = new MockChain(request); + chain.setResponse(successResponse); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should return 200 response"); + assertEquals(1, chain.getCallCount(), "Should only call once (no retry)"); + } + + @Test + @DisplayName("Test successful request with 201 status - no retry") + void testSuccessfulRequestWith201() throws IOException { + Request request = createTestRequest(); + Response successResponse = createMockResponse(201); + + MockChain chain = new MockChain(request); + chain.setResponse(successResponse); + + Response result = interceptor.intercept(chain); + + assertEquals(201, result.code(), "Should return 201 response"); + assertEquals(1, chain.getCallCount(), "Should only call once (no retry)"); + } + + // =========================== + // Non-Retryable Status Code Tests + // =========================== + + @Test + @DisplayName("Test 400 Bad Request - no retry") + void testBadRequestNoRetry() throws IOException { + Request request = createTestRequest(); + Response badRequestResponse = createMockResponse(400); + + MockChain chain = new MockChain(request); + chain.setResponse(badRequestResponse); + + Response result = interceptor.intercept(chain); + + assertEquals(400, result.code(), "Should return 400 response"); + assertEquals(1, chain.getCallCount(), + "Should only call once (400 is not retryable)"); + } + + @Test + @DisplayName("Test 404 Not Found - no retry") + void testNotFoundNoRetry() throws IOException { + Request request = createTestRequest(); + Response notFoundResponse = createMockResponse(404); + + MockChain chain = new MockChain(request); + chain.setResponse(notFoundResponse); + + Response result = interceptor.intercept(chain); + + assertEquals(404, result.code(), "Should return 404 response"); + assertEquals(1, chain.getCallCount(), + "Should only call once (404 is not retryable)"); + } + + @Test + @DisplayName("Test 401 Unauthorized - no retry") + void testUnauthorizedNoRetry() throws IOException { + Request request = createTestRequest(); + Response unauthorizedResponse = createMockResponse(401); + + MockChain chain = new MockChain(request); + chain.setResponse(unauthorizedResponse); + + Response result = interceptor.intercept(chain); + + assertEquals(401, result.code(), "Should return 401 response"); + assertEquals(1, chain.getCallCount(), + "Should only call once (401 is not retryable)"); + } + + // =========================== + // Retryable Status Code Tests + // =========================== + + @Test + @DisplayName("Test 429 Too Many Requests - triggers retry then succeeds") + void testRateLimitRetryThenSuccess() throws IOException { + Request request = createTestRequest(); + Response rateLimitResponse = createMockResponse(429); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + rateLimitResponse, successResponse); + + // Set minimal delay for faster test + retryOptions.setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually return 200"); + assertEquals(2, chain.getCallCount(), + "Should call twice (1 failure + 1 success)"); + } + + @Test + @DisplayName("Test 503 Service Unavailable - triggers retry") + void testServiceUnavailableRetry() throws IOException { + Request request = createTestRequest(); + Response serviceUnavailableResponse = createMockResponse(503); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + serviceUnavailableResponse, successResponse); + + retryOptions.setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually return 200"); + assertEquals(2, chain.getCallCount(), "Should retry once"); + } + + @Test + @DisplayName("Test 502 Bad Gateway - triggers retry") + void testBadGatewayRetry() throws IOException { + Request request = createTestRequest(); + Response badGatewayResponse = createMockResponse(502); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + badGatewayResponse, successResponse); + + retryOptions.setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually return 200"); + assertEquals(2, chain.getCallCount(), "Should retry once"); + } + + @Test + @DisplayName("Test 504 Gateway Timeout - triggers retry") + void testGatewayTimeoutRetry() throws IOException { + Request request = createTestRequest(); + Response timeoutResponse = createMockResponse(504); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + timeoutResponse, successResponse); + + retryOptions.setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually return 200"); + assertEquals(2, chain.getCallCount(), "Should retry once"); + } + + @Test + @DisplayName("Test 408 Request Timeout - triggers retry") + void testRequestTimeoutRetry() throws IOException { + Request request = createTestRequest(); + Response timeoutResponse = createMockResponse(408); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + timeoutResponse, successResponse); + + retryOptions.setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually return 200"); + assertEquals(2, chain.getCallCount(), "Should retry once"); + } + + // =========================== + // Retry Limit Tests + // =========================== + + @Test + @DisplayName("Test retry limit is enforced") + void testRetryLimitEnforced() throws IOException { + Request request = createTestRequest(); + + // Create 5 failure responses (more than retry limit of 3) + Response failureResponse = createMockResponse(503); + DynamicMockChain chain = new DynamicMockChain(request, + failureResponse, failureResponse, failureResponse, + failureResponse, failureResponse); + + retryOptions.setRetryLimit(3).setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + // Should stop after limit and return last failure + assertEquals(503, result.code(), "Should return last failure response"); + assertEquals(4, chain.getCallCount(), + "Should call exactly 4 times (1 initial + 3 retries)"); + } + + @Test + @DisplayName("Test retry limit 0 means no retries") + void testRetryLimitZeroNoRetries() throws IOException { + Request request = createTestRequest(); + Response failureResponse = createMockResponse(503); + + MockChain chain = new MockChain(request); + chain.setResponse(failureResponse); + + retryOptions.setRetryLimit(0); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(503, result.code(), "Should return failure immediately"); + assertEquals(1, chain.getCallCount(), + "Should only call once (no retries with limit 0)"); + } + + @Test + @DisplayName("Test maximum retries eventually succeed") + void testMaximumRetriesEventuallySucceed() throws IOException { + Request request = createTestRequest(); + Response failureResponse = createMockResponse(503); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + failureResponse, failureResponse, successResponse); + + retryOptions.setRetryLimit(3).setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually succeed"); + assertEquals(3, chain.getCallCount(), + "Should call 3 times (2 failures + 1 success)"); + } + + // =========================== + // Network Error (IOException) Tests + // =========================== + + @Test + @DisplayName("Test IOException triggers retry") + void testIOExceptionTriggersRetry() { + Request request = createTestRequest(); + + // Create a chain that throws IOException on first call, succeeds on second + Interceptor.Chain chain = new Interceptor.Chain() { + private int callCount = 0; + + @Override + public Request request() { + return request; + } + + @Override + public Response proceed(Request req) throws IOException { + callCount++; + if (callCount == 1) { + throw new IOException("Network error"); + } + return createMockResponse(200); + } + + @Override + public Connection connection() { return null; } + @Override + public Call call() { return null; } + @Override + public int connectTimeoutMillis() { return 10000; } + @Override + public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override + public int readTimeoutMillis() { return 10000; } + @Override + public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override + public int writeTimeoutMillis() { return 10000; } + @Override + public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + }; + + retryOptions.setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + assertDoesNotThrow(() -> { + Response result = interceptor.intercept(chain); + assertEquals(200, result.code(), "Should succeed after retry"); + }); + } + + @Test + @DisplayName("Test IOException exhausts retries and throws") + void testIOExceptionExhaustsRetries() { + Request request = createTestRequest(); + MockChain chain = new MockChain(request); + chain.setException(new IOException("Persistent network error")); + + retryOptions.setRetryLimit(2).setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + IOException exception = assertThrows(IOException.class, + () -> interceptor.intercept(chain), + "Should throw IOException after exhausting retries"); + + assertTrue(exception.getMessage().contains("Persistent network error"), + "Should throw original exception"); + assertEquals(3, chain.getCallCount(), + "Should call exactly 3 times (1 initial + 2 retries)"); + } + + // =========================== + // Retry Disabled Tests + // =========================== + + @Test + @DisplayName("Test retry disabled - no retries for 503") + void testRetryDisabled() throws IOException { + Request request = createTestRequest(); + Response failureResponse = createMockResponse(503); + + MockChain chain = new MockChain(request); + chain.setResponse(failureResponse); + + retryOptions.setRetryEnabled(false); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(503, result.code(), "Should return failure immediately"); + assertEquals(1, chain.getCallCount(), + "Should only call once (retry disabled)"); + } + + // =========================== + // Custom Retryable Status Codes Tests + // =========================== + + @Test + @DisplayName("Test custom retryable status codes") + void testCustomRetryableStatusCodes() throws IOException { + Request request = createTestRequest(); + Response customErrorResponse = createMockResponse(500); // Not in default list + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + customErrorResponse, successResponse); + + // Add 500 as retryable + retryOptions.setRetryableStatusCodes(500, 502) + .setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should retry 500 and succeed"); + assertEquals(2, chain.getCallCount(), "Should retry once"); + } + + @Test + @DisplayName("Test empty retryable status codes - no retries") + void testEmptyRetryableStatusCodes() throws IOException { + Request request = createTestRequest(); + Response serviceUnavailableResponse = createMockResponse(503); + + MockChain chain = new MockChain(request); + chain.setResponse(serviceUnavailableResponse); + + // Empty retryable codes + retryOptions.setRetryableStatusCodes(new int[]{}); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(503, result.code(), "Should not retry"); + assertEquals(1, chain.getCallCount(), + "Should only call once (no retryable codes defined)"); + } + + // =========================== + // Backoff Strategy Tests (Timing) + // =========================== + + @Test + @DisplayName("Test FIXED backoff strategy delays are constant") + void testFixedBackoffStrategy() { + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.FIXED) + .setRetryDelay(100L); + interceptor = new RetryInterceptor(retryOptions); + + // Access private calculateDelay method through reflection for testing + // Or we can test indirectly by measuring actual delays + + // For now, just verify the configuration is accepted + assertEquals(RetryOptions.BackoffStrategy.FIXED, + retryOptions.getBackoffStrategy(), + "Backoff strategy should be FIXED"); + } + + @Test + @DisplayName("Test LINEAR backoff strategy") + void testLinearBackoffStrategy() { + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.LINEAR) + .setRetryDelay(100L); + interceptor = new RetryInterceptor(retryOptions); + + assertEquals(RetryOptions.BackoffStrategy.LINEAR, + retryOptions.getBackoffStrategy(), + "Backoff strategy should be LINEAR"); + } + + @Test + @DisplayName("Test EXPONENTIAL backoff strategy") + void testExponentialBackoffStrategy() { + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.EXPONENTIAL) + .setRetryDelay(100L); + interceptor = new RetryInterceptor(retryOptions); + + assertEquals(RetryOptions.BackoffStrategy.EXPONENTIAL, + retryOptions.getBackoffStrategy(), + "Backoff strategy should be EXPONENTIAL"); + } + + // =========================== + // Edge Cases + // =========================== + + @Test + @DisplayName("Test interceptor with null RetryOptions throws NullPointerException") + void testNullRetryOptionsThrows() { + assertThrows(NullPointerException.class, + () -> new RetryInterceptor(null), + "Should throw NullPointerException for null RetryOptions"); + } + + @Test + @DisplayName("Test multiple retries with different status codes") + void testMultipleRetriesWithDifferentStatusCodes() throws IOException { + Request request = createTestRequest(); + + DynamicMockChain chain = new DynamicMockChain(request, + createMockResponse(503), + createMockResponse(429), + createMockResponse(502), + createMockResponse(200)); + + retryOptions.setRetryLimit(5).setRetryDelay(10L); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually succeed"); + assertEquals(4, chain.getCallCount(), + "Should retry until success (3 failures + 1 success)"); + } + + @Test + @DisplayName("Test interceptor is thread-safe for configuration") + void testInterceptorThreadSafety() { + // RetryInterceptor should be immutable after construction + // This is a basic test to ensure configuration doesn't change + RetryOptions options = new RetryOptions() + .setRetryLimit(5) + .setRetryDelay(2000L); + + RetryInterceptor interceptor1 = new RetryInterceptor(options); + RetryInterceptor interceptor2 = new RetryInterceptor(options); + + // Both interceptors should work independently + assertNotSame(interceptor1, interceptor2, + "Different instances should be created"); + } + + // =========================== + // Custom Backoff Strategy Tests + // =========================== + + @Test + @DisplayName("Test custom backoff is called for HTTP error retry") + void testCustomBackoffCalledForHttpError() throws IOException { + Request request = createTestRequest(); + Response failureResponse = createMockResponse(503); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + failureResponse, successResponse); + + // Track if custom backoff was called + final int[] callCount = {0}; + final int[] capturedAttempt = {-1}; + final int[] capturedStatusCode = {-1}; + + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + callCount[0]++; + capturedAttempt[0] = attempt; + capturedStatusCode[0] = statusCode; + return 10L; // Fast retry for test + }); + + interceptor = new RetryInterceptor(retryOptions); + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually succeed"); + assertEquals(1, callCount[0], "Custom backoff should be called once"); + assertEquals(0, capturedAttempt[0], "First retry is attempt 0"); + assertEquals(503, capturedStatusCode[0], "Should capture 503 status code"); + } + + @Test + @DisplayName("Test custom backoff is called for network error retry") + void testCustomBackoffCalledForNetworkError() { + Request request = createTestRequest(); + + final int[] callCount = {0}; + final int[] capturedAttempt = {-1}; + final int[] capturedStatusCode = {-1}; + final IOException[] capturedException = {null}; + + // Chain that throws IOException first, then succeeds + Interceptor.Chain chain = new Interceptor.Chain() { + private int attemptCount = 0; + + @Override + public Request request() { + return request; + } + + @Override + public Response proceed(Request req) throws IOException { + attemptCount++; + if (attemptCount == 1) { + throw new IOException("Network error"); + } + return createMockResponse(200); + } + + @Override + public Connection connection() { return null; } + @Override + public Call call() { return null; } + @Override + public int connectTimeoutMillis() { return 10000; } + @Override + public Interceptor.Chain withConnectTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override + public int readTimeoutMillis() { return 10000; } + @Override + public Interceptor.Chain withReadTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + @Override + public int writeTimeoutMillis() { return 10000; } + @Override + public Interceptor.Chain withWriteTimeout(int timeout, java.util.concurrent.TimeUnit unit) { return this; } + }; + + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + callCount[0]++; + capturedAttempt[0] = attempt; + capturedStatusCode[0] = statusCode; + capturedException[0] = exception; + return 10L; // Fast retry for test + }); + + interceptor = new RetryInterceptor(retryOptions); + + assertDoesNotThrow(() -> { + Response result = interceptor.intercept(chain); + assertEquals(200, result.code(), "Should succeed after retry"); + }); + + assertEquals(1, callCount[0], "Custom backoff should be called once"); + assertEquals(0, capturedAttempt[0], "First retry is attempt 0"); + assertEquals(-1, capturedStatusCode[0], "Network error has statusCode -1"); + assertNotNull(capturedException[0], "Should capture IOException"); + assertTrue(capturedException[0].getMessage().contains("Network error")); + } + + @Test + @DisplayName("Test custom backoff with exponential + jitter pattern") + void testCustomBackoffWithJitter() throws IOException { + Request request = createTestRequest(); + + // Create multiple failures to test jitter across attempts + Response failure503 = createMockResponse(503); + Response success = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + failure503, failure503, failure503, success); + + final java.util.List calculatedDelays = new java.util.ArrayList<>(); + + // Exponential backoff with jitter + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + long exponential = 100L * (long)Math.pow(2, attempt); + long jitter = (long)(Math.random() * 50); + long delay = exponential + jitter; + calculatedDelays.add(delay); + return delay; + }); + + interceptor = new RetryInterceptor(retryOptions); + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should eventually succeed"); + assertEquals(3, calculatedDelays.size(), "Should calculate 3 delays"); + + // Verify delays are increasing (exponential) + assertTrue(calculatedDelays.get(1) > calculatedDelays.get(0), + "Second delay should be greater than first"); + assertTrue(calculatedDelays.get(2) > calculatedDelays.get(1), + "Third delay should be greater than second"); + } + + @Test + @DisplayName("Test custom backoff with rate limit logic") + void testCustomBackoffWithRateLimitLogic() throws IOException { + Request request = createTestRequest(); + Response rateLimitResponse = createMockResponse(429); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + rateLimitResponse, successResponse); + + final int[] capturedStatusCode = {-1}; + + // Custom logic: longer delay for 429 rate limits + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + capturedStatusCode[0] = statusCode; + if (statusCode == 429) { + return 100L; // Longer delay for rate limit + } + return 10L; // Normal delay + }); + + interceptor = new RetryInterceptor(retryOptions); + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should succeed after retry"); + assertEquals(429, capturedStatusCode[0], + "Should detect 429 rate limit status"); + } + + @Test + @DisplayName("Test custom backoff with maximum delay cap") + void testCustomBackoffWithMaxDelayCap() throws IOException { + Request request = createTestRequest(); + Response failure = createMockResponse(503); + Response success = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + failure, failure, failure, success); + + final java.util.List calculatedDelays = new java.util.ArrayList<>(); + final long MAX_DELAY = 500L; + + // Exponential backoff with cap + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + long exponential = 100L * (long)Math.pow(2, attempt); + long cappedDelay = Math.min(exponential, MAX_DELAY); + calculatedDelays.add(cappedDelay); + return cappedDelay; + }); + + interceptor = new RetryInterceptor(retryOptions); + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should succeed"); + assertEquals(3, calculatedDelays.size()); + + // Verify all delays are capped + for (Long delay : calculatedDelays) { + assertTrue(delay <= MAX_DELAY, + "Delay " + delay + " should not exceed max " + MAX_DELAY); + } + } + + @Test + @DisplayName("Test custom backoff takes precedence over built-in strategies") + void testCustomBackoffTakesPrecedence() throws IOException { + Request request = createTestRequest(); + Response failure = createMockResponse(503); + Response success = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, failure, success); + + final long CUSTOM_DELAY = 123L; + final long[] actualDelay = {0L}; + + // Set both EXPONENTIAL and custom - custom should win + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.EXPONENTIAL) + .setRetryDelay(1000L) // Would be 1000ms for exponential + .setCustomBackoffStrategy((attempt, statusCode, exception) -> { + actualDelay[0] = CUSTOM_DELAY; + return CUSTOM_DELAY; + }); + + interceptor = new RetryInterceptor(retryOptions); + + long startTime = System.currentTimeMillis(); + Response result = interceptor.intercept(chain); + long duration = System.currentTimeMillis() - startTime; + + assertEquals(200, result.code()); + assertEquals(CUSTOM_DELAY, actualDelay[0], + "Custom backoff should be called, not exponential"); + assertTrue(duration >= CUSTOM_DELAY, + "Should use custom delay, not exponential"); + } + + @Test + @DisplayName("Test custom backoff can return zero delay") + void testCustomBackoffWithZeroDelay() throws IOException { + Request request = createTestRequest(); + Response failure = createMockResponse(503); + Response success = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, failure, success); + + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> 0L); + interceptor = new RetryInterceptor(retryOptions); + + long startTime = System.currentTimeMillis(); + Response result = interceptor.intercept(chain); + long duration = System.currentTimeMillis() - startTime; + + assertEquals(200, result.code()); + assertTrue(duration < 50L, "Should retry almost immediately with zero delay"); + } + + @Test + @DisplayName("Test custom backoff receives correct attempt numbers") + void testCustomBackoffReceivesCorrectAttempts() throws IOException { + Request request = createTestRequest(); + Response failure = createMockResponse(503); + Response success = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + failure, failure, failure, success); + + final java.util.List capturedAttempts = new java.util.ArrayList<>(); + + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + capturedAttempts.add(attempt); + return 10L; + }); + + interceptor = new RetryInterceptor(retryOptions); + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code()); + assertEquals(java.util.Arrays.asList(0, 1, 2), capturedAttempts, + "Should receive attempts 0, 1, 2 (0-based)"); + } + + @Test + @DisplayName("Test custom backoff is called multiple times for multiple retries") + void testCustomBackoffCalledMultipleTimes() throws IOException { + Request request = createTestRequest(); + Response failure = createMockResponse(503); + Response success = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + failure, failure, failure, success); + + final int[] callCount = {0}; + + retryOptions.setRetryLimit(5) + .setCustomBackoffStrategy((attempt, statusCode, exception) -> { + callCount[0]++; + return 10L; + }); + + interceptor = new RetryInterceptor(retryOptions); + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code()); + assertEquals(3, callCount[0], + "Custom backoff should be called 3 times (for 3 retries)"); + } + + @Test + @DisplayName("Test null custom backoff strategy throws exception") + void testNullCustomBackoffThrows() { + assertThrows(NullPointerException.class, + () -> retryOptions.setCustomBackoffStrategy(null), + "Setting null custom backoff should throw NullPointerException"); + } + + @Test + @DisplayName("Test hasCustomBackoff returns correct status") + void testHasCustomBackoffStatus() { + assertFalse(retryOptions.hasCustomBackoff(), + "Should not have custom backoff initially"); + + retryOptions.setCustomBackoffStrategy((a, s, e) -> 1000L); + + assertTrue(retryOptions.hasCustomBackoff(), + "Should have custom backoff after setting it"); + } +} + diff --git a/src/test/java/com/contentstack/sdk/RetryOptionsTest.java b/src/test/java/com/contentstack/sdk/RetryOptionsTest.java new file mode 100644 index 00000000..5aefca64 --- /dev/null +++ b/src/test/java/com/contentstack/sdk/RetryOptionsTest.java @@ -0,0 +1,490 @@ +package com.contentstack.sdk; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for RetryOptions class. + * Tests configuration, validation, and default values. + */ +class RetryOptionsTest { + + private RetryOptions retryOptions; + + @BeforeEach + void setUp() { + retryOptions = new RetryOptions(); + } + + // =========================== + // Default Values Tests + // =========================== + + @Test + @DisplayName("Test default retry limit is 3") + void testDefaultRetryLimit() { + assertEquals(3, retryOptions.getRetryLimit(), + "Default retry limit should be 3"); + } + + @Test + @DisplayName("Test default retry delay is 1000ms") + void testDefaultRetryDelay() { + assertEquals(1000L, retryOptions.getRetryDelay(), + "Default retry delay should be 1000ms"); + } + + @Test + @DisplayName("Test default backoff strategy is EXPONENTIAL") + void testDefaultBackoffStrategy() { + assertEquals(RetryOptions.BackoffStrategy.EXPONENTIAL, + retryOptions.getBackoffStrategy(), + "Default backoff strategy should be EXPONENTIAL"); + } + + @Test + @DisplayName("Test default retryable status codes") + void testDefaultRetryableStatusCodes() { + int[] expected = {408, 429, 502, 503, 504}; + assertArrayEquals(expected, retryOptions.getRetryableStatusCodes(), + "Default retryable status codes should be 408, 429, 502, 503, 504"); + } + + @Test + @DisplayName("Test retry is enabled by default") + void testRetryEnabledByDefault() { + assertTrue(retryOptions.isRetryEnabled(), + "Retry should be enabled by default"); + } + + // =========================== + // Setter Tests + // =========================== + + @Test + @DisplayName("Test setting valid retry limit") + void testSetValidRetryLimit() { + RetryOptions result = retryOptions.setRetryLimit(5); + + assertEquals(5, retryOptions.getRetryLimit(), + "Retry limit should be set to 5"); + assertSame(retryOptions, result, + "Setter should return same instance for chaining"); + } + + @Test + @DisplayName("Test setting retry limit to 0 (disabled retries)") + void testSetRetryLimitToZero() { + retryOptions.setRetryLimit(0); + assertEquals(0, retryOptions.getRetryLimit(), + "Retry limit can be set to 0"); + } + + @Test + @DisplayName("Test setting retry limit to maximum (10)") + void testSetRetryLimitToMaximum() { + retryOptions.setRetryLimit(10); + assertEquals(10, retryOptions.getRetryLimit(), + "Retry limit should accept maximum value of 10"); + } + + @Test + @DisplayName("Test negative retry limit throws exception") + void testNegativeRetryLimitThrowsException() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> retryOptions.setRetryLimit(-1), + "Negative retry limit should throw exception" + ); + assertTrue(exception.getMessage().contains("cannot be negative"), + "Exception message should mention 'cannot be negative'"); + } + + @Test + @DisplayName("Test retry limit above 10 throws exception") + void testRetryLimitAbove10ThrowsException() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> retryOptions.setRetryLimit(11), + "Retry limit above 10 should throw exception" + ); + assertTrue(exception.getMessage().contains("cannot exceed 10"), + "Exception message should mention 'cannot exceed 10'"); + } + + @Test + @DisplayName("Test setting valid retry delay") + void testSetValidRetryDelay() { + RetryOptions result = retryOptions.setRetryDelay(2000L); + + assertEquals(2000L, retryOptions.getRetryDelay(), + "Retry delay should be set to 2000ms"); + assertSame(retryOptions, result, + "Setter should return same instance for chaining"); + } + + @Test + @DisplayName("Test setting retry delay to 0") + void testSetRetryDelayToZero() { + retryOptions.setRetryDelay(0L); + assertEquals(0L, retryOptions.getRetryDelay(), + "Retry delay can be set to 0"); + } + + @Test + @DisplayName("Test negative retry delay throws exception") + void testNegativeRetryDelayThrowsException() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> retryOptions.setRetryDelay(-100L), + "Negative retry delay should throw exception" + ); + assertTrue(exception.getMessage().contains("cannot be negative"), + "Exception message should mention 'cannot be negative'"); + } + + @Test + @DisplayName("Test setting backoff strategy to FIXED") + void testSetBackoffStrategyFixed() { + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.FIXED); + assertEquals(RetryOptions.BackoffStrategy.FIXED, + retryOptions.getBackoffStrategy(), + "Backoff strategy should be set to FIXED"); + } + + @Test + @DisplayName("Test setting backoff strategy to LINEAR") + void testSetBackoffStrategyLinear() { + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.LINEAR); + assertEquals(RetryOptions.BackoffStrategy.LINEAR, + retryOptions.getBackoffStrategy(), + "Backoff strategy should be set to LINEAR"); + } + + @Test + @DisplayName("Test null backoff strategy throws exception") + void testNullBackoffStrategyThrowsException() { + assertThrows( + NullPointerException.class, + () -> retryOptions.setBackoffStrategy(null), + "Null backoff strategy should throw exception" + ); + } + + @Test + @DisplayName("Test setting custom retryable status codes") + void testSetCustomRetryableStatusCodes() { + int[] customCodes = {500, 502, 503}; + RetryOptions result = retryOptions.setRetryableStatusCodes(customCodes); + + assertArrayEquals(customCodes, retryOptions.getRetryableStatusCodes(), + "Custom retryable status codes should be set"); + assertSame(retryOptions, result, + "Setter should return same instance for chaining"); + } + + @Test + @DisplayName("Test setting empty retryable status codes") + void testSetEmptyRetryableStatusCodes() { + retryOptions.setRetryableStatusCodes(new int[]{}); + assertArrayEquals(new int[]{}, retryOptions.getRetryableStatusCodes(), + "Empty status codes array should be accepted"); + } + + @Test + @DisplayName("Test setting null retryable status codes") + void testSetNullRetryableStatusCodes() { + retryOptions.setRetryableStatusCodes(null); + assertArrayEquals(new int[]{}, retryOptions.getRetryableStatusCodes(), + "Null status codes should result in empty array"); + } + + @Test + @DisplayName("Test getRetryableStatusCodes returns defensive copy") + void testGetRetryableStatusCodesReturnsDefensiveCopy() { + int[] codes = retryOptions.getRetryableStatusCodes(); + int originalFirst = codes[0]; + + // Modify returned array + codes[0] = 999; + + // Original should be unchanged + int[] codesAgain = retryOptions.getRetryableStatusCodes(); + assertEquals(originalFirst, codesAgain[0], + "Modifying returned array should not affect internal state"); + } + + @Test + @DisplayName("Test enabling retry") + void testEnableRetry() { + retryOptions.setRetryEnabled(true); + assertTrue(retryOptions.isRetryEnabled(), + "Retry should be enabled"); + } + + @Test + @DisplayName("Test disabling retry") + void testDisableRetry() { + retryOptions.setRetryEnabled(false); + assertFalse(retryOptions.isRetryEnabled(), + "Retry should be disabled"); + } + + // =========================== + // Fluent API / Method Chaining Tests + // =========================== + + @Test + @DisplayName("Test fluent API method chaining") + void testFluentMethodChaining() { + RetryOptions result = retryOptions + .setRetryLimit(5) + .setRetryDelay(2000L) + .setBackoffStrategy(RetryOptions.BackoffStrategy.LINEAR) + .setRetryableStatusCodes(429, 503) + .setRetryEnabled(true); + + assertSame(retryOptions, result, + "All setters should return same instance"); + assertEquals(5, retryOptions.getRetryLimit()); + assertEquals(2000L, retryOptions.getRetryDelay()); + assertEquals(RetryOptions.BackoffStrategy.LINEAR, retryOptions.getBackoffStrategy()); + assertArrayEquals(new int[]{429, 503}, retryOptions.getRetryableStatusCodes()); + assertTrue(retryOptions.isRetryEnabled()); + } + + // =========================== + // toString() Tests + // =========================== + + @Test + @DisplayName("Test toString contains all configuration") + void testToStringContainsConfiguration() { + String result = retryOptions.toString(); + + assertNotNull(result, "toString should not return null"); + assertTrue(result.contains("enabled="), "toString should contain enabled status"); + assertTrue(result.contains("limit="), "toString should contain limit"); + assertTrue(result.contains("delay="), "toString should contain delay"); + assertTrue(result.contains("strategy="), "toString should contain strategy"); + assertTrue(result.contains("retryableCodes="), "toString should contain retryable codes"); + } + + @Test + @DisplayName("Test toString with disabled retry") + void testToStringWithDisabledRetry() { + retryOptions.setRetryEnabled(false); + String result = retryOptions.toString(); + + assertTrue(result.contains("enabled=false"), + "toString should show retry as disabled"); + } + + // =========================== + // Backoff Strategy Enum Tests + // =========================== + + @Test + @DisplayName("Test BackoffStrategy enum values") + void testBackoffStrategyEnumValues() { + RetryOptions.BackoffStrategy[] strategies = RetryOptions.BackoffStrategy.values(); + + assertEquals(4, strategies.length, + "Should have 4 backoff strategies (FIXED, LINEAR, EXPONENTIAL, CUSTOM)"); + assertTrue(Arrays.asList(strategies).contains(RetryOptions.BackoffStrategy.FIXED)); + assertTrue(Arrays.asList(strategies).contains(RetryOptions.BackoffStrategy.LINEAR)); + assertTrue(Arrays.asList(strategies).contains(RetryOptions.BackoffStrategy.EXPONENTIAL)); + assertTrue(Arrays.asList(strategies).contains(RetryOptions.BackoffStrategy.CUSTOM)); + } + + @Test + @DisplayName("Test BackoffStrategy valueOf") + void testBackoffStrategyValueOf() { + assertEquals(RetryOptions.BackoffStrategy.FIXED, + RetryOptions.BackoffStrategy.valueOf("FIXED")); + assertEquals(RetryOptions.BackoffStrategy.LINEAR, + RetryOptions.BackoffStrategy.valueOf("LINEAR")); + assertEquals(RetryOptions.BackoffStrategy.EXPONENTIAL, + RetryOptions.BackoffStrategy.valueOf("EXPONENTIAL")); + } + + // =========================== + // Edge Cases + // =========================== + + @Test + @DisplayName("Test setting very large delay value") + void testVeryLargeDelayValue() { + long largeDelay = Long.MAX_VALUE; + retryOptions.setRetryDelay(largeDelay); + assertEquals(largeDelay, retryOptions.getRetryDelay(), + "Should accept very large delay values"); + } + + @Test + @DisplayName("Test setting single retryable status code") + void testSingleRetryableStatusCode() { + retryOptions.setRetryableStatusCodes(429); + assertArrayEquals(new int[]{429}, retryOptions.getRetryableStatusCodes(), + "Should accept single status code"); + } + + @Test + @DisplayName("Test multiple instances are independent") + void testMultipleInstancesAreIndependent() { + RetryOptions options1 = new RetryOptions(); + RetryOptions options2 = new RetryOptions(); + + options1.setRetryLimit(5); + options2.setRetryLimit(7); + + assertEquals(5, options1.getRetryLimit(), + "First instance should have its own limit"); + assertEquals(7, options2.getRetryLimit(), + "Second instance should have its own limit"); + } + + // =========================== + // Custom Backoff Strategy Tests + // =========================== + + @Test + @DisplayName("Test setting custom backoff strategy") + void testSetCustomBackoffStrategy() { + CustomBackoffStrategy customStrategy = (attempt, statusCode, exception) -> + 1000L * (attempt + 1); + + RetryOptions result = retryOptions.setCustomBackoffStrategy(customStrategy); + + assertEquals(customStrategy, retryOptions.getCustomBackoffStrategy(), + "Custom backoff strategy should be set"); + assertSame(retryOptions, result, + "Setter should return same instance for chaining"); + } + + @Test + @DisplayName("Test setting custom backoff changes strategy to CUSTOM") + void testCustomBackoffSetsStrategyToCustom() { + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.EXPONENTIAL); + + retryOptions.setCustomBackoffStrategy((a, s, e) -> 1000L); + + assertEquals(RetryOptions.BackoffStrategy.CUSTOM, + retryOptions.getBackoffStrategy(), + "Backoff strategy should be set to CUSTOM"); + } + + @Test + @DisplayName("Test hasCustomBackoff returns false by default") + void testHasCustomBackoffDefaultFalse() { + assertFalse(retryOptions.hasCustomBackoff(), + "Should not have custom backoff by default"); + } + + @Test + @DisplayName("Test hasCustomBackoff returns true after setting") + void testHasCustomBackoffAfterSetting() { + retryOptions.setCustomBackoffStrategy((a, s, e) -> 1000L); + + assertTrue(retryOptions.hasCustomBackoff(), + "Should have custom backoff after setting"); + } + + @Test + @DisplayName("Test null custom backoff strategy throws exception") + void testNullCustomBackoffStrategyThrowsException() { + assertThrows( + NullPointerException.class, + () -> retryOptions.setCustomBackoffStrategy(null), + "Null custom backoff strategy should throw exception" + ); + } + + @Test + @DisplayName("Test getCustomBackoffStrategy returns null by default") + void testGetCustomBackoffStrategyDefaultNull() { + assertNull(retryOptions.getCustomBackoffStrategy(), + "Custom backoff strategy should be null by default"); + } + + @Test + @DisplayName("Test custom backoff strategy in fluent chain") + void testCustomBackoffStrategyInFluentChain() { + CustomBackoffStrategy strategy = (attempt, statusCode, exception) -> 2000L; + + RetryOptions result = retryOptions + .setRetryLimit(5) + .setRetryDelay(1000L) + .setCustomBackoffStrategy(strategy) + .setRetryEnabled(true); + + assertSame(retryOptions, result, + "All setters should return same instance"); + assertEquals(strategy, retryOptions.getCustomBackoffStrategy(), + "Custom strategy should be set"); + assertTrue(retryOptions.hasCustomBackoff(), + "Should have custom backoff"); + } + + @Test + @DisplayName("Test CUSTOM enum value exists in BackoffStrategy") + void testCustomEnumValueExists() { + RetryOptions.BackoffStrategy[] strategies = RetryOptions.BackoffStrategy.values(); + + boolean hasCustom = false; + for (RetryOptions.BackoffStrategy strategy : strategies) { + if (strategy == RetryOptions.BackoffStrategy.CUSTOM) { + hasCustom = true; + break; + } + } + + assertTrue(hasCustom, "BackoffStrategy enum should have CUSTOM value"); + } + + @Test + @DisplayName("Test toString includes custom backoff status") + void testToStringWithCustomBackoff() { + retryOptions.setCustomBackoffStrategy((a, s, e) -> 1000L); + + String result = retryOptions.toString(); + + assertNotNull(result, "toString should not return null"); + assertTrue(result.contains("strategy=CUSTOM"), + "toString should indicate CUSTOM strategy when custom backoff is set"); + } + + @Test + @DisplayName("Test custom backoff strategy can be lambda") + void testCustomBackoffStrategyAsLambda() { + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + // Custom logic + return 1000L * (long)Math.pow(2, attempt); + }); + + assertTrue(retryOptions.hasCustomBackoff(), + "Should accept lambda as custom backoff"); + assertNotNull(retryOptions.getCustomBackoffStrategy(), + "Custom backoff should not be null"); + } + + @Test + @DisplayName("Test custom backoff strategy can be method reference") + void testCustomBackoffStrategyAsMethodReference() { + retryOptions.setCustomBackoffStrategy(this::customBackoffMethod); + + assertTrue(retryOptions.hasCustomBackoff(), + "Should accept method reference as custom backoff"); + } + + // Helper method for method reference test + private long customBackoffMethod(int attempt, int statusCode, java.io.IOException exception) { + return 1000L * attempt; + } +} + From ba967d780bb09e7cf5ab3f5d750c0d615f277637 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 23 Jan 2026 12:00:56 +0530 Subject: [PATCH 2/5] test: Add interruption tests for RetryInterceptor to validate behavior during retries --- .../sdk/RetryInterceptorTest.java | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java b/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java index 92bab52f..f9515d87 100644 --- a/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java +++ b/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java @@ -1022,5 +1022,93 @@ void testHasCustomBackoffStatus() { assertTrue(retryOptions.hasCustomBackoff(), "Should have custom backoff after setting it"); } + + // =========================== + // Interruption Tests + // =========================== + + @Test + @DisplayName("Test thread interruption during HTTP retry sleep") + void testThreadInterruptionDuringHttpRetrySleep() throws IOException { + Request request = createTestRequest(); + Response failureResponse = createMockResponse(503); + + MockChain chain = new MockChain(request); + chain.setResponse(failureResponse); + + // Set a long delay and interrupt the thread + retryOptions.setRetryLimit(3).setRetryDelay(10000L); + interceptor = new RetryInterceptor(retryOptions); + + // Interrupt the thread before calling intercept + Thread testThread = new Thread(() -> { + try { + Thread.sleep(50); // Wait a bit for the retry to start + Thread.currentThread().interrupt(); + } catch (InterruptedException ignored) { + } + }); + testThread.start(); + + // Simulate interruption by using a custom backoff that interrupts + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + Thread.currentThread().interrupt(); + return 10L; + }); + interceptor = new RetryInterceptor(retryOptions); + + IOException thrown = assertThrows(IOException.class, + () -> interceptor.intercept(chain), + "Should throw IOException when interrupted"); + + assertTrue(thrown.getMessage().contains("Retry interrupted"), + "Exception message should indicate interruption"); + assertTrue(Thread.interrupted(), "Thread interrupt flag should be set"); + } + + @Test + @DisplayName("Test thread interruption during IOException retry sleep") + void testThreadInterruptionDuringIOExceptionRetrySleep() { + Request request = createTestRequest(); + MockChain chain = new MockChain(request); + chain.setException(new IOException("Network error")); + + retryOptions.setRetryLimit(3).setRetryDelay(10000L); + + // Use custom backoff to trigger interruption + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + Thread.currentThread().interrupt(); + return 10L; + }); + interceptor = new RetryInterceptor(retryOptions); + + IOException thrown = assertThrows(IOException.class, + () -> interceptor.intercept(chain), + "Should throw IOException when interrupted"); + + assertTrue(thrown.getMessage().contains("Retry interrupted"), + "Exception message should indicate interruption"); + assertTrue(Thread.interrupted(), "Thread interrupt flag should be set"); + } + + @Test + @DisplayName("Test response is closed on interruption") + void testResponseClosedOnInterruption() { + Request request = createTestRequest(); + Response failureResponse = createMockResponse(503); + + MockChain chain = new MockChain(request); + chain.setResponse(failureResponse); + + // Use custom backoff to trigger interruption + retryOptions.setCustomBackoffStrategy((attempt, statusCode, exception) -> { + Thread.currentThread().interrupt(); + return 10L; + }); + interceptor = new RetryInterceptor(retryOptions); + + assertThrows(IOException.class, () -> interceptor.intercept(chain)); + assertTrue(Thread.interrupted(), "Thread should be interrupted"); + } } From f2839e522c487608d0167a0f99bfaeb58f3f7a06 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 23 Jan 2026 13:21:37 +0530 Subject: [PATCH 3/5] test: Enhance RetryInterceptor tests with fixed and linear backoff strategies, including interruption handling and multiple retries --- .../sdk/RetryInterceptorTest.java | 98 +++++++++++++++++-- 1 file changed, 91 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java b/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java index f9515d87..3a0b9f76 100644 --- a/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java +++ b/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java @@ -609,15 +609,23 @@ void testEmptyRetryableStatusCodes() throws IOException { @Test @DisplayName("Test FIXED backoff strategy delays are constant") - void testFixedBackoffStrategy() { + void testFixedBackoffStrategy() throws IOException { + Request request = createTestRequest(); + Response failureResponse = createMockResponse(503); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + failureResponse, failureResponse, successResponse); + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.FIXED) - .setRetryDelay(100L); + .setRetryDelay(10L) + .setRetryLimit(2); interceptor = new RetryInterceptor(retryOptions); - // Access private calculateDelay method through reflection for testing - // Or we can test indirectly by measuring actual delays + Response result = interceptor.intercept(chain); - // For now, just verify the configuration is accepted + assertEquals(200, result.code(), "Should succeed after retries"); + assertEquals(3, chain.getCallCount(), "Should make 3 calls (1 initial + 2 retries)"); assertEquals(RetryOptions.BackoffStrategy.FIXED, retryOptions.getBackoffStrategy(), "Backoff strategy should be FIXED"); @@ -625,11 +633,23 @@ void testFixedBackoffStrategy() { @Test @DisplayName("Test LINEAR backoff strategy") - void testLinearBackoffStrategy() { + void testLinearBackoffStrategy() throws IOException { + Request request = createTestRequest(); + Response failureResponse = createMockResponse(503); + Response successResponse = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, + failureResponse, failureResponse, successResponse); + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.LINEAR) - .setRetryDelay(100L); + .setRetryDelay(10L) + .setRetryLimit(2); interceptor = new RetryInterceptor(retryOptions); + Response result = interceptor.intercept(chain); + + assertEquals(200, result.code(), "Should succeed after retries"); + assertEquals(3, chain.getCallCount(), "Should make 3 calls (1 initial + 2 retries)"); assertEquals(RetryOptions.BackoffStrategy.LINEAR, retryOptions.getBackoffStrategy(), "Backoff strategy should be LINEAR"); @@ -1110,5 +1130,69 @@ void testResponseClosedOnInterruption() { assertThrows(IOException.class, () -> interceptor.intercept(chain)); assertTrue(Thread.interrupted(), "Thread should be interrupted"); } + + @Test + @DisplayName("Test interruption when response is null") + void testInterruptionWithNullResponse() { + Request request = createTestRequest(); + MockChain chain = new MockChain(request); + chain.setException(new IOException("Network error")); + + // Interrupt immediately before any response is received + retryOptions.setRetryLimit(1) + .setCustomBackoffStrategy((attempt, statusCode, exception) -> { + Thread.currentThread().interrupt(); + return 10L; + }); + interceptor = new RetryInterceptor(retryOptions); + + IOException thrown = assertThrows(IOException.class, + () -> interceptor.intercept(chain), + "Should throw IOException when interrupted"); + + assertTrue(thrown.getMessage().contains("Retry interrupted"), + "Exception message should indicate interruption"); + assertTrue(Thread.interrupted(), "Thread interrupt flag should be set"); + } + + @Test + @DisplayName("Test FIXED backoff with multiple retries") + void testFixedBackoffWithMultipleRetries() throws IOException { + Request request = createTestRequest(); + Response failure1 = createMockResponse(503); + Response failure2 = createMockResponse(502); + Response success = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, failure1, failure2, success); + + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.FIXED) + .setRetryDelay(5L) + .setRetryLimit(3); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + assertEquals(200, result.code(), "Should succeed after retries with FIXED backoff"); + assertEquals(3, chain.getCallCount(), "Should make 3 calls"); + } + + @Test + @DisplayName("Test LINEAR backoff with multiple retries") + void testLinearBackoffWithMultipleRetries() throws IOException { + Request request = createTestRequest(); + Response failure1 = createMockResponse(503); + Response failure2 = createMockResponse(502); + Response success = createMockResponse(200); + + DynamicMockChain chain = new DynamicMockChain(request, failure1, failure2, success); + + retryOptions.setBackoffStrategy(RetryOptions.BackoffStrategy.LINEAR) + .setRetryDelay(5L) + .setRetryLimit(3); + interceptor = new RetryInterceptor(retryOptions); + + Response result = interceptor.intercept(chain); + assertEquals(200, result.code(), "Should succeed after retries with LINEAR backoff"); + assertEquals(3, chain.getCallCount(), "Should make 3 calls"); + } } From eb995d92fe03f7dd99e8f8b01c672602adcd3888 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 23 Jan 2026 13:30:25 +0530 Subject: [PATCH 4/5] test: Add validation tests for setRetryableStatusCodes to ensure proper exception handling for invalid HTTP status codes --- .../contentstack/sdk/RetryOptionsTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/test/java/com/contentstack/sdk/RetryOptionsTest.java b/src/test/java/com/contentstack/sdk/RetryOptionsTest.java index 5aefca64..9cdae8f2 100644 --- a/src/test/java/com/contentstack/sdk/RetryOptionsTest.java +++ b/src/test/java/com/contentstack/sdk/RetryOptionsTest.java @@ -262,6 +262,45 @@ void testFluentMethodChaining() { // toString() Tests // =========================== + @Test + @DisplayName("Test setRetryableStatusCodes with code below 100 throws exception") + void testSetRetryableStatusCodesWithCodeBelow100() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> retryOptions.setRetryableStatusCodes(99), + "Should throw exception for status code < 100" + ); + assertTrue(exception.getMessage().contains("Invalid HTTP status code: 99"), + "Exception message should mention invalid code 99"); + assertTrue(exception.getMessage().contains("Must be between 100 and 599"), + "Exception message should mention valid range"); + } + + @Test + @DisplayName("Test setRetryableStatusCodes with code above 599 throws exception") + void testSetRetryableStatusCodesWithCodeAbove599() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> retryOptions.setRetryableStatusCodes(600), + "Should throw exception for status code > 599" + ); + assertTrue(exception.getMessage().contains("Invalid HTTP status code: 600"), + "Exception message should mention invalid code 600"); + assertTrue(exception.getMessage().contains("Must be between 100 and 599"), + "Exception message should mention valid range"); + } + + @Test + @DisplayName("Test setRetryableStatusCodes with mixed valid and invalid codes") + void testSetRetryableStatusCodesWithMixedCodes() { + // Should throw on first invalid code encountered + assertThrows( + IllegalArgumentException.class, + () -> retryOptions.setRetryableStatusCodes(200, 50, 503), + "Should throw exception when encountering invalid code in array" + ); + } + @Test @DisplayName("Test toString contains all configuration") void testToStringContainsConfiguration() { From 6820aa11ca2d1d164570b0ab6394741f03ff02f9 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 23 Jan 2026 13:44:18 +0530 Subject: [PATCH 5/5] test: Add unit tests for RetryInterceptor and RetryOptions to validate retry logic, configuration, and backoff strategies --- .../{RetryInterceptorTest.java => TestRetryInterceptor.java} | 2 +- .../sdk/{RetryOptionsTest.java => TestRetryOptions.java} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/test/java/com/contentstack/sdk/{RetryInterceptorTest.java => TestRetryInterceptor.java} (99%) rename src/test/java/com/contentstack/sdk/{RetryOptionsTest.java => TestRetryOptions.java} (99%) diff --git a/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java b/src/test/java/com/contentstack/sdk/TestRetryInterceptor.java similarity index 99% rename from src/test/java/com/contentstack/sdk/RetryInterceptorTest.java rename to src/test/java/com/contentstack/sdk/TestRetryInterceptor.java index 3a0b9f76..bcab7c3f 100644 --- a/src/test/java/com/contentstack/sdk/RetryInterceptorTest.java +++ b/src/test/java/com/contentstack/sdk/TestRetryInterceptor.java @@ -14,7 +14,7 @@ * Unit tests for RetryInterceptor class. * Tests retry logic, backoff strategies, and error handling. */ -class RetryInterceptorTest { +class TestRetryInterceptor { private RetryOptions retryOptions; private RetryInterceptor interceptor; diff --git a/src/test/java/com/contentstack/sdk/RetryOptionsTest.java b/src/test/java/com/contentstack/sdk/TestRetryOptions.java similarity index 99% rename from src/test/java/com/contentstack/sdk/RetryOptionsTest.java rename to src/test/java/com/contentstack/sdk/TestRetryOptions.java index 9cdae8f2..72cb562e 100644 --- a/src/test/java/com/contentstack/sdk/RetryOptionsTest.java +++ b/src/test/java/com/contentstack/sdk/TestRetryOptions.java @@ -12,7 +12,7 @@ * Unit tests for RetryOptions class. * Tests configuration, validation, and default values. */ -class RetryOptionsTest { +class TestRetryOptions { private RetryOptions retryOptions;