From a09d2202c10c029a393fa62032ad8d9b6a50f89f Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Thu, 18 Jun 2026 18:14:45 +0200 Subject: [PATCH 01/10] rewriting global retry, addressing review --- .../transport/DefaultTransportOptions.java | 30 +- .../transport/ElasticsearchTransportBase.java | 17 +- .../ElasticsearchTransportConfig.java | 15 + .../clients/transport/RetryConfig.java | 117 +++++ .../clients/transport/TransportOptions.java | 22 + .../transport/http/RetryingHttpClient.java | 285 ++++++++++++ .../rest5_client/Rest5ClientOptions.java | 46 +- .../rest_client/RestClientOptions.java | 30 +- .../ElasticsearchTransportConfigTest.java | 29 ++ .../http/RetryingHttpClientTest.java | 429 ++++++++++++++++++ 10 files changed, 1003 insertions(+), 17 deletions(-) create mode 100644 java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java create mode 100644 java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java create mode 100644 java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java diff --git a/java-client/src/main/java/co/elastic/clients/transport/DefaultTransportOptions.java b/java-client/src/main/java/co/elastic/clients/transport/DefaultTransportOptions.java index 9714b863c8..589fcc8baa 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/DefaultTransportOptions.java +++ b/java-client/src/main/java/co/elastic/clients/transport/DefaultTransportOptions.java @@ -38,6 +38,7 @@ public class DefaultTransportOptions implements TransportOptions { private final Map parameters; private final Function, Boolean> onWarnings; private boolean keepResponseBodyOnException; + private RetryConfig retryConfig; public static final DefaultTransportOptions EMPTY = new DefaultTransportOptions(); @@ -51,8 +52,19 @@ public DefaultTransportOptions( @Nullable Function, Boolean> onWarnings, boolean keepResponseBodyOnException ) { - this(headers,parameters,onWarnings); + this(headers, parameters, onWarnings, keepResponseBodyOnException, RetryConfig.disabled()); + } + + public DefaultTransportOptions( + @Nullable HeaderMap headers, + @Nullable Map parameters, + @Nullable Function, Boolean> onWarnings, + boolean keepResponseBodyOnException, + @Nullable RetryConfig retryConfig + ) { + this(headers, parameters, onWarnings); this.keepResponseBodyOnException = keepResponseBodyOnException; + this.retryConfig = retryConfig == null ? RetryConfig.disabled() : retryConfig; } public DefaultTransportOptions( @@ -65,10 +77,11 @@ public DefaultTransportOptions( Collections.emptyMap() : Collections.unmodifiableMap(parameters); this.onWarnings = onWarnings; this.keepResponseBodyOnException = false; + this.retryConfig = RetryConfig.disabled(); } protected DefaultTransportOptions(AbstractBuilder builder) { - this(builder.headers, builder.parameters, builder.onWarnings, builder.keepResponseBodyOnException); + this(builder.headers, builder.parameters, builder.onWarnings, builder.keepResponseBodyOnException, builder.retryConfig); } public static DefaultTransportOptions of(@Nullable TransportOptions options) { @@ -111,6 +124,11 @@ public boolean keepResponseBodyOnException() { return keepResponseBodyOnException; } + @Override + public RetryConfig retryConfig() { + return retryConfig; + } + @Override public Builder toBuilder() { return new Builder(this); @@ -135,6 +153,7 @@ public abstract static class AbstractBuilder parameters; private Function, Boolean> onWarnings; private boolean keepResponseBodyOnException; + private RetryConfig retryConfig = RetryConfig.disabled(); public AbstractBuilder() { } @@ -144,6 +163,7 @@ public AbstractBuilder(DefaultTransportOptions options) { this.parameters = copyOrNull(options.parameters); this.onWarnings = options.onWarnings; this.keepResponseBodyOnException = options.keepResponseBodyOnException; + this.retryConfig = options.retryConfig == null ? RetryConfig.disabled() : options.retryConfig; } protected abstract BuilderT self(); @@ -154,6 +174,12 @@ public BuilderT keepResponseBodyOnException(boolean value) { return self(); } + @Override + public BuilderT retryConfig(RetryConfig config) { + this.retryConfig = config == null ? RetryConfig.disabled() : config; + return self(); + } + @Override public BuilderT addHeader(String name, String value) { if (name.equalsIgnoreCase(HeaderMap.CLIENT_META)) { diff --git a/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java b/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java index 51e4d8ef50..a56b164da2 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java +++ b/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java @@ -32,6 +32,7 @@ import co.elastic.clients.transport.endpoints.TextEndpoint; import co.elastic.clients.transport.http.HeaderMap; import co.elastic.clients.transport.http.RepeatableBodyResponse; +import co.elastic.clients.transport.http.RetryingHttpClient; import co.elastic.clients.transport.http.TransportHttpClient; import co.elastic.clients.transport.instrumentation.Instrumentation; import co.elastic.clients.transport.instrumentation.NoopInstrumentation; @@ -80,6 +81,9 @@ public abstract class ElasticsearchTransportBase implements ElasticsearchTranspo } protected final TransportHttpClient httpClient; + // Same as httpClient if no retry policy, will be an instance of RetryingHttpClient in case + // a retry policy has been configured + private final TransportHttpClient wrappingHttpClient; protected final Instrumentation instrumentation; protected final JsonpMapper mapper; protected final TransportOptions transportOptions; @@ -99,6 +103,13 @@ public ElasticsearchTransportBase( this.httpClient = httpClient; this.transportOptions = httpClient.createOptions(options); + RetryConfig retryConfig = this.transportOptions.retryConfig(); + if (retryConfig != null && retryConfig.backoffPolicy() != BackoffPolicy.noBackoff()) { + this.wrappingHttpClient = new RetryingHttpClient(httpClient, retryConfig); + } else { + this.wrappingHttpClient = httpClient; + } + // If no instrumentation is provided, fallback to OpenTelemetry and ultimately noop if (instrumentation == null) { instrumentation = OpenTelemetryForElasticsearch.getDefault(); @@ -120,7 +131,7 @@ protected ElasticsearchTransportBase cloneWith( @Override public void close() throws IOException { - httpClient.close(); + wrappingHttpClient.close(); } @Override @@ -150,7 +161,7 @@ public final ResponseT performRequest( TransportHttpClient.Request req = prepareTransportRequest(request, endpoint); ctx.beforeSendingHttpRequest(req, options); - TransportHttpClient.Response resp = httpClient.performRequest(endpoint.id(), null, req, opts); + TransportHttpClient.Response resp = wrappingHttpClient.performRequest(endpoint.id(), null, req, opts); ctx.afterReceivingHttpResponse(resp); ResponseT apiResponse = getApiResponse(resp, endpoint); @@ -189,7 +200,7 @@ public final CompletableFuture performR // Propagate required property checks to the thread that will decode the response boolean disableRequiredChecks = ApiTypeHelper.requiredPropertiesCheckDisabled(); - CompletableFuture clientFuture = httpClient.performRequestAsync( + CompletableFuture clientFuture = wrappingHttpClient.performRequestAsync( endpoint.id(), null, clientReq, opts ); diff --git a/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportConfig.java b/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportConfig.java index 2d7b9ccb38..07d60de922 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportConfig.java +++ b/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportConfig.java @@ -30,6 +30,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.util.List; +import java.util.function.Consumer; import java.util.function.Function; public abstract class ElasticsearchTransportConfig { @@ -326,6 +327,20 @@ public BuilderT transportOptions(Function o.retryConfig(retryConfig)); + } + + /** + * Configure transport-level retries with a builder lambda. + */ + public BuilderT retryConfig(Consumer fn) { + return transportOptions(o -> o.retryConfig(fn)); + } + protected void checkNull(Object value, String name, String other) { if (value != null) { throw new IllegalArgumentException("Cannot set both " + other + " and " + name + "."); diff --git a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java new file mode 100644 index 0000000000..c16060c76f --- /dev/null +++ b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java @@ -0,0 +1,117 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package co.elastic.clients.transport; + +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Configuration for transport-level retries: which {@link BackoffPolicy} to apply between attempts and which HTTP + * status codes should trigger a retry. {@link java.io.IOException}s thrown by the underlying transport are always + * retried (independently of {@link #retryableStatuses()}). + */ +public final class RetryConfig { + + /** + * Default set of HTTP status codes that trigger a retry: {@code 429, 500, 502, 503, 504}. + */ + public static final Set DEFAULT_RETRYABLE_STATUSES = Set.of(429, 500, 502, 503, 504); + + private static final RetryConfig DISABLED = new RetryConfig(BackoffPolicy.noBackoff(), DEFAULT_RETRYABLE_STATUSES); + + private final BackoffPolicy backoffPolicy; + private final Set retryableStatuses; + + private RetryConfig(BackoffPolicy backoffPolicy, Set retryableStatuses) { + this.backoffPolicy = Objects.requireNonNull(backoffPolicy, "backoffPolicy"); + this.retryableStatuses = retryableStatuses; + } + + private RetryConfig(Builder builder) { + this(builder.backoffPolicy, builder.retryableStatuses); + } + + /** + * Disabled retry configuration — equivalent to {@link BackoffPolicy#noBackoff()} with the default status set. + * No retries will happen. + */ + public static RetryConfig disabled() { + return DISABLED; + } + + public static Builder builder() { + return new Builder(); + } + + public static RetryConfig of(Consumer fn) { + Builder b = new Builder(); + fn.accept(b); + return b.build(); + } + + /** + * The backoff policy controlling delays and the maximum number of retry attempts. {@link BackoffPolicy#noBackoff()} + * means no retries are performed. + */ + public BackoffPolicy backoffPolicy() { + return backoffPolicy; + } + + /** + * The HTTP status codes that should trigger a retry. Defaults to {@link #DEFAULT_RETRYABLE_STATUSES}. + */ + public Set retryableStatuses() { + return retryableStatuses; + } + + + public Builder toBuilder() { + return new Builder() + .backoffPolicy(backoffPolicy) + .retryableStatuses(retryableStatuses); + } + + public static final class Builder { + private BackoffPolicy backoffPolicy = BackoffPolicy.noBackoff(); + private Set retryableStatuses = DEFAULT_RETRYABLE_STATUSES; + + public Builder backoffPolicy(BackoffPolicy policy) { + this.backoffPolicy = policy == null ? BackoffPolicy.noBackoff() : policy; + return this; + } + + public Builder retryableStatuses(Set statuses) { + if (statuses == null || statuses.isEmpty()) { + throw new IllegalArgumentException("retryableStatuses must not be null or empty"); + } + this.retryableStatuses = Set.copyOf(statuses); + return this; + } + + public Builder retryableStatuses(Integer... statuses) { + return retryableStatuses(Set.of(statuses)); + } + + public RetryConfig build() { + return new RetryConfig(this); + } + } +} diff --git a/java-client/src/main/java/co/elastic/clients/transport/TransportOptions.java b/java-client/src/main/java/co/elastic/clients/transport/TransportOptions.java index f5603b9b3d..8fab860828 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/TransportOptions.java +++ b/java-client/src/main/java/co/elastic/clients/transport/TransportOptions.java @@ -47,6 +47,14 @@ public interface TransportOptions { */ boolean keepResponseBodyOnException(); + /** + * Configuration for transport-level retries (I/O exceptions and configurable HTTP status codes). + * Defaults to {@link RetryConfig#disabled()}, i.e. no retries. + */ + default RetryConfig retryConfig() { + return RetryConfig.disabled(); + } + Builder toBuilder(); default TransportOptions with(Consumer fn) { @@ -75,5 +83,19 @@ interface Builder extends ObjectBuilder { * streamed by the http library. */ Builder keepResponseBodyOnException(boolean value); + + /** + * Set the configuration for transport-level retries. + */ + default Builder retryConfig(RetryConfig config) { + throw new UnsupportedOperationException("This implementation does not support setting a retry configuration"); + } + + /** + * Convenience overload to configure transport-level retries with a builder lambda. + */ + default Builder retryConfig(Consumer fn) { + return retryConfig(RetryConfig.of(fn)); + } } } diff --git a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java new file mode 100644 index 0000000000..83630175f1 --- /dev/null +++ b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java @@ -0,0 +1,285 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package co.elastic.clients.transport.http; + +import co.elastic.clients.transport.BackoffPolicy; +import co.elastic.clients.transport.RetryConfig; +import co.elastic.clients.transport.TransportOptions; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.Iterator; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A {@link TransportHttpClient} wrapper that retries failed requests according to a {@link RetryConfig}. + *

+ * A request is retried when: + *

    + *
  • the delegate throws (or completes its future exceptionally with) an {@link IOException}, or
  • + *
  • the delegate returns a response whose status code is in {@link RetryConfig#retryableStatuses()}.
  • + *
+ *

+ * Notes: + *

    + *
  • Retries assume the underlying request is safe to repeat. Most Elasticsearch APIs are idempotent at the user level, + * but users using custom non-idempotent operations should think twice before enabling retries.
  • + *
  • The async path uses a {@link ScheduledExecutorService} to defer retries without blocking the calling thread pool. + * A single-thread daemon scheduler is created by default and is shut down by {@link #close()}; users can supply + * their own scheduler via {@link #RetryingHttpClient(TransportHttpClient, RetryConfig, ScheduledExecutorService)} + * in which case its lifecycle is the user's responsibility.
  • + *
+ */ +public final class RetryingHttpClient implements TransportHttpClient { + + // Instance counter, to name the retry thread + private static final AtomicInteger idCounter = new AtomicInteger(); + + private final TransportHttpClient delegate; + private final BackoffPolicy backoffPolicy; + private final Set retryableStatuses; + private final ScheduledExecutorService retryScheduler; + private final boolean isExternalScheduler; + + public RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig) { + this(delegate, retryConfig, defaultRetryScheduler(), false); + } + + /** + * Build a retrying client using a user-provided scheduler. The scheduler won't be shut down + * by {@link #close()}. + */ + public RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig, ScheduledExecutorService scheduler) { + this(delegate, retryConfig, scheduler, true); + } + + private RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig, + ScheduledExecutorService scheduler, boolean isExternalScheduler) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + RetryConfig cfg = retryConfig == null ? RetryConfig.disabled() : retryConfig; + this.backoffPolicy = cfg.backoffPolicy(); + this.retryableStatuses = cfg.retryableStatuses(); + this.retryScheduler = Objects.requireNonNull(scheduler, "scheduler"); + this.isExternalScheduler = isExternalScheduler; + } + + @Override + public TransportOptions createOptions(@Nullable TransportOptions options) { + return delegate.createOptions(options); + } + + @Override + public Response performRequest(String endpointId, @Nullable Node node, Request request, + TransportOptions options) throws IOException { + try { + return performRequestAsync(endpointId, node, request, options).get(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + InterruptedIOException io = new InterruptedIOException("Retry was interrupted"); + io.initCause(ie); + throw io; + } catch (ExecutionException ee) { + Throwable cause = ee.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw new IOException(cause); + } + } + + @Override + public CompletableFuture performRequestAsync(String endpointId, @Nullable Node node, Request request, + TransportOptions options) { + RetryFuture result = new RetryFuture(); + attemptAsync(endpointId, node, request, options, backoffPolicy.iterator(), result); + return result; + } + + private void attemptAsync(String endpointId, @Nullable Node node, Request request, TransportOptions options, + Iterator backoffIter, RetryFuture result) { + if (result.isDone()) { + return; + } + + CompletableFuture attempt; + try { + attempt = delegate.performRequestAsync(endpointId, node, request, options); + } catch (Throwable t) { + attempt = new CompletableFuture<>(); + attempt.completeExceptionally(t); + } + result.setInFlight(attempt); + + attempt.whenComplete((resp, err) -> { + if (result.isCancelled()) { + if (resp != null) { + closeQuietly(resp); + } + return; + } + result.clearInFlight(); + + Throwable cause = unwrap(err); + + long delayMs; + if (err != null) { + if (!isRetryable(cause) || !backoffIter.hasNext()) { + result.completeExceptionally(cause); + return; + } + delayMs = backoffIter.next(); + } else if (isRetryable(resp)) { + if (!backoffIter.hasNext()) { + result.complete(resp); + return; + } + delayMs = backoffIter.next(); + closeQuietly(resp); + } else { + result.complete(resp); + return; + } + + if (delayMs <= 0) { + attemptAsync(endpointId, node, request, options, backoffIter, result); + return; + } + try { + ScheduledFuture scheduled = retryScheduler.schedule( + () -> attemptAsync(endpointId, node, request, options, backoffIter, result), + delayMs, TimeUnit.MILLISECONDS + ); + result.setScheduledRetry(scheduled); + } catch (RejectedExecutionException rex) { + result.completeExceptionally(cause != null ? cause : rex); + } + }); + } + + @Override + public void close() throws IOException { + try { + delegate.close(); + } finally { + if (!isExternalScheduler) { + retryScheduler.shutdownNow(); + } + } + } + + static boolean isRetryable(Throwable err) { + Throwable t = err; + while (t != null) { + if (t instanceof IOException) { + return true; + } + t = t.getCause() == t ? null : t.getCause(); + } + return false; + } + + boolean isRetryable(Response resp) { + return retryableStatuses.contains(resp.statusCode()); + } + + private static Throwable unwrap(Throwable t) { + while (t instanceof CompletionException && t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + return t; + } + + private static void closeQuietly(Response resp) { + try { + resp.close(); + } catch (IOException ignored) { + // best effort + } + } + + private static ScheduledExecutorService defaultRetryScheduler() { + int clientId = idCounter.incrementAndGet(); + return Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = Executors.defaultThreadFactory().newThread(r); + t.setName("elasticsearch-java-retry#" + clientId + "#" + t.getId()); + t.setDaemon(true); + return t; + }); + } + + /** + * Result future that propagates cancellation to both the in-flight delegate request and any pending scheduled retry. + */ + private static final class RetryFuture extends CompletableFuture { + private volatile CompletableFuture inFlight; + private volatile ScheduledFuture scheduledRetry; + + void setInFlight(CompletableFuture f) { + this.inFlight = f; + if (isCancelled()) { + f.cancel(true); + } + } + + void clearInFlight() { + this.inFlight = null; + } + + void setScheduledRetry(ScheduledFuture sf) { + this.scheduledRetry = sf; + if (isCancelled()) { + sf.cancel(false); + } + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + boolean cancelled = super.cancel(mayInterruptIfRunning); + if (cancelled) { + CompletableFuture f = inFlight; + if (f != null) { + f.cancel(mayInterruptIfRunning); + } + ScheduledFuture sf = scheduledRetry; + if (sf != null) { + sf.cancel(false); + } + } + return cancelled; + } + } +} diff --git a/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientOptions.java b/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientOptions.java index 2520b16bb7..3e326fd206 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientOptions.java +++ b/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientOptions.java @@ -19,6 +19,7 @@ package co.elastic.clients.transport.rest5_client; +import co.elastic.clients.transport.RetryConfig; import co.elastic.clients.transport.TransportOptions; import co.elastic.clients.transport.Version; import co.elastic.clients.transport.http.HeaderMap; @@ -30,11 +31,7 @@ import org.apache.hc.core5.util.VersionInfo; import javax.annotation.Nullable; -import java.util.AbstractMap; -import java.util.Collection; -import java.util.List; -import java.util.Locale; -import java.util.Map; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -44,6 +41,8 @@ public class Rest5ClientOptions implements TransportOptions { boolean keepResponseBodyOnException; + private final RetryConfig retryConfig; + @VisibleForTesting static final String CLIENT_META_VALUE = getClientMeta(); @VisibleForTesting @@ -55,18 +54,26 @@ public static Rest5ClientOptions of(@Nullable TransportOptions options) { } if (options instanceof Rest5ClientOptions) { - return (Rest5ClientOptions)options; + return (Rest5ClientOptions) options; } final Builder builder = new Builder(RequestOptions.DEFAULT.toBuilder()); options.headers().forEach(h -> builder.addHeader(h.getKey(), h.getValue())); options.queryParameters().forEach(builder::setParameter); builder.onWarnings(options.onWarnings()); + builder.keepResponseBodyOnException(options.keepResponseBodyOnException()); + builder.retryConfig(options.retryConfig()); return builder.build(); } public Rest5ClientOptions(RequestOptions options, boolean keepResponseBodyOnException) { + this(options, keepResponseBodyOnException, RetryConfig.disabled()); + } + + public Rest5ClientOptions(RequestOptions options, boolean keepResponseBodyOnException, + RetryConfig retryConfig) { this.keepResponseBodyOnException = keepResponseBodyOnException; + this.retryConfig = retryConfig == null ? RetryConfig.disabled() : retryConfig; this.options = addBuiltinHeaders(options.toBuilder()).build(); } @@ -112,9 +119,17 @@ public boolean keepResponseBodyOnException() { return this.keepResponseBodyOnException; } + @Override + public RetryConfig retryConfig() { + return retryConfig; + } + @Override public Builder toBuilder() { - return new Builder(options.toBuilder()); + Builder b = new Builder(options.toBuilder()); + b.keepResponseBodyOnException(keepResponseBodyOnException); + b.retryConfig(retryConfig); + return b; } public static class Builder implements TransportOptions.Builder { @@ -123,6 +138,8 @@ public static class Builder implements TransportOptions.Builder { private boolean keepResponseBodyOnException; + private RetryConfig retryConfig = RetryConfig.disabled(); + public Builder(RequestOptions.Builder builder) { this.builder = builder; } @@ -141,7 +158,8 @@ public TransportOptions.Builder addHeader(String name, String value) { return this; } if (name.equalsIgnoreCase(HeaderMap.USER_AGENT)) { - // We must remove our own user-agent from the options, or we'll end up with multiple values for the header + // We must remove our own user-agent from the options, or we'll end up with multiple values + // for the header builder.removeHeader(HeaderMap.USER_AGENT); } builder.addHeader(name, value); @@ -173,7 +191,8 @@ public TransportOptions.Builder setParameter(String name, String value) { @Override public TransportOptions.Builder removeParameter(String name) { - throw new UnsupportedOperationException("This implementation does not support removing parameters"); + throw new UnsupportedOperationException("This implementation does not support removing " + + "parameters"); } /** @@ -202,9 +221,16 @@ public TransportOptions.Builder keepResponseBodyOnException(boolean value) { return this; } + @Override + public TransportOptions.Builder retryConfig(RetryConfig config) { + this.retryConfig = config == null ? RetryConfig.disabled() : config; + return this; + } + @Override public Rest5ClientOptions build() { - return new Rest5ClientOptions(addBuiltinHeaders(builder).build(), keepResponseBodyOnException); + return new Rest5ClientOptions(addBuiltinHeaders(builder).build(), keepResponseBodyOnException, + retryConfig); } } diff --git a/java-client/src/main/java/co/elastic/clients/transport/rest_client/RestClientOptions.java b/java-client/src/main/java/co/elastic/clients/transport/rest_client/RestClientOptions.java index de1658e26a..431a9cbd44 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/rest_client/RestClientOptions.java +++ b/java-client/src/main/java/co/elastic/clients/transport/rest_client/RestClientOptions.java @@ -19,6 +19,7 @@ package co.elastic.clients.transport.rest_client; +import co.elastic.clients.transport.RetryConfig; import co.elastic.clients.transport.TransportOptions; import co.elastic.clients.transport.Version; import co.elastic.clients.transport.http.HeaderMap; @@ -44,6 +45,8 @@ public class RestClientOptions implements TransportOptions { boolean keepResponseBodyOnException; + private final RetryConfig retryConfig; + @VisibleForTesting static final String CLIENT_META_VALUE = getClientMeta(); @VisibleForTesting @@ -62,11 +65,18 @@ public static RestClientOptions of(@Nullable TransportOptions options) { options.headers().forEach(h -> builder.addHeader(h.getKey(), h.getValue())); options.queryParameters().forEach(builder::setParameter); builder.onWarnings(options.onWarnings()); + builder.keepResponseBodyOnException(options.keepResponseBodyOnException()); + builder.retryConfig(options.retryConfig()); return builder.build(); } public RestClientOptions(RequestOptions options, boolean keepResponseBodyOnException) { + this(options, keepResponseBodyOnException, RetryConfig.disabled()); + } + + public RestClientOptions(RequestOptions options, boolean keepResponseBodyOnException, RetryConfig retryConfig) { this.keepResponseBodyOnException = keepResponseBodyOnException; + this.retryConfig = retryConfig == null ? RetryConfig.disabled() : retryConfig; this.options = addBuiltinHeaders(options.toBuilder()).build(); } @@ -112,9 +122,17 @@ public boolean keepResponseBodyOnException() { return this.keepResponseBodyOnException; } + @Override + public RetryConfig retryConfig() { + return retryConfig; + } + @Override public Builder toBuilder() { - return new Builder(options.toBuilder()); + Builder b = new Builder(options.toBuilder()); + b.keepResponseBodyOnException(keepResponseBodyOnException); + b.retryConfig(retryConfig); + return b; } public static class Builder implements TransportOptions.Builder { @@ -123,6 +141,8 @@ public static class Builder implements TransportOptions.Builder { private boolean keepResponseBodyOnException; + private RetryConfig retryConfig = RetryConfig.disabled(); + public Builder(RequestOptions.Builder builder) { this.builder = builder; } @@ -202,9 +222,15 @@ public TransportOptions.Builder keepResponseBodyOnException(boolean value) { return this; } + @Override + public TransportOptions.Builder retryConfig(RetryConfig config) { + this.retryConfig = config == null ? RetryConfig.disabled() : config; + return this; + } + @Override public RestClientOptions build() { - return new RestClientOptions(addBuiltinHeaders(builder).build(), keepResponseBodyOnException); + return new RestClientOptions(addBuiltinHeaders(builder).build(), keepResponseBodyOnException, retryConfig); } } diff --git a/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportConfigTest.java b/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportConfigTest.java index d4a3f48e0f..13ece3fd2d 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportConfigTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportConfigTest.java @@ -130,6 +130,35 @@ public void credentialCombinations() { ); } + @Test + public void retryConfigShortcut() { + // Top-level shortcut on the main builder + ElasticsearchClient client = ElasticsearchClient.of(b -> b + .host("http://example.com") + .retryConfig(r -> r + .backoffPolicy(BackoffPolicy.constantBackoff(50L, 3)) + .retryableStatuses(429, 503)) + ); + + RetryConfig cfg = client._transportOptions().retryConfig(); + assertNotEquals(BackoffPolicy.noBackoff(), cfg.backoffPolicy()); + assertEquals(BackoffPolicy.constantBackoff(50L, 3).toString(), cfg.backoffPolicy().toString()); + assertEquals(java.util.Set.of(429, 503), cfg.retryableStatuses()); + } + + @Test + public void retryConfigShortcutComposesWithTransportOptions() { + // Mix the top-level shortcut with other transportOptions calls + ElasticsearchClient client = ElasticsearchClient.of(b -> b + .host("http://example.com") + .transportOptions(o -> o.keepResponseBodyOnException(true)) + .retryConfig(r -> r.backoffPolicy(BackoffPolicy.constantBackoff(10L, 2))) + ); + + assertTrue(client._transportOptions().keepResponseBodyOnException()); + assertNotEquals(BackoffPolicy.noBackoff(), client._transportOptions().retryConfig().backoffPolicy()); + } + @Test public void checkDefaultConfig() throws Exception { ElasticsearchTransportConfig.Default config = new ElasticsearchTransportConfig.Builder() diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java new file mode 100644 index 0000000000..be225adec3 --- /dev/null +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java @@ -0,0 +1,429 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package co.elastic.clients.transport.http; + +import co.elastic.clients.transport.BackoffPolicy; +import co.elastic.clients.transport.DefaultTransportOptions; +import co.elastic.clients.transport.RetryConfig; +import co.elastic.clients.transport.TransportOptions; +import co.elastic.clients.util.BinaryData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +class RetryingHttpClientTest extends Assertions { + + private ScheduledExecutorService scheduler; + + @BeforeEach + void setUp() { + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "retry-test-scheduler"); + t.setDaemon(true); + return t; + }); + } + + @AfterEach + void tearDown() { + scheduler.shutdownNow(); + } + + // ---------- helpers ---------- + + private static final TransportOptions OPTS = DefaultTransportOptions.EMPTY; + private static final TransportHttpClient.Request REQ = new TransportHttpClient.Request( + "GET", "/", Collections.emptyMap(), Collections.emptyMap(), null + ); + + private RetryingHttpClient wrap(TransportHttpClient delegate, RetryConfig config) { + return new RetryingHttpClient(delegate, config, scheduler); + } + + private RetryingHttpClient wrap(TransportHttpClient delegate, BackoffPolicy policy) { + return wrap(delegate, RetryConfig.of(r -> r.backoffPolicy(policy))); + } + + private static BackoffPolicy fixed(long ms, int retries) { + return BackoffPolicy.constantBackoff(ms, retries); + } + + /** A scripted fake transport that returns the next scripted outcome on each call. */ + static class ScriptedClient implements TransportHttpClient { + final List> script = new ArrayList<>(); + final AtomicInteger calls = new AtomicInteger(); + + /** Add a response outcome. */ + ScriptedClient andRespond(int statusCode) { + script.add(b -> new FakeResponse(statusCode, Collections.emptyMap())); + return this; + } + + ScriptedClient andThrow(Throwable t) { + script.add(b -> { throw new ScriptedException(t); }); + return this; + } + + @Override + public Response performRequest(String endpointId, @Nullable Node node, Request request, + TransportOptions options) throws IOException { + int i = calls.getAndIncrement(); + Function step = script.get(Math.min(i, script.size() - 1)); + try { + Object out = step.apply(true); + return (Response) out; + } catch (ScriptedException se) { + if (se.cause instanceof IOException) throw (IOException) se.cause; + if (se.cause instanceof RuntimeException) throw (RuntimeException) se.cause; + throw new RuntimeException(se.cause); + } + } + + @Override + public CompletableFuture performRequestAsync(String endpointId, @Nullable Node node, Request request, + TransportOptions options) { + int i = calls.getAndIncrement(); + Function step = script.get(Math.min(i, script.size() - 1)); + CompletableFuture f = new CompletableFuture<>(); + try { + Object out = step.apply(true); + f.complete((Response) out); + } catch (ScriptedException se) { + f.completeExceptionally(se.cause); + } + return f; + } + + @Override + public void close() {} + } + + static final class ScriptedException extends RuntimeException { + final Throwable cause; + ScriptedException(Throwable c) { this.cause = c; } + } + + static final class FakeResponse implements TransportHttpClient.Response { + private final int status; + private final java.util.Map headers; + boolean closed; + + FakeResponse(int status, java.util.Map headers) { + this.status = status; + this.headers = headers; + } + @Override public TransportHttpClient.Node node() { return new TransportHttpClient.Node("http://localhost/"); } + @Override public int statusCode() { return status; } + @Override public String header(String name) { return headers.get(name); } + @Override public List headers(String name) { + String v = headers.get(name); + return v == null ? Collections.emptyList() : Collections.singletonList(v); + } + @Override public BinaryData body() { return null; } + @Override public Object originalResponse() { return this; } + @Override public void close() { closed = true; } + } + + // ---------- sync tests ---------- + + @Test + void syncRetriesOn5xxThenSucceeds() throws IOException { + ScriptedClient client = new ScriptedClient() + .andRespond(503).andRespond(502).andRespond(500).andRespond(200); + RetryingHttpClient retry = wrap(client, fixed(1L, 5)); + + TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + + assertEquals(200, resp.statusCode()); + assertEquals(4, client.calls.get()); + } + + @Test + void syncRetriesOn429() throws IOException { + ScriptedClient client = new ScriptedClient().andRespond(429).andRespond(200); + RetryingHttpClient retry = wrap(client, fixed(1L, 3)); + + TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + + assertEquals(200, resp.statusCode()); + assertEquals(2, client.calls.get()); + } + + @Test + void syncDoesNotRetryOn501() throws IOException { + ScriptedClient client = new ScriptedClient().andRespond(501).andRespond(200); + RetryingHttpClient retry = wrap(client, fixed(1L, 3)); + + TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + + assertEquals(501, resp.statusCode()); + assertEquals(1, client.calls.get()); + } + + @Test + void customRetryableStatusesOnly() throws IOException { + // Configure to retry only on 418 — a 503 should pass through + RetryConfig cfg = RetryConfig.of(r -> r + .backoffPolicy(fixed(1L, 3)) + .retryableStatuses(418)); + + ScriptedClient client = new ScriptedClient().andRespond(503).andRespond(200); + TransportHttpClient.Response resp = wrap(client, cfg).performRequest("ep", null, REQ, OPTS); + + assertEquals(503, resp.statusCode()); + assertEquals(1, client.calls.get()); + } + + @Test + void customRetryableStatusesIncludingNonStandard() throws IOException { + // Configure to retry on 408 (Request Timeout) — not in the default set + RetryConfig cfg = RetryConfig.of(r -> r + .backoffPolicy(fixed(1L, 3)) + .retryableStatuses(408, 503)); + + ScriptedClient client = new ScriptedClient().andRespond(408).andRespond(200); + TransportHttpClient.Response resp = wrap(client, cfg).performRequest("ep", null, REQ, OPTS); + + assertEquals(200, resp.statusCode()); + assertEquals(2, client.calls.get()); + } + + @Test + void retryConfigBuilderRejectsEmptyStatuses() { + assertThrows(IllegalArgumentException.class, + () -> RetryConfig.of(r -> r.retryableStatuses(new HashSet<>()))); + } + + @Test + void retryConfigDefensivelyCopiesStatusSet() { + HashSet mutable = new HashSet<>(); + mutable.add(429); + mutable.add(503); + RetryConfig cfg = RetryConfig.of(r -> r.retryableStatuses(mutable)); + + // Mutating the original set after building must not affect the config. + mutable.add(418); + assertFalse(cfg.retryableStatuses().contains(418)); + + // The returned set itself must be immutable. + assertThrows(UnsupportedOperationException.class, + () -> cfg.retryableStatuses().add(999)); + } + + @Test + void syncDoesNotRetryOn4xx() throws IOException { + ScriptedClient client = new ScriptedClient().andRespond(404).andRespond(200); + RetryingHttpClient retry = wrap(client, fixed(1L, 3)); + + TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + + assertEquals(404, resp.statusCode()); + assertEquals(1, client.calls.get()); + } + + @Test + void syncRetriesOnIOExceptionButNotOnRuntimeException() { + ScriptedClient client1 = new ScriptedClient() + .andThrow(new SocketException("boom")).andRespond(200); + try { + TransportHttpClient.Response r = wrap(client1, fixed(1L, 3)).performRequest("ep", null, REQ, OPTS); + assertEquals(200, r.statusCode()); + assertEquals(2, client1.calls.get()); + } catch (IOException e) { + fail("did not expect exception", e); + } + + ScriptedClient client2 = new ScriptedClient() + .andThrow(new IllegalStateException("bad")).andRespond(200); + assertThrows(IllegalStateException.class, + () -> wrap(client2, fixed(1L, 3)).performRequest("ep", null, REQ, OPTS)); + assertEquals(1, client2.calls.get()); + } + + @Test + void syncReturnsLastResponseWhenRetriesExhausted() throws IOException { + ScriptedClient client = new ScriptedClient() + .andRespond(503).andRespond(503).andRespond(503); + RetryingHttpClient retry = wrap(client, fixed(1L, 2)); + + TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + + assertEquals(503, resp.statusCode()); + assertEquals(3, client.calls.get()); + } + + @Test + void syncThrowsLastIOExceptionWhenRetriesExhausted() { + ScriptedClient client = new ScriptedClient() + .andThrow(new SocketException("first")) + .andThrow(new SocketException("second")) + .andThrow(new SocketException("third")); + RetryingHttpClient retry = wrap(client, fixed(1L, 2)); + + IOException e = assertThrows(IOException.class, + () -> retry.performRequest("ep", null, REQ, OPTS)); + assertEquals("third", e.getMessage()); + assertEquals(3, client.calls.get()); + } + + // ---------- async tests ---------- + + @Test + void asyncRetriesOn5xxThenSucceeds() throws Exception { + ScriptedClient client = new ScriptedClient() + .andRespond(503).andRespond(502).andRespond(200); + RetryingHttpClient retry = wrap(client, fixed(1L, 5)); + + TransportHttpClient.Response resp = retry.performRequestAsync("ep", null, REQ, OPTS) + .get(5, TimeUnit.SECONDS); + + assertEquals(200, resp.statusCode()); + assertEquals(3, client.calls.get()); + } + + @Test + void asyncRetriesOnIOExceptionThenSucceeds() throws Exception { + ScriptedClient client = new ScriptedClient() + .andThrow(new SocketException("nope")).andRespond(200); + RetryingHttpClient retry = wrap(client, fixed(1L, 3)); + + TransportHttpClient.Response resp = retry.performRequestAsync("ep", null, REQ, OPTS) + .get(5, TimeUnit.SECONDS); + + assertEquals(200, resp.statusCode()); + assertEquals(2, client.calls.get()); + } + + @Test + void asyncDoesNotRetryOnRuntimeException() { + ScriptedClient client = new ScriptedClient() + .andThrow(new IllegalArgumentException("nope")).andRespond(200); + RetryingHttpClient retry = wrap(client, fixed(1L, 3)); + + CompletableFuture f = retry.performRequestAsync("ep", null, REQ, OPTS); + CompletionException ex = assertThrows(CompletionException.class, f::join); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + assertEquals(1, client.calls.get()); + } + + @Test + void asyncReturnsLastResponseWhenRetriesExhausted() throws Exception { + ScriptedClient client = new ScriptedClient() + .andRespond(503).andRespond(503).andRespond(503); + RetryingHttpClient retry = wrap(client, fixed(1L, 2)); + + TransportHttpClient.Response resp = retry.performRequestAsync("ep", null, REQ, OPTS) + .get(5, TimeUnit.SECONDS); + + assertEquals(503, resp.statusCode()); + assertEquals(3, client.calls.get()); + } + + /** + * Verifies that cancelling the result future stops pending retries: with a long backoff (1 hour), + * after we cancel during the first delay, the delegate must not be called again. + */ + @Test + void asyncCancellationStopsPendingRetries() throws Exception { + CountDownLatch firstCallReturned = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + TransportHttpClient delegate = new TransportHttpClient() { + @Override + public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture performRequestAsync(String endpointId, @Nullable Node node, + Request request, TransportOptions options) { + calls.incrementAndGet(); + CompletableFuture f = new CompletableFuture<>(); + f.complete(new FakeResponse(503, Collections.emptyMap())); + firstCallReturned.countDown(); + return f; + } + + @Override public void close() {} + }; + + RetryingHttpClient retry = wrap(delegate, BackoffPolicy.constantBackoff(3_600_000L, 5)); // 1h delay + + CompletableFuture result = retry.performRequestAsync("ep", null, REQ, OPTS); + + // Wait for the first attempt to complete (we know it returned 503, so a retry is scheduled) + assertTrue(firstCallReturned.await(5, TimeUnit.SECONDS)); + + // Give the whenComplete handler a moment to schedule the retry + Thread.sleep(50); + + result.cancel(true); + + // The cancellation should be observable + assertThrows(CancellationException.class, () -> result.get(1, TimeUnit.SECONDS)); + + // No further calls should happen + Thread.sleep(100); + assertEquals(1, calls.get(), "delegate should not be called again after cancellation"); + } + + // ---------- retryability classifiers ---------- + + @Test + void throwableClassifier() { + assertTrue(RetryingHttpClient.isRetryable(new SocketException("io"))); + assertTrue(RetryingHttpClient.isRetryable(new RuntimeException(new IOException("wrapped")))); + assertFalse(RetryingHttpClient.isRetryable(new IllegalArgumentException("bad"))); + assertFalse(RetryingHttpClient.isRetryable(new NullPointerException())); + } + + @Test + void responseClassifierUsesConfiguredStatuses() { + RetryingHttpClient defaultClient = wrap(new ScriptedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)))); + assertTrue(defaultClient.isRetryable(new FakeResponse(500, Collections.emptyMap()))); + assertTrue(defaultClient.isRetryable(new FakeResponse(429, Collections.emptyMap()))); + assertTrue(defaultClient.isRetryable(new FakeResponse(503, Collections.emptyMap()))); + assertFalse(defaultClient.isRetryable(new FakeResponse(501, Collections.emptyMap()))); + assertFalse(defaultClient.isRetryable(new FakeResponse(404, Collections.emptyMap()))); + assertFalse(defaultClient.isRetryable(new FakeResponse(200, Collections.emptyMap()))); + + RetryingHttpClient custom = wrap(new ScriptedClient(), + RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableStatuses(418))); + assertTrue(custom.isRetryable(new FakeResponse(418, Collections.emptyMap()))); + assertFalse(custom.isRetryable(new FakeResponse(503, Collections.emptyMap()))); + } +} From e77829b82d58029fb6680308fc6f08cb1e33134e Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Fri, 19 Jun 2026 15:01:55 +0200 Subject: [PATCH 02/10] refactoring retrying logic --- .../transport/http/RetryingHttpClient.java | 85 ++++++++----------- .../http/RetryingHttpClientTest.java | 24 +++--- 2 files changed, 48 insertions(+), 61 deletions(-) diff --git a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java index 83630175f1..846d16246b 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java @@ -25,7 +25,6 @@ import javax.annotation.Nullable; import java.io.IOException; -import java.io.InterruptedIOException; import java.util.Iterator; import java.util.Objects; import java.util.Set; @@ -74,7 +73,7 @@ public RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig) } /** - * Build a retrying client using a user-provided scheduler. The scheduler won't be shut down + * Build a retrying client using a user-provided scheduler. The external scheduler won't be shut down * by {@link #close()}. */ public RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig, ScheduledExecutorService scheduler) { @@ -103,9 +102,7 @@ public Response performRequest(String endpointId, @Nullable Node node, Request r return performRequestAsync(endpointId, node, request, options).get(); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); - InterruptedIOException io = new InterruptedIOException("Retry was interrupted"); - io.initCause(ie); - throw io; + throw new RuntimeException("thread waiting for the response was interrupted", ie); } catch (ExecutionException ee) { Throwable cause = ee.getCause(); if (cause instanceof IOException) { @@ -135,16 +132,11 @@ private void attemptAsync(String endpointId, @Nullable Node node, Request reques return; } - CompletableFuture attempt; - try { - attempt = delegate.performRequestAsync(endpointId, node, request, options); - } catch (Throwable t) { - attempt = new CompletableFuture<>(); - attempt.completeExceptionally(t); - } + CompletableFuture attempt = delegate.performRequestAsync(endpointId, node, request, options); result.setInFlight(attempt); attempt.whenComplete((resp, err) -> { + // Early return if request was cancelled if (result.isCancelled()) { if (resp != null) { closeQuietly(resp); @@ -153,31 +145,33 @@ private void attemptAsync(String endpointId, @Nullable Node node, Request reques } result.clearInFlight(); - Throwable cause = unwrap(err); + // Early return if there's no more retries + if (!backoffIter.hasNext()) { + if (err != null) { + result.completeExceptionally(unwrap(err)); + } else { + result.complete(resp); + } + return; + } - long delayMs; + // Checking if exception/status is retryable + Throwable cause = null; if (err != null) { - if (!isRetryable(cause) || !backoffIter.hasNext()) { + cause = unwrap(err); + if (!isRetryableException(cause)) { result.completeExceptionally(cause); return; } - delayMs = backoffIter.next(); - } else if (isRetryable(resp)) { - if (!backoffIter.hasNext()) { - result.complete(resp); - return; - } - delayMs = backoffIter.next(); + } else if (isRetryableStatus(resp)) { + // Need to close existing response before triggering a retry closeQuietly(resp); } else { result.complete(resp); return; } - if (delayMs <= 0) { - attemptAsync(endpointId, node, request, options, backoffIter, result); - return; - } + long delayMs = backoffIter.next(); try { ScheduledFuture scheduled = retryScheduler.schedule( () -> attemptAsync(endpointId, node, request, options, backoffIter, result), @@ -201,18 +195,11 @@ public void close() throws IOException { } } - static boolean isRetryable(Throwable err) { - Throwable t = err; - while (t != null) { - if (t instanceof IOException) { - return true; - } - t = t.getCause() == t ? null : t.getCause(); - } - return false; + static boolean isRetryableException(Throwable err) { + return err instanceof IOException || err.getCause() instanceof IOException; } - boolean isRetryable(Response resp) { + boolean isRetryableStatus(Response resp) { return retryableStatuses.contains(resp.statusCode()); } @@ -227,7 +214,7 @@ private static void closeQuietly(Response resp) { try { resp.close(); } catch (IOException ignored) { - // best effort + // ignored } } @@ -248,10 +235,10 @@ private static final class RetryFuture extends CompletableFuture { private volatile CompletableFuture inFlight; private volatile ScheduledFuture scheduledRetry; - void setInFlight(CompletableFuture f) { - this.inFlight = f; + void setInFlight(CompletableFuture future) { + this.inFlight = future; if (isCancelled()) { - f.cancel(true); + future.cancel(true); } } @@ -259,10 +246,10 @@ void clearInFlight() { this.inFlight = null; } - void setScheduledRetry(ScheduledFuture sf) { - this.scheduledRetry = sf; + void setScheduledRetry(ScheduledFuture scheduledFuture) { + this.scheduledRetry = scheduledFuture; if (isCancelled()) { - sf.cancel(false); + scheduledFuture.cancel(false); } } @@ -270,13 +257,13 @@ void setScheduledRetry(ScheduledFuture sf) { public boolean cancel(boolean mayInterruptIfRunning) { boolean cancelled = super.cancel(mayInterruptIfRunning); if (cancelled) { - CompletableFuture f = inFlight; - if (f != null) { - f.cancel(mayInterruptIfRunning); + CompletableFuture future = inFlight; + if (future != null) { + future.cancel(mayInterruptIfRunning); } - ScheduledFuture sf = scheduledRetry; - if (sf != null) { - sf.cancel(false); + ScheduledFuture scheduledFuture = scheduledRetry; + if (scheduledFuture != null) { + scheduledFuture.cancel(false); } } return cancelled; diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java index be225adec3..edbde16625 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java @@ -405,25 +405,25 @@ public CompletableFuture performRequestAsync(String endpointId, @Nulla @Test void throwableClassifier() { - assertTrue(RetryingHttpClient.isRetryable(new SocketException("io"))); - assertTrue(RetryingHttpClient.isRetryable(new RuntimeException(new IOException("wrapped")))); - assertFalse(RetryingHttpClient.isRetryable(new IllegalArgumentException("bad"))); - assertFalse(RetryingHttpClient.isRetryable(new NullPointerException())); + assertTrue(RetryingHttpClient.isRetryableException(new SocketException("io"))); + assertTrue(RetryingHttpClient.isRetryableException(new RuntimeException(new IOException("wrapped")))); + assertFalse(RetryingHttpClient.isRetryableException(new IllegalArgumentException("bad"))); + assertFalse(RetryingHttpClient.isRetryableException(new NullPointerException())); } @Test void responseClassifierUsesConfiguredStatuses() { RetryingHttpClient defaultClient = wrap(new ScriptedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)))); - assertTrue(defaultClient.isRetryable(new FakeResponse(500, Collections.emptyMap()))); - assertTrue(defaultClient.isRetryable(new FakeResponse(429, Collections.emptyMap()))); - assertTrue(defaultClient.isRetryable(new FakeResponse(503, Collections.emptyMap()))); - assertFalse(defaultClient.isRetryable(new FakeResponse(501, Collections.emptyMap()))); - assertFalse(defaultClient.isRetryable(new FakeResponse(404, Collections.emptyMap()))); - assertFalse(defaultClient.isRetryable(new FakeResponse(200, Collections.emptyMap()))); + assertTrue(defaultClient.isRetryableStatus(new FakeResponse(500, Collections.emptyMap()))); + assertTrue(defaultClient.isRetryableStatus(new FakeResponse(429, Collections.emptyMap()))); + assertTrue(defaultClient.isRetryableStatus(new FakeResponse(503, Collections.emptyMap()))); + assertFalse(defaultClient.isRetryableStatus(new FakeResponse(501, Collections.emptyMap()))); + assertFalse(defaultClient.isRetryableStatus(new FakeResponse(404, Collections.emptyMap()))); + assertFalse(defaultClient.isRetryableStatus(new FakeResponse(200, Collections.emptyMap()))); RetryingHttpClient custom = wrap(new ScriptedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableStatuses(418))); - assertTrue(custom.isRetryable(new FakeResponse(418, Collections.emptyMap()))); - assertFalse(custom.isRetryable(new FakeResponse(503, Collections.emptyMap()))); + assertTrue(custom.isRetryableStatus(new FakeResponse(418, Collections.emptyMap()))); + assertFalse(custom.isRetryableStatus(new FakeResponse(503, Collections.emptyMap()))); } } From 8fb36bfaff4017266c854ff33e6fa9cf99a6577c Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Fri, 19 Jun 2026 17:32:02 +0200 Subject: [PATCH 03/10] add logs --- .../elastic/clients/transport/http/RetryingHttpClient.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java index 846d16246b..30f5590423 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java @@ -22,6 +22,8 @@ import co.elastic.clients.transport.BackoffPolicy; import co.elastic.clients.transport.RetryConfig; import co.elastic.clients.transport.TransportOptions; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; import javax.annotation.Nullable; import java.io.IOException; @@ -59,6 +61,8 @@ */ public final class RetryingHttpClient implements TransportHttpClient { + private static final Log logger = LogFactory.getLog(RetryingHttpClient.class); + // Instance counter, to name the retry thread private static final AtomicInteger idCounter = new AtomicInteger(); @@ -147,6 +151,7 @@ private void attemptAsync(String endpointId, @Nullable Node node, Request reques // Early return if there's no more retries if (!backoffIter.hasNext()) { + logger.warn("Retries exhausted for [" + endpointId + "]"); if (err != null) { result.completeExceptionally(unwrap(err)); } else { @@ -172,6 +177,8 @@ private void attemptAsync(String endpointId, @Nullable Node node, Request reques } long delayMs = backoffIter.next(); + logger.warn("Retrying [" + endpointId + "] in " + delayMs + " ms"); + try { ScheduledFuture scheduled = retryScheduler.schedule( () -> attemptAsync(endpointId, node, request, options, backoffIter, result), From 9170c8e60647a6c72722834238dd29e3f887b749 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Fri, 19 Jun 2026 17:32:21 +0200 Subject: [PATCH 04/10] minimal test refactor --- .../clients/transport/BackoffPolicy.java | 6 +- .../http/RetryingHttpClientTest.java | 58 +++++++++---------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/java-client/src/main/java/co/elastic/clients/transport/BackoffPolicy.java b/java-client/src/main/java/co/elastic/clients/transport/BackoffPolicy.java index 798af4c063..0b09d684f7 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/BackoffPolicy.java +++ b/java-client/src/main/java/co/elastic/clients/transport/BackoffPolicy.java @@ -55,7 +55,7 @@ public static BackoffPolicy noBackoff() { } /** - * Creates an new constant backoff policy with the provided configuration. + * Creates a new constant backoff policy with the provided configuration. * * @param delay The delay defines how long to wait between retry attempts. Must not be null. * Must be <= Integer.MAX_VALUE ms. @@ -68,7 +68,7 @@ public static BackoffPolicy constantBackoff(Long delay, int maxNumberOfRetries) } /** - * Creates an new exponential backoff policy with a default configuration of 50 ms initial wait period and 8 retries taking + * Creates a new exponential backoff policy with a default configuration of 50 ms initial wait period and 8 retries taking * roughly 5.1 seconds in total. * * @return A backoff policy with an exponential increase in wait time for retries. The returned instance is thread safe but each @@ -79,7 +79,7 @@ public static BackoffPolicy exponentialBackoff() { } /** - * Creates an new exponential backoff policy with the provided configuration. + * Creates a new exponential backoff policy with the provided configuration. * * @param initialDelay The initial delay defines how long to wait for the first retry attempt. Must not be null. * Must be <= Integer.MAX_VALUE ms. diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java index edbde16625..472e5c927d 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java @@ -64,8 +64,6 @@ void tearDown() { scheduler.shutdownNow(); } - // ---------- helpers ---------- - private static final TransportOptions OPTS = DefaultTransportOptions.EMPTY; private static final TransportHttpClient.Request REQ = new TransportHttpClient.Request( "GET", "/", Collections.emptyMap(), Collections.emptyMap(), null @@ -83,19 +81,19 @@ private static BackoffPolicy fixed(long ms, int retries) { return BackoffPolicy.constantBackoff(ms, retries); } - /** A scripted fake transport that returns the next scripted outcome on each call. */ - static class ScriptedClient implements TransportHttpClient { + // A mock transport instance that returns the next scripted outcome on each call. + static class MockedClient implements TransportHttpClient { final List> script = new ArrayList<>(); final AtomicInteger calls = new AtomicInteger(); - /** Add a response outcome. */ - ScriptedClient andRespond(int statusCode) { + // Add a response outcome. + MockedClient andRespond(int statusCode) { script.add(b -> new FakeResponse(statusCode, Collections.emptyMap())); return this; } - ScriptedClient andThrow(Throwable t) { - script.add(b -> { throw new ScriptedException(t); }); + MockedClient andThrow(Throwable t) { + script.add(b -> { throw new FakeException(t); }); return this; } @@ -107,7 +105,7 @@ public Response performRequest(String endpointId, @Nullable Node node, Request r try { Object out = step.apply(true); return (Response) out; - } catch (ScriptedException se) { + } catch (FakeException se) { if (se.cause instanceof IOException) throw (IOException) se.cause; if (se.cause instanceof RuntimeException) throw (RuntimeException) se.cause; throw new RuntimeException(se.cause); @@ -123,7 +121,7 @@ public CompletableFuture performRequestAsync(String endpointId, @Nulla try { Object out = step.apply(true); f.complete((Response) out); - } catch (ScriptedException se) { + } catch (FakeException se) { f.completeExceptionally(se.cause); } return f; @@ -133,9 +131,9 @@ public CompletableFuture performRequestAsync(String endpointId, @Nulla public void close() {} } - static final class ScriptedException extends RuntimeException { + static final class FakeException extends RuntimeException { final Throwable cause; - ScriptedException(Throwable c) { this.cause = c; } + FakeException(Throwable c) { this.cause = c; } } static final class FakeResponse implements TransportHttpClient.Response { @@ -159,11 +157,9 @@ static final class FakeResponse implements TransportHttpClient.Response { @Override public void close() { closed = true; } } - // ---------- sync tests ---------- - @Test void syncRetriesOn5xxThenSucceeds() throws IOException { - ScriptedClient client = new ScriptedClient() + MockedClient client = new MockedClient() .andRespond(503).andRespond(502).andRespond(500).andRespond(200); RetryingHttpClient retry = wrap(client, fixed(1L, 5)); @@ -175,7 +171,7 @@ void syncRetriesOn5xxThenSucceeds() throws IOException { @Test void syncRetriesOn429() throws IOException { - ScriptedClient client = new ScriptedClient().andRespond(429).andRespond(200); + MockedClient client = new MockedClient().andRespond(429).andRespond(200); RetryingHttpClient retry = wrap(client, fixed(1L, 3)); TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); @@ -186,7 +182,7 @@ void syncRetriesOn429() throws IOException { @Test void syncDoesNotRetryOn501() throws IOException { - ScriptedClient client = new ScriptedClient().andRespond(501).andRespond(200); + MockedClient client = new MockedClient().andRespond(501).andRespond(200); RetryingHttpClient retry = wrap(client, fixed(1L, 3)); TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); @@ -202,7 +198,7 @@ void customRetryableStatusesOnly() throws IOException { .backoffPolicy(fixed(1L, 3)) .retryableStatuses(418)); - ScriptedClient client = new ScriptedClient().andRespond(503).andRespond(200); + MockedClient client = new MockedClient().andRespond(503).andRespond(200); TransportHttpClient.Response resp = wrap(client, cfg).performRequest("ep", null, REQ, OPTS); assertEquals(503, resp.statusCode()); @@ -216,7 +212,7 @@ void customRetryableStatusesIncludingNonStandard() throws IOException { .backoffPolicy(fixed(1L, 3)) .retryableStatuses(408, 503)); - ScriptedClient client = new ScriptedClient().andRespond(408).andRespond(200); + MockedClient client = new MockedClient().andRespond(408).andRespond(200); TransportHttpClient.Response resp = wrap(client, cfg).performRequest("ep", null, REQ, OPTS); assertEquals(200, resp.statusCode()); @@ -247,7 +243,7 @@ void retryConfigDefensivelyCopiesStatusSet() { @Test void syncDoesNotRetryOn4xx() throws IOException { - ScriptedClient client = new ScriptedClient().andRespond(404).andRespond(200); + MockedClient client = new MockedClient().andRespond(404).andRespond(200); RetryingHttpClient retry = wrap(client, fixed(1L, 3)); TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); @@ -258,7 +254,7 @@ void syncDoesNotRetryOn4xx() throws IOException { @Test void syncRetriesOnIOExceptionButNotOnRuntimeException() { - ScriptedClient client1 = new ScriptedClient() + MockedClient client1 = new MockedClient() .andThrow(new SocketException("boom")).andRespond(200); try { TransportHttpClient.Response r = wrap(client1, fixed(1L, 3)).performRequest("ep", null, REQ, OPTS); @@ -268,7 +264,7 @@ void syncRetriesOnIOExceptionButNotOnRuntimeException() { fail("did not expect exception", e); } - ScriptedClient client2 = new ScriptedClient() + MockedClient client2 = new MockedClient() .andThrow(new IllegalStateException("bad")).andRespond(200); assertThrows(IllegalStateException.class, () -> wrap(client2, fixed(1L, 3)).performRequest("ep", null, REQ, OPTS)); @@ -277,7 +273,7 @@ void syncRetriesOnIOExceptionButNotOnRuntimeException() { @Test void syncReturnsLastResponseWhenRetriesExhausted() throws IOException { - ScriptedClient client = new ScriptedClient() + MockedClient client = new MockedClient() .andRespond(503).andRespond(503).andRespond(503); RetryingHttpClient retry = wrap(client, fixed(1L, 2)); @@ -289,7 +285,7 @@ void syncReturnsLastResponseWhenRetriesExhausted() throws IOException { @Test void syncThrowsLastIOExceptionWhenRetriesExhausted() { - ScriptedClient client = new ScriptedClient() + MockedClient client = new MockedClient() .andThrow(new SocketException("first")) .andThrow(new SocketException("second")) .andThrow(new SocketException("third")); @@ -301,11 +297,9 @@ void syncThrowsLastIOExceptionWhenRetriesExhausted() { assertEquals(3, client.calls.get()); } - // ---------- async tests ---------- - @Test void asyncRetriesOn5xxThenSucceeds() throws Exception { - ScriptedClient client = new ScriptedClient() + MockedClient client = new MockedClient() .andRespond(503).andRespond(502).andRespond(200); RetryingHttpClient retry = wrap(client, fixed(1L, 5)); @@ -318,7 +312,7 @@ void asyncRetriesOn5xxThenSucceeds() throws Exception { @Test void asyncRetriesOnIOExceptionThenSucceeds() throws Exception { - ScriptedClient client = new ScriptedClient() + MockedClient client = new MockedClient() .andThrow(new SocketException("nope")).andRespond(200); RetryingHttpClient retry = wrap(client, fixed(1L, 3)); @@ -331,7 +325,7 @@ void asyncRetriesOnIOExceptionThenSucceeds() throws Exception { @Test void asyncDoesNotRetryOnRuntimeException() { - ScriptedClient client = new ScriptedClient() + MockedClient client = new MockedClient() .andThrow(new IllegalArgumentException("nope")).andRespond(200); RetryingHttpClient retry = wrap(client, fixed(1L, 3)); @@ -343,7 +337,7 @@ void asyncDoesNotRetryOnRuntimeException() { @Test void asyncReturnsLastResponseWhenRetriesExhausted() throws Exception { - ScriptedClient client = new ScriptedClient() + MockedClient client = new MockedClient() .andRespond(503).andRespond(503).andRespond(503); RetryingHttpClient retry = wrap(client, fixed(1L, 2)); @@ -413,7 +407,7 @@ void throwableClassifier() { @Test void responseClassifierUsesConfiguredStatuses() { - RetryingHttpClient defaultClient = wrap(new ScriptedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)))); + RetryingHttpClient defaultClient = wrap(new MockedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)))); assertTrue(defaultClient.isRetryableStatus(new FakeResponse(500, Collections.emptyMap()))); assertTrue(defaultClient.isRetryableStatus(new FakeResponse(429, Collections.emptyMap()))); assertTrue(defaultClient.isRetryableStatus(new FakeResponse(503, Collections.emptyMap()))); @@ -421,7 +415,7 @@ void responseClassifierUsesConfiguredStatuses() { assertFalse(defaultClient.isRetryableStatus(new FakeResponse(404, Collections.emptyMap()))); assertFalse(defaultClient.isRetryableStatus(new FakeResponse(200, Collections.emptyMap()))); - RetryingHttpClient custom = wrap(new ScriptedClient(), + RetryingHttpClient custom = wrap(new MockedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableStatuses(418))); assertTrue(custom.isRetryableStatus(new FakeResponse(418, Collections.emptyMap()))); assertFalse(custom.isRetryableStatus(new FakeResponse(503, Collections.emptyMap()))); From c819e7e4e6b757945dd81d6b93eff4fbcda59758 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Fri, 19 Jun 2026 18:09:21 +0200 Subject: [PATCH 05/10] add configurable exception logic --- .../clients/transport/RetryConfig.java | 62 +++++++++++++++---- .../transport/http/RetryingHttpClient.java | 20 +++++- .../http/RetryingHttpClientTest.java | 58 ++++++++++++++--- 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java index c16060c76f..4712ccb5af 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java +++ b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java @@ -19,14 +19,20 @@ package co.elastic.clients.transport; +import java.io.IOException; import java.util.Objects; import java.util.Set; import java.util.function.Consumer; /** - * Configuration for transport-level retries: which {@link BackoffPolicy} to apply between attempts and which HTTP - * status codes should trigger a retry. {@link java.io.IOException}s thrown by the underlying transport are always - * retried (independently of {@link #retryableStatuses()}). + * Configuration for transport-level retries. A request is retried when: + *
    + *
  • it fails with an exception that matches one of {@link #retryableExceptions()}, or
  • + *
  • it returns a response whose status code is in {@link #retryableStatuses()}.
  • + *
+ * Either set may be empty to disable that retry route. + *

+ * Delays and the maximum number of attempts are controlled by {@link #backoffPolicy()}. */ public final class RetryConfig { @@ -35,18 +41,28 @@ public final class RetryConfig { */ public static final Set DEFAULT_RETRYABLE_STATUSES = Set.of(429, 500, 502, 503, 504); - private static final RetryConfig DISABLED = new RetryConfig(BackoffPolicy.noBackoff(), DEFAULT_RETRYABLE_STATUSES); + /** + * Default set of exceptions that trigger a retry: {@link IOException} (and all subclasses). + */ + public static final Set> DEFAULT_RETRYABLE_EXCEPTIONS = Set.of(IOException.class); + + private static final RetryConfig DISABLED = new RetryConfig( + BackoffPolicy.noBackoff(), DEFAULT_RETRYABLE_STATUSES, DEFAULT_RETRYABLE_EXCEPTIONS + ); private final BackoffPolicy backoffPolicy; private final Set retryableStatuses; + private final Set> retryableExceptions; - private RetryConfig(BackoffPolicy backoffPolicy, Set retryableStatuses) { + private RetryConfig(BackoffPolicy backoffPolicy, Set retryableStatuses, + Set> retryableExceptions) { this.backoffPolicy = Objects.requireNonNull(backoffPolicy, "backoffPolicy"); this.retryableStatuses = retryableStatuses; + this.retryableExceptions = retryableExceptions; } private RetryConfig(Builder builder) { - this(builder.backoffPolicy, builder.retryableStatuses); + this(builder.backoffPolicy, builder.retryableStatuses, builder.retryableExceptions); } /** @@ -82,27 +98,38 @@ public Set retryableStatuses() { return retryableStatuses; } + /** + * The exception types that should trigger a retry. Matched by {@code instanceof}, walking the immediate cause + * so wrapped exceptions (e.g. {@code RuntimeException(IOException)}) are also caught. + * Defaults to {@link #DEFAULT_RETRYABLE_EXCEPTIONS}. + */ + public Set> retryableExceptions() { + return retryableExceptions; + } + public Builder toBuilder() { return new Builder() .backoffPolicy(backoffPolicy) - .retryableStatuses(retryableStatuses); + .retryableStatuses(retryableStatuses) + .retryableExceptions(retryableExceptions); } public static final class Builder { private BackoffPolicy backoffPolicy = BackoffPolicy.noBackoff(); private Set retryableStatuses = DEFAULT_RETRYABLE_STATUSES; + private Set> retryableExceptions = DEFAULT_RETRYABLE_EXCEPTIONS; public Builder backoffPolicy(BackoffPolicy policy) { this.backoffPolicy = policy == null ? BackoffPolicy.noBackoff() : policy; return this; } + /** + * Sets the HTTP status codes that should trigger a retry. Pass an empty set to disable status-based retries. + */ public Builder retryableStatuses(Set statuses) { - if (statuses == null || statuses.isEmpty()) { - throw new IllegalArgumentException("retryableStatuses must not be null or empty"); - } - this.retryableStatuses = Set.copyOf(statuses); + this.retryableStatuses = Set.copyOf(Objects.requireNonNull(statuses, "retryableStatuses")); return this; } @@ -110,6 +137,19 @@ public Builder retryableStatuses(Integer... statuses) { return retryableStatuses(Set.of(statuses)); } + /** + * Sets the exception types that should trigger a retry. Pass an empty set to disable exception-based retries. + */ + public Builder retryableExceptions(Set> exceptions) { + this.retryableExceptions = Set.copyOf(Objects.requireNonNull(exceptions, "retryableExceptions")); + return this; + } + + @SafeVarargs + public final Builder retryableExceptions(Class... exceptions) { + return retryableExceptions(Set.of(exceptions)); + } + public RetryConfig build() { return new RetryConfig(this); } diff --git a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java index 30f5590423..ba221c044e 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java @@ -45,7 +45,7 @@ *

* A request is retried when: *

    - *
  • the delegate throws (or completes its future exceptionally with) an {@link IOException}, or
  • + *
  • the delegate fails with an exception matching {@link RetryConfig#retryableExceptions()}, or
  • *
  • the delegate returns a response whose status code is in {@link RetryConfig#retryableStatuses()}.
  • *
*

@@ -69,6 +69,7 @@ public final class RetryingHttpClient implements TransportHttpClient { private final TransportHttpClient delegate; private final BackoffPolicy backoffPolicy; private final Set retryableStatuses; + private final Set> retryableExceptions; private final ScheduledExecutorService retryScheduler; private final boolean isExternalScheduler; @@ -90,6 +91,7 @@ private RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig RetryConfig cfg = retryConfig == null ? RetryConfig.disabled() : retryConfig; this.backoffPolicy = cfg.backoffPolicy(); this.retryableStatuses = cfg.retryableStatuses(); + this.retryableExceptions = cfg.retryableExceptions(); this.retryScheduler = Objects.requireNonNull(scheduler, "scheduler"); this.isExternalScheduler = isExternalScheduler; } @@ -202,8 +204,20 @@ public void close() throws IOException { } } - static boolean isRetryableException(Throwable err) { - return err instanceof IOException || err.getCause() instanceof IOException; + boolean isRetryableException(Throwable err) { + return matchesRetryable(err) || matchesRetryable(err.getCause()); + } + + private boolean matchesRetryable(@Nullable Throwable t) { + if (t == null) { + return false; + } + for (Class ex : retryableExceptions) { + if (ex.isInstance(t)) { + return true; + } + } + return false; } boolean isRetryableStatus(Response resp) { diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java index 472e5c927d..47701daff0 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java @@ -220,9 +220,43 @@ void customRetryableStatusesIncludingNonStandard() throws IOException { } @Test - void retryConfigBuilderRejectsEmptyStatuses() { - assertThrows(IllegalArgumentException.class, - () -> RetryConfig.of(r -> r.retryableStatuses(new HashSet<>()))); + void customRetryableExceptionsOnly() throws IOException { + // Narrow exception retries to SocketTimeoutException — a plain SocketException must not retry + RetryConfig cfg = RetryConfig.of(r -> r + .backoffPolicy(fixed(1L, 3)) + .retryableExceptions(java.net.SocketTimeoutException.class)); + + MockedClient client = new MockedClient().andThrow(new SocketException("not a timeout")).andRespond(200); + IOException thrown = assertThrows(IOException.class, + () -> wrap(client, cfg).performRequest("ep", null, REQ, OPTS)); + assertEquals("not a timeout", thrown.getMessage()); + assertEquals(1, client.calls.get()); + } + + @Test + void emptyRetryableStatusesDisablesStatusRetries() throws IOException { + // Exception-based retries only — a 503 response should NOT trigger a retry + RetryConfig cfg = RetryConfig.of(r -> r + .backoffPolicy(fixed(1L, 3)) + .retryableStatuses(new HashSet<>())); + + MockedClient client = new MockedClient().andRespond(503).andRespond(200); + TransportHttpClient.Response resp = wrap(client, cfg).performRequest("ep", null, REQ, OPTS); + + assertEquals(503, resp.statusCode()); + assertEquals(1, client.calls.get()); + } + + @Test + void emptyRetryableExceptionsDisablesExceptionRetries() { + // Status-based retries only — a SocketException should NOT trigger a retry + RetryConfig cfg = RetryConfig.of(r -> r + .backoffPolicy(fixed(1L, 3)) + .retryableExceptions(new HashSet<>())); + + MockedClient client = new MockedClient().andThrow(new SocketException("io")).andRespond(200); + assertThrows(IOException.class, () -> wrap(client, cfg).performRequest("ep", null, REQ, OPTS)); + assertEquals(1, client.calls.get()); } @Test @@ -398,11 +432,19 @@ public CompletableFuture performRequestAsync(String endpointId, @Nulla // ---------- retryability classifiers ---------- @Test - void throwableClassifier() { - assertTrue(RetryingHttpClient.isRetryableException(new SocketException("io"))); - assertTrue(RetryingHttpClient.isRetryableException(new RuntimeException(new IOException("wrapped")))); - assertFalse(RetryingHttpClient.isRetryableException(new IllegalArgumentException("bad"))); - assertFalse(RetryingHttpClient.isRetryableException(new NullPointerException())); + void exceptionClassifierUsesConfiguredTypes() { + // Default behaviour: any IOException (and its subclasses) is retryable + RetryingHttpClient defaultClient = wrap(new MockedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)))); + assertTrue(defaultClient.isRetryableException(new SocketException("io"))); + assertTrue(defaultClient.isRetryableException(new RuntimeException(new IOException("wrapped")))); + assertFalse(defaultClient.isRetryableException(new IllegalArgumentException("bad"))); + assertFalse(defaultClient.isRetryableException(new NullPointerException())); + + // Narrowed to a specific subclass: only that subclass is retryable + RetryingHttpClient narrow = wrap(new MockedClient(), + RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableExceptions(SocketException.class))); + assertTrue(narrow.isRetryableException(new SocketException("io"))); + assertFalse(narrow.isRetryableException(new IOException("plain io"))); } @Test From 2fd0bbc3c0505397ed65e511d7080cd0ee7d1cb8 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Fri, 19 Jun 2026 18:35:26 +0200 Subject: [PATCH 06/10] comments --- .../java/co/elastic/clients/transport/RetryConfig.java | 7 ++++++- .../clients/transport/http/RetryingHttpClient.java | 3 +++ .../clients/transport/http/RetryingHttpClientTest.java | 10 +++++----- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java index 4712ccb5af..55e04121b5 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java +++ b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java @@ -33,6 +33,11 @@ * Either set may be empty to disable that retry route. *

* Delays and the maximum number of attempts are controlled by {@link #backoffPolicy()}. + *

+ * Scope: retries reissue the same logical request to the underlying transport. Node selection is delegated + * to that transport (e.g. {@code Rest5Client}, {@code RestClient}), which manages its own node-rotation and + * dead-node tracking independently. Configuring retries here does not control which node a retried + * request is sent to. */ public final class RetryConfig { @@ -66,7 +71,7 @@ private RetryConfig(Builder builder) { } /** - * Disabled retry configuration — equivalent to {@link BackoffPolicy#noBackoff()} with the default status set. + * Disabled retry configuration, equivalent to {@link BackoffPolicy#noBackoff()} with the default status set. * No retries will happen. */ public static RetryConfig disabled() { diff --git a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java index ba221c044e..670811ea3b 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java @@ -53,6 +53,9 @@ *

    *
  • Retries assume the underlying request is safe to repeat. Most Elasticsearch APIs are idempotent at the user level, * but users using custom non-idempotent operations should think twice before enabling retries.
  • + *
  • Retries reissue the same logical request to the {@code delegate}. Node selection (and any node-rotation / + * dead-node tracking) is the delegate's responsibility: this wrapper does not influence which node a retried + * request is sent to.
  • *
  • The async path uses a {@link ScheduledExecutorService} to defer retries without blocking the calling thread pool. * A single-thread daemon scheduler is created by default and is shut down by {@link #close()}; users can supply * their own scheduler via {@link #RetryingHttpClient(TransportHttpClient, RetryConfig, ScheduledExecutorService)} diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java index 47701daff0..323a7249c8 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java @@ -193,7 +193,7 @@ void syncDoesNotRetryOn501() throws IOException { @Test void customRetryableStatusesOnly() throws IOException { - // Configure to retry only on 418 — a 503 should pass through + // Configure to retry only on 418 - a 503 should pass through RetryConfig cfg = RetryConfig.of(r -> r .backoffPolicy(fixed(1L, 3)) .retryableStatuses(418)); @@ -207,7 +207,7 @@ void customRetryableStatusesOnly() throws IOException { @Test void customRetryableStatusesIncludingNonStandard() throws IOException { - // Configure to retry on 408 (Request Timeout) — not in the default set + // Configure to retry on 408 (Request Timeout) - not in the default set RetryConfig cfg = RetryConfig.of(r -> r .backoffPolicy(fixed(1L, 3)) .retryableStatuses(408, 503)); @@ -221,7 +221,7 @@ void customRetryableStatusesIncludingNonStandard() throws IOException { @Test void customRetryableExceptionsOnly() throws IOException { - // Narrow exception retries to SocketTimeoutException — a plain SocketException must not retry + // Narrow exception retries to SocketTimeoutException - a plain SocketException must not retry RetryConfig cfg = RetryConfig.of(r -> r .backoffPolicy(fixed(1L, 3)) .retryableExceptions(java.net.SocketTimeoutException.class)); @@ -235,7 +235,7 @@ void customRetryableExceptionsOnly() throws IOException { @Test void emptyRetryableStatusesDisablesStatusRetries() throws IOException { - // Exception-based retries only — a 503 response should NOT trigger a retry + // Exception-based retries only - a 503 response should NOT trigger a retry RetryConfig cfg = RetryConfig.of(r -> r .backoffPolicy(fixed(1L, 3)) .retryableStatuses(new HashSet<>())); @@ -249,7 +249,7 @@ void emptyRetryableStatusesDisablesStatusRetries() throws IOException { @Test void emptyRetryableExceptionsDisablesExceptionRetries() { - // Status-based retries only — a SocketException should NOT trigger a retry + // Status-based retries only - a SocketException should NOT trigger a retry RetryConfig cfg = RetryConfig.of(r -> r .backoffPolicy(fixed(1L, 3)) .retryableExceptions(new HashSet<>())); From a2a15e63ae6d78d3b317a1a29f1ab1e2566b826c Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Fri, 19 Jun 2026 18:44:59 +0200 Subject: [PATCH 07/10] code style --- .../clients/transport/rest5_client/Rest5ClientOptions.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientOptions.java b/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientOptions.java index 3e326fd206..6f4f745297 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientOptions.java +++ b/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientOptions.java @@ -31,7 +31,11 @@ import org.apache.hc.core5.util.VersionInfo; import javax.annotation.Nullable; -import java.util.*; +import java.util.AbstractMap; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; From 339b171e592f01197d914d2774ae98cd8a9f9448 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Mon, 22 Jun 2026 10:53:57 +0200 Subject: [PATCH 08/10] simplify test --- .../transport/http/RetryingHttpClientTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java index 323a7249c8..518ebbe688 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java @@ -44,7 +44,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; +import java.util.function.Supplier; class RetryingHttpClientTest extends Assertions { @@ -83,17 +83,17 @@ private static BackoffPolicy fixed(long ms, int retries) { // A mock transport instance that returns the next scripted outcome on each call. static class MockedClient implements TransportHttpClient { - final List> script = new ArrayList<>(); + final List> script = new ArrayList<>(); final AtomicInteger calls = new AtomicInteger(); // Add a response outcome. MockedClient andRespond(int statusCode) { - script.add(b -> new FakeResponse(statusCode, Collections.emptyMap())); + script.add(() -> new FakeResponse(statusCode, Collections.emptyMap())); return this; } MockedClient andThrow(Throwable t) { - script.add(b -> { throw new FakeException(t); }); + script.add(() -> { throw new FakeException(t); }); return this; } @@ -101,9 +101,9 @@ MockedClient andThrow(Throwable t) { public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) throws IOException { int i = calls.getAndIncrement(); - Function step = script.get(Math.min(i, script.size() - 1)); + Supplier step = script.get(Math.min(i, script.size() - 1)); try { - Object out = step.apply(true); + Object out = step.get(); return (Response) out; } catch (FakeException se) { if (se.cause instanceof IOException) throw (IOException) se.cause; @@ -116,10 +116,10 @@ public Response performRequest(String endpointId, @Nullable Node node, Request r public CompletableFuture performRequestAsync(String endpointId, @Nullable Node node, Request request, TransportOptions options) { int i = calls.getAndIncrement(); - Function step = script.get(Math.min(i, script.size() - 1)); + Supplier step = script.get(Math.min(i, script.size() - 1)); CompletableFuture f = new CompletableFuture<>(); try { - Object out = step.apply(true); + Object out = step.get(); f.complete((Response) out); } catch (FakeException se) { f.completeExceptionally(se.cause); From 8c4ad6d018cd0365cf38a7452bd4ba839c41c377 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Tue, 23 Jun 2026 15:47:18 +0200 Subject: [PATCH 09/10] single request retry --- .../transport/ElasticsearchTransportBase.java | 52 +++- .../clients/transport/RetryConfig.java | 7 +- .../transport/http/RetryingHttpClient.java | 178 +++++++----- .../ElasticsearchTransportRetryTest.java | 260 ++++++++++++++++++ .../http/RetryingHttpClientTest.java | 137 ++++----- .../clients/transport/http/RetryingTest.java | 31 +++ 6 files changed, 525 insertions(+), 140 deletions(-) create mode 100644 java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportRetryTest.java create mode 100644 java-client/src/test/java/co/elastic/clients/transport/http/RetryingTest.java diff --git a/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java b/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java index a56b164da2..2101342f85 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java +++ b/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java @@ -81,9 +81,11 @@ public abstract class ElasticsearchTransportBase implements ElasticsearchTranspo } protected final TransportHttpClient httpClient; - // Same as httpClient if no retry policy, will be an instance of RetryingHttpClient in case - // a retry policy has been configured - private final TransportHttpClient wrappingHttpClient; + // Single retry wrapper, shared by every request that uses retries (whether configured on the client or per + // request) and reading the effective RetryConfig from each call's TransportOptions. Created lazily on first + // use, so clients that never use retries keep the exact same object graph as before and never allocate a + // retry scheduler. Guarded by `this` for creation. + private volatile RetryingHttpClient retryingHttpClient; protected final Instrumentation instrumentation; protected final JsonpMapper mapper; protected final TransportOptions transportOptions; @@ -103,13 +105,6 @@ public ElasticsearchTransportBase( this.httpClient = httpClient; this.transportOptions = httpClient.createOptions(options); - RetryConfig retryConfig = this.transportOptions.retryConfig(); - if (retryConfig != null && retryConfig.backoffPolicy() != BackoffPolicy.noBackoff()) { - this.wrappingHttpClient = new RetryingHttpClient(httpClient, retryConfig); - } else { - this.wrappingHttpClient = httpClient; - } - // If no instrumentation is provided, fallback to OpenTelemetry and ultimately noop if (instrumentation == null) { instrumentation = OpenTelemetryForElasticsearch.getDefault(); @@ -131,7 +126,14 @@ protected ElasticsearchTransportBase cloneWith( @Override public void close() throws IOException { - wrappingHttpClient.close(); + // The retry wrapper, if it was ever created, owns the delegate and the retry scheduler, so closing it + // also closes httpClient and shuts down the scheduler. Otherwise close httpClient directly. + RetryingHttpClient retrying = retryingHttpClient; + if (retrying != null) { + retrying.close(); + } else { + httpClient.close(); + } } @Override @@ -148,6 +150,30 @@ public TransportHttpClient httpClient() { return httpClient; } + // Returns the shared retry wrapper when this request uses retries, the bare http client otherwise. The + // wrapper is created lazily on first use, so clients that never retry keep the non-retry path untouched. + private TransportHttpClient httpClientFor(TransportOptions options) { + RetryConfig retryConfig = options.retryConfig(); + if (retryConfig == null || !retryConfig.isEnabled()) { + return httpClient; + } + return retryingHttpClient(); + } + + private RetryingHttpClient retryingHttpClient() { + RetryingHttpClient client = retryingHttpClient; + if (client == null) { + synchronized (this) { + client = retryingHttpClient; + if (client == null) { + client = new RetryingHttpClient(httpClient); + retryingHttpClient = client; + } + } + } + return client; + } + @Override public final ResponseT performRequest( RequestT request, @@ -161,7 +187,7 @@ public final ResponseT performRequest( TransportHttpClient.Request req = prepareTransportRequest(request, endpoint); ctx.beforeSendingHttpRequest(req, options); - TransportHttpClient.Response resp = wrappingHttpClient.performRequest(endpoint.id(), null, req, opts); + TransportHttpClient.Response resp = httpClientFor(opts).performRequest(endpoint.id(), null, req, opts); ctx.afterReceivingHttpResponse(resp); ResponseT apiResponse = getApiResponse(resp, endpoint); @@ -200,7 +226,7 @@ public final CompletableFuture performR // Propagate required property checks to the thread that will decode the response boolean disableRequiredChecks = ApiTypeHelper.requiredPropertiesCheckDisabled(); - CompletableFuture clientFuture = wrappingHttpClient.performRequestAsync( + CompletableFuture clientFuture = httpClientFor(opts).performRequestAsync( endpoint.id(), null, clientReq, opts ); diff --git a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java index 55e04121b5..32c71b42ba 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java +++ b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java @@ -96,6 +96,10 @@ public BackoffPolicy backoffPolicy() { return backoffPolicy; } + public boolean isEnabled() { + return backoffPolicy != BackoffPolicy.noBackoff(); + } + /** * The HTTP status codes that should trigger a retry. Defaults to {@link #DEFAULT_RETRYABLE_STATUSES}. */ @@ -104,8 +108,7 @@ public Set retryableStatuses() { } /** - * The exception types that should trigger a retry. Matched by {@code instanceof}, walking the immediate cause - * so wrapped exceptions (e.g. {@code RuntimeException(IOException)}) are also caught. + * The exception types that should trigger a retry. Wrapped exceptions are also considered. * Defaults to {@link #DEFAULT_RETRYABLE_EXCEPTIONS}. */ public Set> retryableExceptions() { diff --git a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java index 670811ea3b..e5133fe54a 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java @@ -19,7 +19,6 @@ package co.elastic.clients.transport.http; -import co.elastic.clients.transport.BackoffPolicy; import co.elastic.clients.transport.RetryConfig; import co.elastic.clients.transport.TransportOptions; import org.apache.commons.logging.Log; @@ -29,7 +28,6 @@ import java.io.IOException; import java.util.Iterator; import java.util.Objects; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; @@ -37,11 +35,18 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** - * A {@link TransportHttpClient} wrapper that retries failed requests according to a {@link RetryConfig}. + * A {@link TransportHttpClient} wrapper that retries failed requests according to the {@link RetryConfig} + * carried by each request's {@link TransportOptions}. + *

    + * The retry configuration is read from the {@link TransportOptions} passed to every call (see + * {@link TransportOptions#retryConfig()}), so it can be set globally on the client or overridden per request, + * exactly like headers or query parameters. When the effective configuration is disabled + * ({@link RetryConfig#isEnabled()} returns {@code false}), the call is forwarded to the delegate unchanged. *

    * A request is retried when: *

      @@ -51,15 +56,18 @@ *

      * Notes: *

        - *
      • Retries assume the underlying request is safe to repeat. Most Elasticsearch APIs are idempotent at the user level, - * but users using custom non-idempotent operations should think twice before enabling retries.
      • + *
      • Retries assume the underlying request is safe to repeat. Most Elasticsearch APIs are idempotent at the user + * level, but users issuing custom non-idempotent operations should think twice before enabling retries.
      • *
      • Retries reissue the same logical request to the {@code delegate}. Node selection (and any node-rotation / * dead-node tracking) is the delegate's responsibility: this wrapper does not influence which node a retried * request is sent to.
      • - *
      • The async path uses a {@link ScheduledExecutorService} to defer retries without blocking the calling thread pool. - * A single-thread daemon scheduler is created by default and is shut down by {@link #close()}; users can supply - * their own scheduler via {@link #RetryingHttpClient(TransportHttpClient, RetryConfig, ScheduledExecutorService)} - * in which case its lifecycle is the user's responsibility.
      • + *
      • The async path uses a {@link ScheduledExecutorService} to defer retries without blocking the calling thread + * pool. A single-thread daemon scheduler is created with this client and shut down by {@link #close()}. Its + * worker thread is started on the first actual retry and reaped once it has been idle longer than the largest + * backoff delay seen so far (then recreated on demand), so a client that isn't actively retrying holds no retry + * thread, while an in-progress retry sequence never reaps its worker between attempts. Users can supply their + * own scheduler via {@link #RetryingHttpClient(TransportHttpClient, ScheduledExecutorService)}, in which case + * its lifecycle (and idle behavior) is the user's responsibility.
      • *
      */ public final class RetryingHttpClient implements TransportHttpClient { @@ -69,34 +77,37 @@ public final class RetryingHttpClient implements TransportHttpClient { // Instance counter, to name the retry thread private static final AtomicInteger idCounter = new AtomicInteger(); + // Margin added on top of a retry's backoff delay when sizing the worker's idle keep-alive, so the worker + // comfortably outlives the wait between two attempts. It also acts as the minimum idle window (for very short + // backoffs) and as the initial keep-alive before any retry is scheduled. See keepWorkerAliveFor. + private static final long IDLE_KEEP_ALIVE_MARGIN_MS = 1000L; + private final TransportHttpClient delegate; - private final BackoffPolicy backoffPolicy; - private final Set retryableStatuses; - private final Set> retryableExceptions; + + // The scheduler used to defer retries. Its (single, daemon) worker thread is only started on the first + // scheduled retry and is reaped while idle (see defaultRetryScheduler / keepWorkerAliveFor), so a client + // that isn't actively retrying holds no retry thread. private final ScheduledExecutorService retryScheduler; - private final boolean isExternalScheduler; + // Same instance as retryScheduler when we created it ourselves, null for a user-supplied scheduler. Used to + // shut down and tune the idle keep-alive of a scheduler we own; we never reconfigure one we don't. + @Nullable + private final ScheduledThreadPoolExecutor managedScheduler; - public RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig) { - this(delegate, retryConfig, defaultRetryScheduler(), false); + public RetryingHttpClient(TransportHttpClient delegate) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + ScheduledThreadPoolExecutor scheduler = defaultRetryScheduler(); + this.retryScheduler = scheduler; + this.managedScheduler = scheduler; } /** - * Build a retrying client using a user-provided scheduler. The external scheduler won't be shut down - * by {@link #close()}. + * Build a retrying client using a user-provided scheduler. The external scheduler won't be shut down or + * reconfigured by this client. */ - public RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig, ScheduledExecutorService scheduler) { - this(delegate, retryConfig, scheduler, true); - } - - private RetryingHttpClient(TransportHttpClient delegate, RetryConfig retryConfig, - ScheduledExecutorService scheduler, boolean isExternalScheduler) { + public RetryingHttpClient(TransportHttpClient delegate, ScheduledExecutorService scheduler) { this.delegate = Objects.requireNonNull(delegate, "delegate"); - RetryConfig cfg = retryConfig == null ? RetryConfig.disabled() : retryConfig; - this.backoffPolicy = cfg.backoffPolicy(); - this.retryableStatuses = cfg.retryableStatuses(); - this.retryableExceptions = cfg.retryableExceptions(); this.retryScheduler = Objects.requireNonNull(scheduler, "scheduler"); - this.isExternalScheduler = isExternalScheduler; + this.managedScheduler = null; } @Override @@ -107,6 +118,10 @@ public TransportOptions createOptions(@Nullable TransportOptions options) { @Override public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) throws IOException { + // No retries configured for this request: forward to the delegate unchanged (no async indirection). + if (!configOf(options).isEnabled()) { + return delegate.performRequest(endpointId, node, request, options); + } try { return performRequestAsync(endpointId, node, request, options).get(); } catch (InterruptedException ie) { @@ -130,13 +145,17 @@ public Response performRequest(String endpointId, @Nullable Node node, Request r @Override public CompletableFuture performRequestAsync(String endpointId, @Nullable Node node, Request request, TransportOptions options) { + RetryConfig config = configOf(options); + if (!config.isEnabled()) { + return delegate.performRequestAsync(endpointId, node, request, options); + } RetryFuture result = new RetryFuture(); - attemptAsync(endpointId, node, request, options, backoffPolicy.iterator(), result); + attemptAsync(endpointId, node, request, options, config, config.backoffPolicy().iterator(), result); return result; } private void attemptAsync(String endpointId, @Nullable Node node, Request request, TransportOptions options, - Iterator backoffIter, RetryFuture result) { + RetryConfig config, Iterator backoffIter, RetryFuture result) { if (result.isDone()) { return; } @@ -154,39 +173,41 @@ private void attemptAsync(String endpointId, @Nullable Node node, Request reques } result.clearInFlight(); - // Early return if there's no more retries - if (!backoffIter.hasNext()) { - logger.warn("Retries exhausted for [" + endpointId + "]"); - if (err != null) { - result.completeExceptionally(unwrap(err)); - } else { - result.complete(resp); + // Classify the outcome + Throwable cause = err != null ? unwrap(err) : null; + boolean retryable = cause != null + ? isRetryableException(config, cause) + : isRetryableStatus(config, resp); + + // Not retryable, or no retries left: complete with whatever we got + if (!retryable || !backoffIter.hasNext()) { + if (retryable) { + logger.warn("Retries exhausted for [" + endpointId + "]"); + } + if (cause != null) { + result.completeExceptionally(cause); + } else if (!result.complete(resp)) { + // The future was already completed/cancelled elsewhere: avoid leaking the response + closeQuietly(resp); } return; } - // Checking if exception/status is retryable - Throwable cause = null; - if (err != null) { - cause = unwrap(err); - if (!isRetryableException(cause)) { - result.completeExceptionally(cause); - return; - } - } else if (isRetryableStatus(resp)) { - // Need to close existing response before triggering a retry + // Retryable with budget left: close any response before triggering a retry + if (resp != null) { closeQuietly(resp); - } else { - result.complete(resp); - return; } long delayMs = backoffIter.next(); - logger.warn("Retrying [" + endpointId + "] in " + delayMs + " ms"); + if (logger.isDebugEnabled()) { + logger.debug("Retrying [" + endpointId + "] in " + delayMs + " ms"); + } try { + // Make sure the worker survives this wait so the sequence doesn't churn its thread. + keepWorkerAliveFor(delayMs); ScheduledFuture scheduled = retryScheduler.schedule( - () -> attemptAsync(endpointId, node, request, options, backoffIter, result), + () -> attemptAsync(endpointId, node, request, options, config, backoffIter, result), delayMs, TimeUnit.MILLISECONDS ); result.setScheduledRetry(scheduled); @@ -201,21 +222,22 @@ public void close() throws IOException { try { delegate.close(); } finally { - if (!isExternalScheduler) { - retryScheduler.shutdownNow(); + // Only shut down a scheduler we created; a user-supplied one is the caller's responsibility. + if (managedScheduler != null) { + managedScheduler.shutdownNow(); } } } - boolean isRetryableException(Throwable err) { - return matchesRetryable(err) || matchesRetryable(err.getCause()); + boolean isRetryableException(RetryConfig config, Throwable err) { + return matchesRetryable(config, err) || matchesRetryable(config, err.getCause()); } - private boolean matchesRetryable(@Nullable Throwable t) { + private boolean matchesRetryable(RetryConfig config, @Nullable Throwable t) { if (t == null) { return false; } - for (Class ex : retryableExceptions) { + for (Class ex : config.retryableExceptions()) { if (ex.isInstance(t)) { return true; } @@ -223,8 +245,13 @@ private boolean matchesRetryable(@Nullable Throwable t) { return false; } - boolean isRetryableStatus(Response resp) { - return retryableStatuses.contains(resp.statusCode()); + boolean isRetryableStatus(RetryConfig config, Response resp) { + return config.retryableStatuses().contains(resp.statusCode()); + } + + private static RetryConfig configOf(@Nullable TransportOptions options) { + RetryConfig config = options == null ? null : options.retryConfig(); + return config == null ? RetryConfig.disabled() : config; } private static Throwable unwrap(Throwable t) { @@ -242,14 +269,41 @@ private static void closeQuietly(Response resp) { } } - private static ScheduledExecutorService defaultRetryScheduler() { + private static ScheduledThreadPoolExecutor defaultRetryScheduler() { int clientId = idCounter.incrementAndGet(); - return Executors.newSingleThreadScheduledExecutor(r -> { + ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(1, r -> { Thread t = Executors.defaultThreadFactory().newThread(r); t.setName("elasticsearch-java-retry#" + clientId + "#" + t.getId()); t.setDaemon(true); return t; }); + // Don't keep an idle worker parked between (potentially infrequent) retry sequences: the thread is started + // on the first scheduled retry, reaped once it sits idle, and recreated on demand. The idle window starts + // at the margin below and is grown to track the backoff in use (see keepWorkerAliveFor) so an active + // sequence never reaps its worker between attempts. + scheduler.setKeepAliveTime(IDLE_KEEP_ALIVE_MARGIN_MS, TimeUnit.MILLISECONDS); + scheduler.allowCoreThreadTimeOut(true); + // Cancelled retries (e.g. after request cancellation) are removed from the queue immediately so they + // don't keep the worker alive or delay its reaping. + scheduler.setRemoveOnCancelPolicy(true); + return scheduler; + } + + /** + * Grows the managed scheduler's idle keep-alive so its single worker outlives the wait before the next attempt + * ({@code delayMs} plus a margin) and isn't reaped/recreated mid-sequence. We only ever increase it, so it ends + * up tracking the largest backoff delay seen; an idle scheduler then reaps its worker after that window. No-op + * for a user-supplied scheduler, which we never reconfigure. + */ + private void keepWorkerAliveFor(long delayMs) { + ScheduledThreadPoolExecutor scheduler = managedScheduler; + if (scheduler == null || delayMs <= 0) { + return; + } + long desiredMs = delayMs + IDLE_KEEP_ALIVE_MARGIN_MS; + if (desiredMs > 0 && scheduler.getKeepAliveTime(TimeUnit.MILLISECONDS) < desiredMs) { + scheduler.setKeepAliveTime(desiredMs, TimeUnit.MILLISECONDS); + } } /** diff --git a/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportRetryTest.java b/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportRetryTest.java new file mode 100644 index 0000000000..4c9bf6b93b --- /dev/null +++ b/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportRetryTest.java @@ -0,0 +1,260 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package co.elastic.clients.transport; + +import co.elastic.clients.elasticsearch.core.CountRequest; +import co.elastic.clients.elasticsearch.core.CountResponse; +import co.elastic.clients.testkit.MockHttpClient; +import co.elastic.clients.testkit.ModelTestCase; +import co.elastic.clients.transport.http.TransportHttpClient; +import co.elastic.clients.util.BinaryData; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Verifies how {@link ElasticsearchTransportBase} routes requests depending on where retries are configured: + *
        + *
      • enabled at the client level, and
      • + *
      • enabled only for a single request on an otherwise non-retrying client.
      • + *
      + * In both cases requests go through a single, lazily-created, shared retry wrapper; requests without retries go + * straight to the bare http client. + */ +public class ElasticsearchTransportRetryTest extends ModelTestCase { + + private static final BackoffPolicy FAST = BackoffPolicy.constantBackoff(1L, 3); + + private static TransportOptions retryOptions() { + return new DefaultTransportOptions.Builder() + .retryConfig(RetryConfig.of(r -> r.backoffPolicy(FAST))) + .build(); + } + + private static TransportOptions disabledRetryOptions() { + return new DefaultTransportOptions.Builder() + .retryConfig(RetryConfig.disabled()) + .build(); + } + + // An http client that returns `failuresBeforeSuccess` retryable 503 responses, then a valid 200 _count response. + private ScriptedHttpClient scriptedClient(int failuresBeforeSuccess) { + ScriptedHttpClient client = new ScriptedHttpClient(failuresBeforeSuccess); + client.add("/_count", "application/json", toJson(CountResponse.of(c -> c + .count(1) + .shards(s -> s.successful(1).failed(0).total(1)) + ))); + return client; + } + + private ElasticsearchTransportBase transport(MockHttpClient httpClient, TransportOptions clientOptions) { + return new ElasticsearchTransportBase(httpClient, clientOptions, mapper) {}; + } + + @Test + public void clientLevelRetryRetriesAndSucceeds() throws IOException { + ScriptedHttpClient http = scriptedClient(2); + ElasticsearchTransportBase transport = transport(http, retryOptions()); + + // No per-request options: the client-level RetryConfig (carried by transportOptions) applies. + CountResponse resp = transport.performRequest(CountRequest.of(c -> c), CountRequest._ENDPOINT, null); + + assertEquals(1L, resp.count()); + assertEquals(3, http.calls.get()); + } + + @Test + public void perRequestRetryRetriesAndSucceedsSync() throws IOException { + ScriptedHttpClient http = scriptedClient(1); + // Client has NO retries: the retry is requested only for this call. + ElasticsearchTransportBase transport = transport(http, null); + + CountResponse resp = transport.performRequest(CountRequest.of(c -> c), CountRequest._ENDPOINT, retryOptions()); + + assertEquals(1L, resp.count()); + assertEquals(2, http.calls.get()); + } + + @Test + public void perRequestRetryRetriesAndSucceedsAsync() throws Exception { + ScriptedHttpClient http = scriptedClient(1); + ElasticsearchTransportBase transport = transport(http, null); + + CompletableFuture future = + transport.performRequestAsync(CountRequest.of(c -> c), CountRequest._ENDPOINT, retryOptions()); + CountResponse resp = future.get(5, TimeUnit.SECONDS); + + assertEquals(1L, resp.count()); + assertEquals(2, http.calls.get()); + } + + @Test + public void noRetryConfiguredMakesSingleAttempt() { + ScriptedHttpClient http = scriptedClient(5); + ElasticsearchTransportBase transport = transport(http, null); + + // Neither client nor request enable retries: the 503 surfaces immediately. + assertThrows(TransportException.class, + () -> transport.performRequest(CountRequest.of(c -> c), CountRequest._ENDPOINT, null)); + assertEquals(1, http.calls.get()); + } + + /** + * Cancelling a per-request retrying call must stop pending retries and not call the delegate again, + * without surfacing spurious exceptions. + */ + @Test + public void perRequestRetryAsyncCancellationStopsRetries() throws Exception { + AtomicInteger calls = new AtomicInteger(); + CountDownLatch firstCall = new CountDownLatch(1); + + TransportHttpClient delegate = new TransportHttpClient() { + @Override + public Response performRequest(String endpointId, Node node, Request request, TransportOptions options) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture performRequestAsync(String endpointId, Node node, Request request, + TransportOptions options) { + calls.incrementAndGet(); + CompletableFuture f = new CompletableFuture<>(); + f.complete(new StatusResponse(503)); + firstCall.countDown(); + return f; + } + + @Override + public void close() { + } + }; + + ElasticsearchTransportBase transport = new ElasticsearchTransportBase(delegate, null, mapper) {}; + + // Long backoff so the retry stays pending until we cancel. + TransportOptions longRetry = new DefaultTransportOptions.Builder() + .retryConfig(RetryConfig.of(r -> r.backoffPolicy(BackoffPolicy.constantBackoff(3_600_000L, 5)))) + .build(); + + CompletableFuture future = + transport.performRequestAsync(CountRequest.of(c -> c), CountRequest._ENDPOINT, longRetry); + + assertTrue(firstCall.await(5, TimeUnit.SECONDS)); + // Give the retry a moment to be scheduled before we cancel. + Thread.sleep(50); + future.cancel(true); + + assertThrows(CancellationException.class, () -> future.get(1, TimeUnit.SECONDS)); + + // No further attempts after cancellation. + Thread.sleep(100); + assertEquals(1, calls.get(), "delegate must not be called again after cancellation"); + } + + @Test + public void perRequestDisableOverridesClientRetry() { + ScriptedHttpClient http = scriptedClient(5); + // Client enables retries, but this specific request opts out. + ElasticsearchTransportBase transport = transport(http, retryOptions()); + + assertThrows(TransportException.class, + () -> transport.performRequest(CountRequest.of(c -> c), CountRequest._ENDPOINT, disabledRetryOptions())); + assertEquals(1, http.calls.get()); + } + + static class ScriptedHttpClient extends MockHttpClient { + final AtomicInteger calls = new AtomicInteger(); + private final int failuresBeforeSuccess; + + ScriptedHttpClient(int failuresBeforeSuccess) { + this.failuresBeforeSuccess = failuresBeforeSuccess; + } + + @Override + public Response performRequest(String endpointId, Node node, Request request, TransportOptions option) + throws IOException { + int i = calls.getAndIncrement(); + if (i < failuresBeforeSuccess) { + return new StatusResponse(503); + } + return super.performRequest(endpointId, node, request, option); + } + + @Override + public CompletableFuture performRequestAsync(String endpointId, Node node, Request request, + TransportOptions options) { + CompletableFuture f = new CompletableFuture<>(); + try { + f.complete(performRequest(endpointId, node, request, options)); + } catch (Exception e) { + f.completeExceptionally(e); + } + return f; + } + } + + static final class StatusResponse implements TransportHttpClient.Response { + private final int status; + + StatusResponse(int status) { + this.status = status; + } + + @Override + public TransportHttpClient.Node node() { + return null; + } + + @Override + public int statusCode() { + return status; + } + + @Override + public String header(String name) { + return null; + } + + @Override + public List headers(String name) { + return null; + } + + @Override + public BinaryData body() { + return null; + } + + @Override + public Object originalResponse() { + return null; + } + + @Override + public void close() { + } + } +} diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java index 518ebbe688..f6c3a1f0a5 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java @@ -44,7 +44,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; +import java.util.function.Function; class RetryingHttpClientTest extends Assertions { @@ -64,17 +64,21 @@ void tearDown() { scheduler.shutdownNow(); } - private static final TransportOptions OPTS = DefaultTransportOptions.EMPTY; private static final TransportHttpClient.Request REQ = new TransportHttpClient.Request( "GET", "/", Collections.emptyMap(), Collections.emptyMap(), null ); - private RetryingHttpClient wrap(TransportHttpClient delegate, RetryConfig config) { - return new RetryingHttpClient(delegate, config, scheduler); + private RetryingHttpClient wrap(TransportHttpClient delegate) { + return new RetryingHttpClient(delegate, scheduler); } - private RetryingHttpClient wrap(TransportHttpClient delegate, BackoffPolicy policy) { - return wrap(delegate, RetryConfig.of(r -> r.backoffPolicy(policy))); + // The retry configuration is now read from each request's TransportOptions, so tests inject it there. + private static TransportOptions opts(RetryConfig config) { + return new DefaultTransportOptions.Builder().retryConfig(config).build(); + } + + private static TransportOptions opts(BackoffPolicy policy) { + return opts(RetryConfig.of(r -> r.backoffPolicy(policy))); } private static BackoffPolicy fixed(long ms, int retries) { @@ -83,17 +87,17 @@ private static BackoffPolicy fixed(long ms, int retries) { // A mock transport instance that returns the next scripted outcome on each call. static class MockedClient implements TransportHttpClient { - final List> script = new ArrayList<>(); + final List> script = new ArrayList<>(); final AtomicInteger calls = new AtomicInteger(); // Add a response outcome. MockedClient andRespond(int statusCode) { - script.add(() -> new FakeResponse(statusCode, Collections.emptyMap())); + script.add(b -> new FakeResponse(statusCode, Collections.emptyMap())); return this; } MockedClient andThrow(Throwable t) { - script.add(() -> { throw new FakeException(t); }); + script.add(b -> { throw new FakeException(t); }); return this; } @@ -101,9 +105,9 @@ MockedClient andThrow(Throwable t) { public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) throws IOException { int i = calls.getAndIncrement(); - Supplier step = script.get(Math.min(i, script.size() - 1)); + Function step = script.get(Math.min(i, script.size() - 1)); try { - Object out = step.get(); + Object out = step.apply(true); return (Response) out; } catch (FakeException se) { if (se.cause instanceof IOException) throw (IOException) se.cause; @@ -116,10 +120,10 @@ public Response performRequest(String endpointId, @Nullable Node node, Request r public CompletableFuture performRequestAsync(String endpointId, @Nullable Node node, Request request, TransportOptions options) { int i = calls.getAndIncrement(); - Supplier step = script.get(Math.min(i, script.size() - 1)); + Function step = script.get(Math.min(i, script.size() - 1)); CompletableFuture f = new CompletableFuture<>(); try { - Object out = step.get(); + Object out = step.apply(true); f.complete((Response) out); } catch (FakeException se) { f.completeExceptionally(se.cause); @@ -161,9 +165,8 @@ static final class FakeResponse implements TransportHttpClient.Response { void syncRetriesOn5xxThenSucceeds() throws IOException { MockedClient client = new MockedClient() .andRespond(503).andRespond(502).andRespond(500).andRespond(200); - RetryingHttpClient retry = wrap(client, fixed(1L, 5)); - TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response resp = wrap(client).performRequest("ep", null, REQ, opts(fixed(1L, 5))); assertEquals(200, resp.statusCode()); assertEquals(4, client.calls.get()); @@ -172,9 +175,8 @@ void syncRetriesOn5xxThenSucceeds() throws IOException { @Test void syncRetriesOn429() throws IOException { MockedClient client = new MockedClient().andRespond(429).andRespond(200); - RetryingHttpClient retry = wrap(client, fixed(1L, 3)); - TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response resp = wrap(client).performRequest("ep", null, REQ, opts(fixed(1L, 3))); assertEquals(200, resp.statusCode()); assertEquals(2, client.calls.get()); @@ -183,9 +185,8 @@ void syncRetriesOn429() throws IOException { @Test void syncDoesNotRetryOn501() throws IOException { MockedClient client = new MockedClient().andRespond(501).andRespond(200); - RetryingHttpClient retry = wrap(client, fixed(1L, 3)); - TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response resp = wrap(client).performRequest("ep", null, REQ, opts(fixed(1L, 3))); assertEquals(501, resp.statusCode()); assertEquals(1, client.calls.get()); @@ -199,7 +200,7 @@ void customRetryableStatusesOnly() throws IOException { .retryableStatuses(418)); MockedClient client = new MockedClient().andRespond(503).andRespond(200); - TransportHttpClient.Response resp = wrap(client, cfg).performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response resp = wrap(client).performRequest("ep", null, REQ, opts(cfg)); assertEquals(503, resp.statusCode()); assertEquals(1, client.calls.get()); @@ -213,7 +214,7 @@ void customRetryableStatusesIncludingNonStandard() throws IOException { .retryableStatuses(408, 503)); MockedClient client = new MockedClient().andRespond(408).andRespond(200); - TransportHttpClient.Response resp = wrap(client, cfg).performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response resp = wrap(client).performRequest("ep", null, REQ, opts(cfg)); assertEquals(200, resp.statusCode()); assertEquals(2, client.calls.get()); @@ -228,7 +229,7 @@ void customRetryableExceptionsOnly() throws IOException { MockedClient client = new MockedClient().andThrow(new SocketException("not a timeout")).andRespond(200); IOException thrown = assertThrows(IOException.class, - () -> wrap(client, cfg).performRequest("ep", null, REQ, OPTS)); + () -> wrap(client).performRequest("ep", null, REQ, opts(cfg))); assertEquals("not a timeout", thrown.getMessage()); assertEquals(1, client.calls.get()); } @@ -241,7 +242,7 @@ void emptyRetryableStatusesDisablesStatusRetries() throws IOException { .retryableStatuses(new HashSet<>())); MockedClient client = new MockedClient().andRespond(503).andRespond(200); - TransportHttpClient.Response resp = wrap(client, cfg).performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response resp = wrap(client).performRequest("ep", null, REQ, opts(cfg)); assertEquals(503, resp.statusCode()); assertEquals(1, client.calls.get()); @@ -255,7 +256,7 @@ void emptyRetryableExceptionsDisablesExceptionRetries() { .retryableExceptions(new HashSet<>())); MockedClient client = new MockedClient().andThrow(new SocketException("io")).andRespond(200); - assertThrows(IOException.class, () -> wrap(client, cfg).performRequest("ep", null, REQ, OPTS)); + assertThrows(IOException.class, () -> wrap(client).performRequest("ep", null, REQ, opts(cfg))); assertEquals(1, client.calls.get()); } @@ -278,9 +279,8 @@ void retryConfigDefensivelyCopiesStatusSet() { @Test void syncDoesNotRetryOn4xx() throws IOException { MockedClient client = new MockedClient().andRespond(404).andRespond(200); - RetryingHttpClient retry = wrap(client, fixed(1L, 3)); - TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response resp = wrap(client).performRequest("ep", null, REQ, opts(fixed(1L, 3))); assertEquals(404, resp.statusCode()); assertEquals(1, client.calls.get()); @@ -291,7 +291,7 @@ void syncRetriesOnIOExceptionButNotOnRuntimeException() { MockedClient client1 = new MockedClient() .andThrow(new SocketException("boom")).andRespond(200); try { - TransportHttpClient.Response r = wrap(client1, fixed(1L, 3)).performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response r = wrap(client1).performRequest("ep", null, REQ, opts(fixed(1L, 3))); assertEquals(200, r.statusCode()); assertEquals(2, client1.calls.get()); } catch (IOException e) { @@ -301,7 +301,7 @@ void syncRetriesOnIOExceptionButNotOnRuntimeException() { MockedClient client2 = new MockedClient() .andThrow(new IllegalStateException("bad")).andRespond(200); assertThrows(IllegalStateException.class, - () -> wrap(client2, fixed(1L, 3)).performRequest("ep", null, REQ, OPTS)); + () -> wrap(client2).performRequest("ep", null, REQ, opts(fixed(1L, 3)))); assertEquals(1, client2.calls.get()); } @@ -309,9 +309,8 @@ void syncRetriesOnIOExceptionButNotOnRuntimeException() { void syncReturnsLastResponseWhenRetriesExhausted() throws IOException { MockedClient client = new MockedClient() .andRespond(503).andRespond(503).andRespond(503); - RetryingHttpClient retry = wrap(client, fixed(1L, 2)); - TransportHttpClient.Response resp = retry.performRequest("ep", null, REQ, OPTS); + TransportHttpClient.Response resp = wrap(client).performRequest("ep", null, REQ, opts(fixed(1L, 2))); assertEquals(503, resp.statusCode()); assertEquals(3, client.calls.get()); @@ -323,10 +322,9 @@ void syncThrowsLastIOExceptionWhenRetriesExhausted() { .andThrow(new SocketException("first")) .andThrow(new SocketException("second")) .andThrow(new SocketException("third")); - RetryingHttpClient retry = wrap(client, fixed(1L, 2)); IOException e = assertThrows(IOException.class, - () -> retry.performRequest("ep", null, REQ, OPTS)); + () -> wrap(client).performRequest("ep", null, REQ, opts(fixed(1L, 2)))); assertEquals("third", e.getMessage()); assertEquals(3, client.calls.get()); } @@ -335,9 +333,8 @@ void syncThrowsLastIOExceptionWhenRetriesExhausted() { void asyncRetriesOn5xxThenSucceeds() throws Exception { MockedClient client = new MockedClient() .andRespond(503).andRespond(502).andRespond(200); - RetryingHttpClient retry = wrap(client, fixed(1L, 5)); - TransportHttpClient.Response resp = retry.performRequestAsync("ep", null, REQ, OPTS) + TransportHttpClient.Response resp = wrap(client).performRequestAsync("ep", null, REQ, opts(fixed(1L, 5))) .get(5, TimeUnit.SECONDS); assertEquals(200, resp.statusCode()); @@ -348,9 +345,8 @@ void asyncRetriesOn5xxThenSucceeds() throws Exception { void asyncRetriesOnIOExceptionThenSucceeds() throws Exception { MockedClient client = new MockedClient() .andThrow(new SocketException("nope")).andRespond(200); - RetryingHttpClient retry = wrap(client, fixed(1L, 3)); - TransportHttpClient.Response resp = retry.performRequestAsync("ep", null, REQ, OPTS) + TransportHttpClient.Response resp = wrap(client).performRequestAsync("ep", null, REQ, opts(fixed(1L, 3))) .get(5, TimeUnit.SECONDS); assertEquals(200, resp.statusCode()); @@ -361,9 +357,9 @@ void asyncRetriesOnIOExceptionThenSucceeds() throws Exception { void asyncDoesNotRetryOnRuntimeException() { MockedClient client = new MockedClient() .andThrow(new IllegalArgumentException("nope")).andRespond(200); - RetryingHttpClient retry = wrap(client, fixed(1L, 3)); - CompletableFuture f = retry.performRequestAsync("ep", null, REQ, OPTS); + CompletableFuture f = + wrap(client).performRequestAsync("ep", null, REQ, opts(fixed(1L, 3))); CompletionException ex = assertThrows(CompletionException.class, f::join); assertTrue(ex.getCause() instanceof IllegalArgumentException); assertEquals(1, client.calls.get()); @@ -373,15 +369,26 @@ void asyncDoesNotRetryOnRuntimeException() { void asyncReturnsLastResponseWhenRetriesExhausted() throws Exception { MockedClient client = new MockedClient() .andRespond(503).andRespond(503).andRespond(503); - RetryingHttpClient retry = wrap(client, fixed(1L, 2)); - TransportHttpClient.Response resp = retry.performRequestAsync("ep", null, REQ, OPTS) + TransportHttpClient.Response resp = wrap(client).performRequestAsync("ep", null, REQ, opts(fixed(1L, 2))) .get(5, TimeUnit.SECONDS); assertEquals(503, resp.statusCode()); assertEquals(3, client.calls.get()); } + @Test + void disabledRetryConfigForwardsToDelegate() throws IOException { + // A disabled RetryConfig must not retry: a 503 is returned as-is on the first attempt. + MockedClient client = new MockedClient().andRespond(503).andRespond(200); + + TransportHttpClient.Response resp = wrap(client) + .performRequest("ep", null, REQ, opts(RetryConfig.disabled())); + + assertEquals(503, resp.statusCode()); + assertEquals(1, client.calls.get()); + } + /** * Verifies that cancelling the result future stops pending retries: with a long backoff (1 hour), * after we cancel during the first delay, the delegate must not be called again. @@ -409,9 +416,11 @@ public CompletableFuture performRequestAsync(String endpointId, @Nulla @Override public void close() {} }; - RetryingHttpClient retry = wrap(delegate, BackoffPolicy.constantBackoff(3_600_000L, 5)); // 1h delay + RetryingHttpClient retry = wrap(delegate); - CompletableFuture result = retry.performRequestAsync("ep", null, REQ, OPTS); + // 1h delay so the retry stays pending until we cancel + CompletableFuture result = retry.performRequestAsync( + "ep", null, REQ, opts(BackoffPolicy.constantBackoff(3_600_000L, 5))); // Wait for the first attempt to complete (we know it returned 503, so a retry is scheduled) assertTrue(firstCallReturned.await(5, TimeUnit.SECONDS)); @@ -433,33 +442,35 @@ public CompletableFuture performRequestAsync(String endpointId, @Nulla @Test void exceptionClassifierUsesConfiguredTypes() { + RetryingHttpClient client = wrap(new MockedClient()); + // Default behaviour: any IOException (and its subclasses) is retryable - RetryingHttpClient defaultClient = wrap(new MockedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)))); - assertTrue(defaultClient.isRetryableException(new SocketException("io"))); - assertTrue(defaultClient.isRetryableException(new RuntimeException(new IOException("wrapped")))); - assertFalse(defaultClient.isRetryableException(new IllegalArgumentException("bad"))); - assertFalse(defaultClient.isRetryableException(new NullPointerException())); + RetryConfig defaultCfg = RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1))); + assertTrue(client.isRetryableException(defaultCfg, new SocketException("io"))); + assertTrue(client.isRetryableException(defaultCfg, new RuntimeException(new IOException("wrapped")))); + assertFalse(client.isRetryableException(defaultCfg, new IllegalArgumentException("bad"))); + assertFalse(client.isRetryableException(defaultCfg, new NullPointerException())); // Narrowed to a specific subclass: only that subclass is retryable - RetryingHttpClient narrow = wrap(new MockedClient(), - RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableExceptions(SocketException.class))); - assertTrue(narrow.isRetryableException(new SocketException("io"))); - assertFalse(narrow.isRetryableException(new IOException("plain io"))); + RetryConfig narrowCfg = RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableExceptions(SocketException.class)); + assertTrue(client.isRetryableException(narrowCfg, new SocketException("io"))); + assertFalse(client.isRetryableException(narrowCfg, new IOException("plain io"))); } @Test void responseClassifierUsesConfiguredStatuses() { - RetryingHttpClient defaultClient = wrap(new MockedClient(), RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)))); - assertTrue(defaultClient.isRetryableStatus(new FakeResponse(500, Collections.emptyMap()))); - assertTrue(defaultClient.isRetryableStatus(new FakeResponse(429, Collections.emptyMap()))); - assertTrue(defaultClient.isRetryableStatus(new FakeResponse(503, Collections.emptyMap()))); - assertFalse(defaultClient.isRetryableStatus(new FakeResponse(501, Collections.emptyMap()))); - assertFalse(defaultClient.isRetryableStatus(new FakeResponse(404, Collections.emptyMap()))); - assertFalse(defaultClient.isRetryableStatus(new FakeResponse(200, Collections.emptyMap()))); - - RetryingHttpClient custom = wrap(new MockedClient(), - RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableStatuses(418))); - assertTrue(custom.isRetryableStatus(new FakeResponse(418, Collections.emptyMap()))); - assertFalse(custom.isRetryableStatus(new FakeResponse(503, Collections.emptyMap()))); + RetryingHttpClient client = wrap(new MockedClient()); + + RetryConfig defaultCfg = RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1))); + assertTrue(client.isRetryableStatus(defaultCfg, new FakeResponse(500, Collections.emptyMap()))); + assertTrue(client.isRetryableStatus(defaultCfg, new FakeResponse(429, Collections.emptyMap()))); + assertTrue(client.isRetryableStatus(defaultCfg, new FakeResponse(503, Collections.emptyMap()))); + assertFalse(client.isRetryableStatus(defaultCfg, new FakeResponse(501, Collections.emptyMap()))); + assertFalse(client.isRetryableStatus(defaultCfg, new FakeResponse(404, Collections.emptyMap()))); + assertFalse(client.isRetryableStatus(defaultCfg, new FakeResponse(200, Collections.emptyMap()))); + + RetryConfig customCfg = RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableStatuses(418)); + assertTrue(client.isRetryableStatus(customCfg, new FakeResponse(418, Collections.emptyMap()))); + assertFalse(client.isRetryableStatus(customCfg, new FakeResponse(503, Collections.emptyMap()))); } } diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingTest.java new file mode 100644 index 0000000000..51f6f1d145 --- /dev/null +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingTest.java @@ -0,0 +1,31 @@ +package co.elastic.clients.transport.http; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.transport.BackoffPolicy; + +import java.net.SocketException; +import java.util.List; +import java.util.Set; + +public class RetryingTest { + + public static void main(String[] args) { + String serverUrl = "http://localhost:9200"; + String apikey = "wrong"; + + try (ElasticsearchClient client = ElasticsearchClient.of(e -> e + .host(serverUrl) + .apiKey(apikey) + .retryConfig(r -> r + .backoffPolicy(BackoffPolicy.constantBackoff(5000L,5)) + ) + )) { + client + .withTransportOptions(t -> t.retryConfig(r -> r.backoffPolicy(BackoffPolicy.noBackoff()))) + .ping(); + } + catch (Exception e) { + // + } + } +} From 553e65b81859dc65004d356657d2720e0f6ec22d Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Thu, 9 Jul 2026 10:33:57 +0200 Subject: [PATCH 10/10] bugfixes, fetching inner status code --- .../transport/ElasticsearchTransportBase.java | 19 ++- .../clients/transport/RetryConfig.java | 41 +++++- .../transport/http/RetryingHttpClient.java | 96 ++++++------ .../transport/http/TransportHttpClient.java | 14 ++ .../rest5_client/Rest5ClientHttpClient.java | 10 ++ .../rest_client/RestClientHttpClient.java | 10 ++ .../ElasticsearchTransportRetryTest.java | 17 +++ .../transport/TransportRetryStatusTest.java | 138 ++++++++++++++++++ .../http/RetryingHttpClientTest.java | 72 ++++++++- .../clients/transport/http/RetryingTest.java | 31 ---- 10 files changed, 365 insertions(+), 83 deletions(-) create mode 100644 java-client/src/test/java/co/elastic/clients/transport/TransportRetryStatusTest.java delete mode 100644 java-client/src/test/java/co/elastic/clients/transport/http/RetryingTest.java diff --git a/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java b/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java index 2101342f85..66f682a2f9 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java +++ b/java-client/src/main/java/co/elastic/clients/transport/ElasticsearchTransportBase.java @@ -84,8 +84,11 @@ public abstract class ElasticsearchTransportBase implements ElasticsearchTranspo // Single retry wrapper, shared by every request that uses retries (whether configured on the client or per // request) and reading the effective RetryConfig from each call's TransportOptions. Created lazily on first // use, so clients that never use retries keep the exact same object graph as before and never allocate a - // retry scheduler. Guarded by `this` for creation. + // retry scheduler. Creation and close() are serialized on `this` so a wrapper can't be created after close + // and leak its scheduler. private volatile RetryingHttpClient retryingHttpClient; + // Guarded by `this` + private boolean closed; protected final Instrumentation instrumentation; protected final JsonpMapper mapper; protected final TransportOptions transportOptions; @@ -128,7 +131,11 @@ protected ElasticsearchTransportBase cloneWith( public void close() throws IOException { // The retry wrapper, if it was ever created, owns the delegate and the retry scheduler, so closing it // also closes httpClient and shuts down the scheduler. Otherwise close httpClient directly. - RetryingHttpClient retrying = retryingHttpClient; + RetryingHttpClient retrying; + synchronized (this) { + closed = true; + retrying = retryingHttpClient; + } if (retrying != null) { retrying.close(); } else { @@ -157,6 +164,11 @@ private TransportHttpClient httpClientFor(TransportOptions options) { if (retryConfig == null || !retryConfig.isEnabled()) { return httpClient; } + // If the user supplied a client that already retries, don't wrap it a second time: both layers would + // read the same RetryConfig and multiply attempts. + if (httpClient instanceof RetryingHttpClient) { + return httpClient; + } return retryingHttpClient(); } @@ -164,6 +176,9 @@ private RetryingHttpClient retryingHttpClient() { RetryingHttpClient client = retryingHttpClient; if (client == null) { synchronized (this) { + if (closed) { + throw new IllegalStateException("Transport has been closed"); + } client = retryingHttpClient; if (client == null) { client = new RetryingHttpClient(httpClient); diff --git a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java index 32c71b42ba..16a2d23eab 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java +++ b/java-client/src/main/java/co/elastic/clients/transport/RetryConfig.java @@ -19,6 +19,7 @@ package co.elastic.clients.transport; +import javax.annotation.Nullable; import java.io.IOException; import java.util.Objects; import java.util.Set; @@ -27,17 +28,30 @@ /** * Configuration for transport-level retries. A request is retried when: *
        - *
      • it fails with an exception that matches one of {@link #retryableExceptions()}, or
      • - *
      • it returns a response whose status code is in {@link #retryableStatuses()}.
      • + *
      • it results in a response — even one carried by an exception, such as the low-level clients' + * {@code ResponseException} — whose status code is in {@link #retryableStatuses()}, or
      • + *
      • it fails with an exception that matches one of {@link #retryableExceptions()}.
      • *
      * Either set may be empty to disable that retry route. *

      - * Delays and the maximum number of attempts are controlled by {@link #backoffPolicy()}. + * Delays and the maximum number of attempts are controlled by {@link #backoffPolicy()}, which must be set for + * retries to happen: retries are always opt-in. *

      * Scope: retries reissue the same logical request to the underlying transport. Node selection is delegated * to that transport (e.g. {@code Rest5Client}, {@code RestClient}), which manages its own node-rotation and * dead-node tracking independently. Configuring retries here does not control which node a retried - * request is sent to. + * request is sent to. Note that for some failures (e.g. 502/503/504 responses and connection errors) the + * underlying client already tries every configured node before reporting the failure, so the total number of + * attempts on the cluster can be up to {@code nodes × (retries + 1)}. + *

      + * Idempotency: a retried request may have already been received and processed by the server (e.g. when the + * connection drops after the request was sent), giving at-least-once semantics. Most Elasticsearch APIs are + * idempotent, but retrying operations that are not (e.g. bulk indexing without user-provided document ids) can + * duplicate their effects. Choose {@link #retryableExceptions()} accordingly. + *

      + * Memory: the request body stays referenced for the whole retry sequence, including backoff waits. With + * large requests (e.g. big bulks) and long backoffs, this pins the corresponding buffers in memory until the + * request completes or fails definitively. */ public final class RetryConfig { @@ -124,11 +138,19 @@ public Builder toBuilder() { } public static final class Builder { - private BackoffPolicy backoffPolicy = BackoffPolicy.noBackoff(); + // Null means "not set", which build() rejects: enabling retries requires an explicit backoff policy, + // and a config accidentally built without one must fail fast rather than silently never retry. + @Nullable + private BackoffPolicy backoffPolicy; private Set retryableStatuses = DEFAULT_RETRYABLE_STATUSES; private Set> retryableExceptions = DEFAULT_RETRYABLE_EXCEPTIONS; - public Builder backoffPolicy(BackoffPolicy policy) { + /** + * Sets the backoff policy controlling delays and the maximum number of retry attempts. Required. + * Passing {@link BackoffPolicy#noBackoff()} (or {@code null}) explicitly disables retries, like + * {@link RetryConfig#disabled()}. + */ + public Builder backoffPolicy(@Nullable BackoffPolicy policy) { this.backoffPolicy = policy == null ? BackoffPolicy.noBackoff() : policy; return this; } @@ -159,6 +181,13 @@ public final Builder retryableExceptions(Class... exception } public RetryConfig build() { + if (backoffPolicy == null) { + throw new IllegalStateException( + "A backoff policy is required to enable retries: set RetryConfig.Builder#backoffPolicy," + + " e.g. BackoffPolicy.exponentialBackoff(). To explicitly disable retries, use" + + " RetryConfig.disabled() or BackoffPolicy.noBackoff()." + ); + } return new RetryConfig(this); } } diff --git a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java index e5133fe54a..f9a248274f 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/http/RetryingHttpClient.java @@ -50,8 +50,11 @@ *

      * A request is retried when: *

        - *
      • the delegate fails with an exception matching {@link RetryConfig#retryableExceptions()}, or
      • - *
      • the delegate returns a response whose status code is in {@link RetryConfig#retryableStatuses()}.
      • + *
      • the delegate returns a response — or fails with an exception that the delegate recognizes as carrying + * a response, see {@link TransportHttpClient#responseStatusCode(Throwable)} — whose status code is in + * {@link RetryConfig#retryableStatuses()}, or
      • + *
      • the delegate fails with an exception matching {@link RetryConfig#retryableExceptions()}. Exceptions + * carrying a response are always classified by their status code, never by exception type.
      • *
      *

      * Notes: @@ -61,13 +64,13 @@ *

    • Retries reissue the same logical request to the {@code delegate}. Node selection (and any node-rotation / * dead-node tracking) is the delegate's responsibility: this wrapper does not influence which node a retried * request is sent to.
    • - *
    • The async path uses a {@link ScheduledExecutorService} to defer retries without blocking the calling thread - * pool. A single-thread daemon scheduler is created with this client and shut down by {@link #close()}. Its - * worker thread is started on the first actual retry and reaped once it has been idle longer than the largest - * backoff delay seen so far (then recreated on demand), so a client that isn't actively retrying holds no retry - * thread, while an in-progress retry sequence never reaps its worker between attempts. Users can supply their - * own scheduler via {@link #RetryingHttpClient(TransportHttpClient, ScheduledExecutorService)}, in which case - * its lifecycle (and idle behavior) is the user's responsibility.
    • + *
    • When retries are enabled, blocking requests are executed through the delegate's asynchronous path, so + * exception stack traces reflect the async machinery rather than the calling thread.
    • + *
    • Backoff delays are scheduled on a {@link ScheduledExecutorService}. A single daemon-thread + * scheduler is created with this client and shut down by {@link #close()}; its thread is started lazily on + * the first retry and stopped while idle. A scheduler can also be supplied via + * {@link #RetryingHttpClient(TransportHttpClient, ScheduledExecutorService)}, in which case its lifecycle is + * the caller's responsibility.
    • *
    */ public final class RetryingHttpClient implements TransportHttpClient { @@ -77,19 +80,18 @@ public final class RetryingHttpClient implements TransportHttpClient { // Instance counter, to name the retry thread private static final AtomicInteger idCounter = new AtomicInteger(); - // Margin added on top of a retry's backoff delay when sizing the worker's idle keep-alive, so the worker - // comfortably outlives the wait between two attempts. It also acts as the minimum idle window (for very short - // backoffs) and as the initial keep-alive before any retry is scheduled. See keepWorkerAliveFor. - private static final long IDLE_KEEP_ALIVE_MARGIN_MS = 1000L; + // Idle time (ms) after which the retry worker thread is stopped. During waits longer than this the worker + // wakes up once per interval, which is cheap; there is no need to track the backoff delays in use. + private static final long RETRY_SCHEDULER_KEEP_ALIVE_MS = 10_000L; private final TransportHttpClient delegate; // The scheduler used to defer retries. Its (single, daemon) worker thread is only started on the first - // scheduled retry and is reaped while idle (see defaultRetryScheduler / keepWorkerAliveFor), so a client - // that isn't actively retrying holds no retry thread. + // scheduled retry and is stopped while idle (see defaultRetryScheduler), so a client that isn't actively + // retrying holds no retry thread. private final ScheduledExecutorService retryScheduler; - // Same instance as retryScheduler when we created it ourselves, null for a user-supplied scheduler. Used to - // shut down and tune the idle keep-alive of a scheduler we own; we never reconfigure one we don't. + // Same instance as retryScheduler when we created it ourselves, null for a user-supplied scheduler, whose + // lifecycle is the caller's responsibility. Used to shut down a scheduler we own on close(). @Nullable private final ScheduledThreadPoolExecutor managedScheduler; @@ -115,6 +117,12 @@ public TransportOptions createOptions(@Nullable TransportOptions options) { return delegate.createOptions(options); } + @Nullable + @Override + public Integer responseStatusCode(Throwable exception) { + return delegate.responseStatusCode(exception); + } + @Override public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) throws IOException { @@ -122,9 +130,13 @@ public Response performRequest(String endpointId, @Nullable Node node, Request r if (!configOf(options).isEnabled()) { return delegate.performRequest(endpointId, node, request, options); } + CompletableFuture future = performRequestAsync(endpointId, node, request, options); try { - return performRequestAsync(endpointId, node, request, options).get(); + return future.get(); } catch (InterruptedException ie) { + // The caller has given up: stop the in-flight attempt and any pending retries, so they don't + // keep running (and holding the request body and a connection) with nobody waiting. + future.cancel(true); Thread.currentThread().interrupt(); throw new RuntimeException("thread waiting for the response was interrupted", ie); } catch (ExecutionException ee) { @@ -176,7 +188,7 @@ private void attemptAsync(String endpointId, @Nullable Node node, Request reques // Classify the outcome Throwable cause = err != null ? unwrap(err) : null; boolean retryable = cause != null - ? isRetryableException(config, cause) + ? isRetryableFailure(config, cause) : isRetryableStatus(config, resp); // Not retryable, or no retries left: complete with whatever we got @@ -204,8 +216,6 @@ private void attemptAsync(String endpointId, @Nullable Node node, Request reques } try { - // Make sure the worker survives this wait so the sequence doesn't churn its thread. - keepWorkerAliveFor(delayMs); ScheduledFuture scheduled = retryScheduler.schedule( () -> attemptAsync(endpointId, node, request, options, config, backoffIter, result), delayMs, TimeUnit.MILLISECONDS @@ -229,6 +239,23 @@ public void close() throws IOException { } } + /** + * Decides whether a failed attempt is retryable. Failures that the delegate recognizes as carrying an http + * response (see {@link TransportHttpClient#responseStatusCode(Throwable)}) are classified by their status + * code only: the server did answer, so the exception type must not make an otherwise non-retryable status + * retryable. Other failures are classified by exception type. + */ + boolean isRetryableFailure(RetryConfig config, Throwable err) { + Integer status = delegate.responseStatusCode(err); + if (status == null && err.getCause() != null) { + status = delegate.responseStatusCode(err.getCause()); + } + if (status != null) { + return config.retryableStatuses().contains(status); + } + return isRetryableException(config, err); + } + boolean isRetryableException(RetryConfig config, Throwable err) { return matchesRetryable(config, err) || matchesRetryable(config, err.getCause()); } @@ -278,34 +305,17 @@ private static ScheduledThreadPoolExecutor defaultRetryScheduler() { return t; }); // Don't keep an idle worker parked between (potentially infrequent) retry sequences: the thread is started - // on the first scheduled retry, reaped once it sits idle, and recreated on demand. The idle window starts - // at the margin below and is grown to track the backoff in use (see keepWorkerAliveFor) so an active - // sequence never reaps its worker between attempts. - scheduler.setKeepAliveTime(IDLE_KEEP_ALIVE_MARGIN_MS, TimeUnit.MILLISECONDS); + // on the first scheduled retry, stopped once it has been idle for the keep-alive window, and recreated on + // demand. A pending scheduled retry always keeps the worker alive (the pool never stops its last worker + // while the queue is non-empty), so an in-progress sequence is never affected. + scheduler.setKeepAliveTime(RETRY_SCHEDULER_KEEP_ALIVE_MS, TimeUnit.MILLISECONDS); scheduler.allowCoreThreadTimeOut(true); // Cancelled retries (e.g. after request cancellation) are removed from the queue immediately so they - // don't keep the worker alive or delay its reaping. + // don't keep the worker alive or delay it being stopped. scheduler.setRemoveOnCancelPolicy(true); return scheduler; } - /** - * Grows the managed scheduler's idle keep-alive so its single worker outlives the wait before the next attempt - * ({@code delayMs} plus a margin) and isn't reaped/recreated mid-sequence. We only ever increase it, so it ends - * up tracking the largest backoff delay seen; an idle scheduler then reaps its worker after that window. No-op - * for a user-supplied scheduler, which we never reconfigure. - */ - private void keepWorkerAliveFor(long delayMs) { - ScheduledThreadPoolExecutor scheduler = managedScheduler; - if (scheduler == null || delayMs <= 0) { - return; - } - long desiredMs = delayMs + IDLE_KEEP_ALIVE_MARGIN_MS; - if (desiredMs > 0 && scheduler.getKeepAliveTime(TimeUnit.MILLISECONDS) < desiredMs) { - scheduler.setKeepAliveTime(desiredMs, TimeUnit.MILLISECONDS); - } - } - /** * Result future that propagates cancellation to both the in-flight delegate request and any pending scheduled retry. */ diff --git a/java-client/src/main/java/co/elastic/clients/transport/http/TransportHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/http/TransportHttpClient.java index 4a8b3ffa33..3f587e08d0 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/http/TransportHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/http/TransportHttpClient.java @@ -50,6 +50,20 @@ default TransportOptions createOptions(@Nullable TransportOptions options) { return options == null ? DefaultTransportOptions.EMPTY : options; } + /** + * If the given exception carries an http response — like the low-level clients' {@code ResponseException}, + * which reports non-2xx statuses as an exception rather than as a response — returns that response's status + * code, otherwise {@code null}. + *

    + * This allows implementation-agnostic wrappers (e.g. {@link RetryingHttpClient}) to classify such failures + * by status code without knowing the underlying http library. Implementations should override this for + * their own response-carrying exception types. + */ + @Nullable + default Integer responseStatusCode(Throwable exception) { + return null; + } + /** * Perform a blocking request. * diff --git a/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientHttpClient.java index a8649fe65c..e401822749 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/rest5_client/Rest5ClientHttpClient.java @@ -23,6 +23,7 @@ import co.elastic.clients.transport.http.HeaderMap; import co.elastic.clients.transport.http.TransportHttpClient; import co.elastic.clients.transport.rest5_client.low_level.Cancellable; +import co.elastic.clients.transport.rest5_client.low_level.ResponseException; import co.elastic.clients.transport.rest5_client.low_level.ResponseListener; import co.elastic.clients.transport.rest5_client.low_level.Rest5Client; import co.elastic.clients.util.BinaryData; @@ -85,6 +86,15 @@ public Rest5ClientOptions createOptions(@Nullable TransportOptions options) { return Rest5ClientOptions.of(options); } + @Nullable + @Override + public Integer responseStatusCode(Throwable exception) { + if (exception instanceof ResponseException) { + return ((ResponseException) exception).getResponse().getStatusCode(); + } + return null; + } + @Override public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) throws IOException { diff --git a/java-client/src/main/java/co/elastic/clients/transport/rest_client/RestClientHttpClient.java b/java-client/src/main/java/co/elastic/clients/transport/rest_client/RestClientHttpClient.java index 85b92c1070..7d2165c5d6 100644 --- a/java-client/src/main/java/co/elastic/clients/transport/rest_client/RestClientHttpClient.java +++ b/java-client/src/main/java/co/elastic/clients/transport/rest_client/RestClientHttpClient.java @@ -30,6 +30,7 @@ import org.apache.http.entity.ContentType; import org.apache.http.util.EntityUtils; import org.elasticsearch.client.Cancellable; +import org.elasticsearch.client.ResponseException; import org.elasticsearch.client.ResponseListener; import org.elasticsearch.client.RestClient; @@ -84,6 +85,15 @@ public RestClientOptions createOptions(@Nullable TransportOptions options) { return RestClientOptions.of(options); } + @Nullable + @Override + public Integer responseStatusCode(Throwable exception) { + if (exception instanceof ResponseException) { + return ((ResponseException) exception).getResponse().getStatusLine().getStatusCode(); + } + return null; + } + @Override public Response performRequest(String endpointId, @Nullable Node node, Request request, TransportOptions options) throws IOException { diff --git a/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportRetryTest.java b/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportRetryTest.java index 4c9bf6b93b..5204d72b30 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportRetryTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/ElasticsearchTransportRetryTest.java @@ -23,6 +23,7 @@ import co.elastic.clients.elasticsearch.core.CountResponse; import co.elastic.clients.testkit.MockHttpClient; import co.elastic.clients.testkit.ModelTestCase; +import co.elastic.clients.transport.http.RetryingHttpClient; import co.elastic.clients.transport.http.TransportHttpClient; import co.elastic.clients.util.BinaryData; import org.junit.jupiter.api.Test; @@ -174,6 +175,22 @@ public void close() { assertEquals(1, calls.get(), "delegate must not be called again after cancellation"); } + @Test + public void alreadyRetryingHttpClientIsNotWrappedAgain() throws IOException { + // If the http client handed to the transport already retries, the transport must not add a second + // retry layer: both layers would read the same RetryConfig and multiply attempts. + ScriptedHttpClient http = scriptedClient(100); + ElasticsearchTransportBase transport = + new ElasticsearchTransportBase(new RetryingHttpClient(http), retryOptions(), mapper) {}; + + assertThrows(TransportException.class, + () -> transport.performRequest(CountRequest.of(c -> c), CountRequest._ENDPOINT, null)); + + // FAST allows 3 retries: 4 attempts with a single retry layer, 16 with an accidental double layer. + assertEquals(4, http.calls.get()); + transport.close(); + } + @Test public void perRequestDisableOverridesClientRetry() { ScriptedHttpClient http = scriptedClient(5); diff --git a/java-client/src/test/java/co/elastic/clients/transport/TransportRetryStatusTest.java b/java-client/src/test/java/co/elastic/clients/transport/TransportRetryStatusTest.java new file mode 100644 index 0000000000..dc0ad08e70 --- /dev/null +++ b/java-client/src/test/java/co/elastic/clients/transport/TransportRetryStatusTest.java @@ -0,0 +1,138 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package co.elastic.clients.transport; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.transport.rest5_client.Rest5ClientTransport; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketException; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +/** + * Verifies status-based retries through the real http clients. Both low-level clients surface + * retryable statuses as {@code ResponseException} rather than as a plain response (the rest5 client for 5xx, + * the legacy client for every non-ignored error status), so these tests ensure the retry classification + * extracts status codes from those exceptions instead of matching them as generic {@link IOException}s. + * Exception-based retries are deliberately narrowed or disabled in these tests: they must not be the reason + * a request is retried. + */ +public class TransportRetryStatusTest extends Assertions { + + private HttpServer server; + private final AtomicInteger hits = new AtomicInteger(); + + @BeforeEach + public void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.start(); + } + + @AfterEach + public void tearDown() { + server.stop(0); + } + + // Responds to ping (HEAD /) with the scripted statuses, then 200 for any further request. + private String scriptedPingServer(int... statuses) { + server.createContext("/", exchange -> { + int i = hits.getAndIncrement(); + int status = i < statuses.length ? statuses[i] : 200; + if (status == 200) { + exchange.getResponseHeaders().set("X-Elastic-Product", "Elasticsearch"); + } + exchange.sendResponseHeaders(status, -1); + exchange.close(); + }); + var address = server.getAddress(); + return "http://" + address.getHostString() + ":" + address.getPort(); + } + + private ElasticsearchClient client( + String url, + Function transportFactory, + RetryConfig retryConfig + ) { + return ElasticsearchClient.of(b -> b + .host(url) + .transportFactory(transportFactory) + .retryConfig(retryConfig) + ); + } + + @Test + public void rest5RetriesOn5xxStatusWithNarrowedExceptions() throws IOException { + // The rest5 client reports a 503 as a ResponseException. With exception retries narrowed to + // SocketException, only the status route can trigger the retries. + String url = scriptedPingServer(503, 503); + + RetryConfig config = RetryConfig.of(r -> r + .backoffPolicy(BackoffPolicy.constantBackoff(1L, 5)) + .retryableStatuses(503) + .retryableExceptions(SocketException.class)); + + try (ElasticsearchClient client = client(url, Rest5ClientTransport::new, config)) { + assertTrue(client.ping().value()); + } + assertEquals(3, hits.get()); + } + + @Test + public void rest5DoesNotRetryStatusesOutsideTheConfiguredSet() throws IOException { + // A 501 also surfaces as a ResponseException (an IOException). It must be classified by its status + // code - not in the default retryable set - and not retried as a generic IOException. + String url = scriptedPingServer(501); + + RetryConfig config = RetryConfig.of(r -> r.backoffPolicy(BackoffPolicy.constantBackoff(1L, 5))); + + try (ElasticsearchClient client = client(url, Rest5ClientTransport::new, config)) { + assertThrows(IOException.class, () -> client.ping()); + } + assertEquals(1, hits.get()); + } + + @Test + public void legacyRetriesOnStatusWithExceptionRetriesDisabled() throws IOException { + // The legacy client reports every non-ignored error status (429 included) as a ResponseException, + // which is accessed reflectively. Exception-based retries are fully disabled here: only the status + // route can trigger the retries. + String url = scriptedPingServer(429, 429); + + RetryConfig config = RetryConfig.of(r -> r + .backoffPolicy(BackoffPolicy.constantBackoff(1L, 5)) + .retryableStatuses(429) + .retryableExceptions(Set.of())); + + try (ElasticsearchClient client = client(url, RestClientTransport::new, config)) { + assertTrue(client.ping().value()); + } + assertEquals(3, hits.get()); + } +} diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java index f6c3a1f0a5..36c79257c2 100644 --- a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java +++ b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingHttpClientTest.java @@ -265,7 +265,7 @@ void retryConfigDefensivelyCopiesStatusSet() { HashSet mutable = new HashSet<>(); mutable.add(429); mutable.add(503); - RetryConfig cfg = RetryConfig.of(r -> r.retryableStatuses(mutable)); + RetryConfig cfg = RetryConfig.of(r -> r.backoffPolicy(fixed(1L, 1)).retryableStatuses(mutable)); // Mutating the original set after building must not affect the config. mutable.add(418); @@ -276,6 +276,20 @@ void retryConfigDefensivelyCopiesStatusSet() { () -> cfg.retryableStatuses().add(999)); } + @Test + void retryConfigRequiresABackoffPolicy() { + // Customizing retry conditions without choosing a backoff policy must fail fast instead of silently + // building a config that never retries. + assertThrows(IllegalStateException.class, + () -> RetryConfig.of(r -> r.retryableStatuses(429, 503))); + assertThrows(IllegalStateException.class, () -> RetryConfig.builder().build()); + + // Explicitly choosing no backoff remains a valid way to build a disabled config (per-request opt-out). + RetryConfig disabled = RetryConfig.of(r -> r.backoffPolicy(BackoffPolicy.noBackoff())); + assertFalse(disabled.isEnabled()); + assertFalse(RetryConfig.disabled().isEnabled()); + } + @Test void syncDoesNotRetryOn4xx() throws IOException { MockedClient client = new MockedClient().andRespond(404).andRespond(200); @@ -457,6 +471,62 @@ void exceptionClassifierUsesConfiguredTypes() { assertFalse(client.isRetryableException(narrowCfg, new IOException("plain io"))); } + /** An exception carrying an http status, like the low-level clients' ResponseException. */ + static final class StatusException extends IOException { + final int status; + StatusException(int status) { + super("status " + status); + this.status = status; + } + } + + /** A delegate that recognizes StatusException, like the real adapters recognize ResponseException. */ + static class StatusAwareClient extends MockedClient { + @Override + public Integer responseStatusCode(Throwable exception) { + return exception instanceof StatusException ? ((StatusException) exception).status : null; + } + } + + @Test + void failureClassifierUsesDelegateProvidedStatusOverExceptionType() { + RetryingHttpClient client = wrap(new StatusAwareClient()); + RetryConfig cfg = RetryConfig.of(r -> r + .backoffPolicy(fixed(1L, 1)) + .retryableStatuses(429, 503)); + + // Exceptions recognized by the delegate as carrying a response are classified by status code only: + // even though StatusException is an IOException (the default retryable exception), a non-retryable + // status must not be retried. + assertTrue(client.isRetryableFailure(cfg, new StatusException(503))); + assertFalse(client.isRetryableFailure(cfg, new StatusException(501))); + // Also when the status-carrying exception is wrapped + assertFalse(client.isRetryableFailure(cfg, new RuntimeException(new StatusException(501)))); + + // Ordinary exceptions don't carry a status code and are classified by type. + assertTrue(client.isRetryableFailure(cfg, new SocketException("io"))); + assertFalse(client.isRetryableFailure(cfg, new IllegalArgumentException("bad"))); + } + + @Test + void asyncRetriesOnStatusCarryingException() throws Exception { + // End-to-end: a 503 surfacing as an exception is retried, then the request succeeds. Exception-based + // retries are disabled, so only the status extracted from the exception can trigger the retry. + MockedClient client = new StatusAwareClient(); + client.andThrow(new StatusException(503)).andRespond(200); + + RetryConfig cfg = RetryConfig.of(r -> r + .backoffPolicy(fixed(1L, 3)) + .retryableExceptions(new HashSet<>())); + + TransportHttpClient.Response resp = wrap(client) + .performRequestAsync("ep", null, REQ, opts(cfg)) + .get(5, TimeUnit.SECONDS); + + assertEquals(200, resp.statusCode()); + assertEquals(2, client.calls.get()); + } + @Test void responseClassifierUsesConfiguredStatuses() { RetryingHttpClient client = wrap(new MockedClient()); diff --git a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingTest.java b/java-client/src/test/java/co/elastic/clients/transport/http/RetryingTest.java deleted file mode 100644 index 51f6f1d145..0000000000 --- a/java-client/src/test/java/co/elastic/clients/transport/http/RetryingTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package co.elastic.clients.transport.http; - -import co.elastic.clients.elasticsearch.ElasticsearchClient; -import co.elastic.clients.transport.BackoffPolicy; - -import java.net.SocketException; -import java.util.List; -import java.util.Set; - -public class RetryingTest { - - public static void main(String[] args) { - String serverUrl = "http://localhost:9200"; - String apikey = "wrong"; - - try (ElasticsearchClient client = ElasticsearchClient.of(e -> e - .host(serverUrl) - .apiKey(apikey) - .retryConfig(r -> r - .backoffPolicy(BackoffPolicy.constantBackoff(5000L,5)) - ) - )) { - client - .withTransportOptions(t -> t.retryConfig(r -> r.backoffPolicy(BackoffPolicy.noBackoff()))) - .ping(); - } - catch (Exception e) { - // - } - } -}