Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ private Response sendRequestWithRetry(
throw new MarkLogicIOException("Request cancelled: thread was interrupted before request could be sent");
}

RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
Response response = null;
int status = -1;

Expand Down Expand Up @@ -1195,7 +1195,7 @@ private TemporalDescriptor putPostDocumentImpl(RequestLogger reqlog, String meth
}
boolean isResendable = handleBase.isResendable();

RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
Response response = null;
int status = -1;
Headers responseHeaders = null;
Expand Down Expand Up @@ -1348,7 +1348,7 @@ private TemporalDescriptor putPostDocumentImpl(RequestLogger reqlog, String meth
requestBldr = addVersionHeader(desc, requestBldr, "If-Match");
}

RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
Response response = null;
int status = -1;
Headers responseHeaders = null;
Expand Down Expand Up @@ -2107,7 +2107,7 @@ void init() {
}

Response getResponse() {
RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, OkHttpServices.this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, OkHttpServices.this::resetFirstRequestFlag);
Response response = null;
int status = -1;
for (; retryContext.shouldContinueRetrying(minRetryAttempts, maxDelayForRetries); retryContext.incrementRetry()) {
Expand Down Expand Up @@ -2635,7 +2635,7 @@ private void putPostValueImpl(RequestLogger reqlog, String method,
String connectPath = null;
Request.Builder requestBldr = null;

RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
Response response = null;
int status = -1;
for (; retryContext.shouldContinueRetrying(minRetryAttempts, maxDelayForRetries); retryContext.incrementRetry()) {
Expand Down Expand Up @@ -3060,7 +3060,7 @@ public <R extends AbstractReadHandle, W extends AbstractWriteHandle> R putResour
String outputMimetype = outputBase.getMimetype();
Class as = outputBase.receiveAs();

RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
Response response = null;
int status = -1;
for (; retryContext.shouldContinueRetrying(minRetryAttempts, maxDelayForRetries); retryContext.incrementRetry()) {
Expand Down Expand Up @@ -3223,7 +3223,7 @@ public <R extends AbstractReadHandle, W extends AbstractWriteHandle> R postResou
String outputMimetype = outputBase != null ? outputBase.getMimetype() : null;
Class as = outputBase != null ? outputBase.receiveAs() : null;

RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
Response response = null;
int status = -1;
for (; retryContext.shouldContinueRetrying(minRetryAttempts, maxDelayForRetries); retryContext.incrementRetry()) {
Expand Down Expand Up @@ -3822,7 +3822,7 @@ private <W extends AbstractWriteHandle, U extends OkHttpResultIterator> U postIt
throws ResourceNotFoundException, ResourceNotResendableException, ForbiddenUserException, FailedRequestException {
if (params == null) params = new RequestParameters();
if (transaction != null) params.add("txid", transaction.getTransactionId());
RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
Response response = null;
int status = -1;
for (; retryContext.shouldContinueRetrying(minRetryAttempts, maxDelayForRetries); retryContext.incrementRetry()) {
Expand Down Expand Up @@ -4928,7 +4928,7 @@ public InputStream match(QueryDefinition queryDef,
}
requestBldr = addTelemetryAgentId(requestBldr);

RetryContext retryContext = new RetryContext(logger, RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
RetryContext retryContext = new RetryContext(RETRYABLE_STATUS_CODES, this::resetFirstRequestFlag);
Response response = null;
int status = -1;
for (; retryContext.shouldContinueRetrying(minRetryAttempts, maxDelayForRetries); retryContext.incrementRetry()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import com.marklogic.client.FailedRetryException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Set;

Expand All @@ -13,7 +14,9 @@
* Tracks retry state, calculates delays, handles sleeping, and logs retry attempts.
*/
class RetryContext {
private final Logger logger;

static final private Logger logger = LoggerFactory.getLogger(RetryContext.class);

private final Set<Integer> retryableStatusCodes;
private final Runnable onMaxRetriesCallback;

Expand All @@ -22,12 +25,10 @@ class RetryContext {
private int nextDelay = 0;

/**
* @param logger Logger for debug output
* @param retryableStatusCodes Set of HTTP status codes that trigger retries
* @param onMaxRetriesCallback Callback to invoke when max retries is exceeded (e.g., to reset first request flag)
*/
RetryContext(Logger logger, Set<Integer> retryableStatusCodes, Runnable onMaxRetriesCallback) {
this.logger = logger;
RetryContext(Set<Integer> retryableStatusCodes, Runnable onMaxRetriesCallback) {
this.retryableStatusCodes = retryableStatusCodes;
this.onMaxRetriesCallback = onMaxRetriesCallback;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* Copyright (c) 2010-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
*/
package com.marklogic.client.impl.okhttp;

import com.marklogic.client.DatabaseClient;
import com.marklogic.client.DatabaseClientFactory;
import com.marklogic.client.FailedRequestException;
import com.marklogic.client.extra.okhttpclient.OkHttpClientConfigurator;
import com.marklogic.client.impl.RESTServices;
import com.marklogic.client.test.Common;
import okhttp3.Interceptor;
import okhttp3.Protocol;
import okhttp3.Request;
import okhttp3.Response;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;

import static org.junit.jupiter.api.Assertions.*;

/**
* Tests the retry logic in OkHttpServices using a custom interceptor that simulates server errors. This is not a
* well-documented feature but users are currently able to discover it via the codebase.
*/
class RetryOn50XResponseTest {

/**
* Custom interceptor that returns 502 Bad Gateway responses and counts how many times it's invoked.
*/
private static class BadGatewayInterceptor implements Interceptor {

private final AtomicInteger invocationCount = new AtomicInteger(0);
private final int failureCount;

/**
* @param failureCount Number of times to return 502 before allowing the request through
*/
public BadGatewayInterceptor(int failureCount) {
this.failureCount = failureCount;
}

@Override
public Response intercept(Chain chain) throws IOException {
int count = invocationCount.incrementAndGet();
Request request = chain.request();

// Fail the first N requests
if (count <= failureCount) {
return new Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(502)
.message("Bad Gateway")
.body(okhttp3.ResponseBody.create("Simulated 502 error", null))
.build();
}

// After N failures, let the request through
return chain.proceed(request);
}

public int getInvocationCount() {
return invocationCount.get();
}

public void reset() {
invocationCount.set(0);
}
}

@BeforeEach
void setUp() {
// Configure very short retry delays for testing
System.setProperty(RESTServices.MAX_DELAY_PROP, "3");
System.setProperty(RESTServices.MIN_RETRY_PROP, "2");

// "Touch" the Common class to trigger the static block that removes existing configurators on
// DatabaseClientFactory.
Common.newServerPayload();
Comment on lines +81 to +83
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Common.newServerPayload() is used here only to force Common's static initializer to run and clear existing configurators. That initializer will only run once per JVM, so subsequent test methods won't get any reset behavior from this call. For better test isolation, consider explicitly calling DatabaseClientFactory.removeConfigurators() (and then adding only the configurators this test needs) instead of relying on a one-time class initialization side effect.

Suggested change
// "Touch" the Common class to trigger the static block that removes existing configurators on
// DatabaseClientFactory.
Common.newServerPayload();
// Explicitly remove any previously registered configurators to ensure test isolation.
DatabaseClientFactory.removeConfigurators();

Copilot uses AI. Check for mistakes.
}
Comment on lines +75 to +84
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test sets system properties com.marklogic.client.retry.maxDelay and com.marklogic.client.retry.minRetry, but OkHttpServices reads retry settings from RESTServices.MAX_DELAY_PROP (com.marklogic.client.maximumRetrySeconds) and RESTServices.MIN_RETRY_PROP (com.marklogic.client.minimumRetries). As-is, the test will run with defaults (up to ~120s retry window / 8 minimum retries), which can make it very slow/flaky and may not validate the intended behavior. Update the test to set/clear the actual property keys used by the retry logic (and keep in mind max delay is in seconds, not ms).

Copilot uses AI. Check for mistakes.

@AfterEach
void tearDown() {
DatabaseClientFactory.removeConfigurators();
System.clearProperty(RESTServices.MAX_DELAY_PROP);
System.clearProperty(RESTServices.MIN_RETRY_PROP);
}

@Test
void testRetryWith502Responses() {
// Create an interceptor that will fail 2 times, then succeed
BadGatewayInterceptor interceptor = new BadGatewayInterceptor(2);

DatabaseClientFactory.addConfigurator((OkHttpClientConfigurator) builder ->
builder.addInterceptor(interceptor));

try (DatabaseClient client = Common.newClient()) {
client.checkConnection();
assertEquals(3, interceptor.getInvocationCount(),
"Expected 3 invocations: 2 failures followed by 1 success");
}
}

@Test
void testRetryExceedsMaxAttempts() {
// Create an interceptor that will fail 10 times (more than minRetry)
BadGatewayInterceptor interceptor = new BadGatewayInterceptor(10);

DatabaseClientFactory.addConfigurator((OkHttpClientConfigurator) builder ->
builder.addInterceptor(interceptor));

try (DatabaseClient client = Common.newClient()) {
assertThrows(FailedRequestException.class, () -> {
client.checkConnection();
}, "Expected FailedRequestException after exhausting retries");

assertTrue(interceptor.getInvocationCount() >= 3,
"Expected at least 3 retry attempts, but got " + interceptor.getInvocationCount());
}
}

@Test
void testRetryCountIncreases() {
// Test that retry attempts increase as expected
BadGatewayInterceptor interceptor = new BadGatewayInterceptor(1);

DatabaseClientFactory.addConfigurator((OkHttpClientConfigurator) builder ->
builder.addInterceptor(interceptor));

try (DatabaseClient client = Common.newClient()) {
// First request: fails once, then succeeds
client.checkConnection();
assertEquals(2, interceptor.getInvocationCount(), "Expected 2 invocations for first request");

// Reset and try again
interceptor.reset();
client.checkConnection();
assertEquals(2, interceptor.getInvocationCount(), "Expected 2 invocations for second request");
}
}

@Test
void testNoRetryOnSuccessfulRequest() {
// Interceptor that never fails
BadGatewayInterceptor interceptor = new BadGatewayInterceptor(0);

DatabaseClientFactory.addConfigurator((OkHttpClientConfigurator) builder ->
builder.addInterceptor(interceptor));

try (DatabaseClient client = Common.newClient()) {
client.checkConnection();
assertEquals(1, interceptor.getInvocationCount(),
"Expected exactly 1 invocation when request succeeds immediately");
}
}
}
4 changes: 4 additions & 0 deletions marklogic-client-api/src/test/resources/logback-test.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@
<appender-ref ref="STDOUT" />
</logger>

<logger name="com.marklogic.client.impl.RetryContext" level="DEBUG" additivity="false">
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a DEBUG logger for RetryContext in the shared logback-test.xml will enable verbose retry logging for the entire test suite. If this is only needed while developing/debugging this new test (and the test does not assert on logs), consider removing it or scoping it to just this test to avoid noisy CI output.

Suggested change
<logger name="com.marklogic.client.impl.RetryContext" level="DEBUG" additivity="false">
<logger name="com.marklogic.client.impl.RetryContext" level="WARN" additivity="false">

Copilot uses AI. Check for mistakes.
<appender-ref ref="STDOUT" />
</logger>

</configuration>