Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9524c1f
feat: add automatic S3 request retry with exponential backoff
allanrogerr May 4, 2026
a79aa11
fix: address Copilot review on retry mechanism
allanrogerr May 5, 2026
beead6e
fix: strengthen exception assertions in RetryTest and guard regionCac…
allanrogerr May 5, 2026
53da859
fix: replace instanceof assertions with typed catch to satisfy SpotBu…
allanrogerr May 5, 2026
60aeaed
fix: use ThreadLocalRandom for backoff jitter, disable OkHttp retry, …
allanrogerr May 5, 2026
d1c52e0
style: fix Spotless formatting violations in BaseS3Client
allanrogerr May 5, 2026
0d687ec
fix: propagate runAsync dispatch failure into retryFuture
allanrogerr May 5, 2026
34abd64
refactor: move retry to OkHttp interceptor, drop S3-code retry
allanrogerr May 7, 2026
1997b48
fix: narrow throws clauses in RetryTest to satisfy SpotBugs
allanrogerr May 7, 2026
b2cad44
Merge branch 'master' into feature/retry-mechanism
allanrogerr May 7, 2026
ba65312
refactor: strip retry to balamurugana's interceptor proposal scope
allanrogerr May 7, 2026
6d39a12
feat: restore full retry capability on top of OkHttp interceptor
allanrogerr May 7, 2026
756e294
docs(retry): clarify RetryInterceptor as supported API; add terminal-…
allanrogerr May 7, 2026
2f2fb4f
fix(retry): cancellation awareness, broader docs, tighter scope, more…
allanrogerr May 7, 2026
7fe6e87
fix(retry): drop public Javadoc links to package-private Retry; strip…
allanrogerr May 7, 2026
9a5c8e4
fix(retry): address bala review on PR #1701 review 4248622939
allanrogerr May 8, 2026
93e4f47
fix(retry): address bala review batch on PR #1701 review 4250022733
allanrogerr May 8, 2026
0ae488b
fix(retry): drop Retry.java; revert createBody bracket per r3206778779
allanrogerr May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 66 additions & 6 deletions api/src/main/java/io/minio/BaseS3Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.logging.Logger;
import javax.annotation.Nonnull;
Expand Down Expand Up @@ -103,6 +106,15 @@ public abstract class BaseS3Client implements AutoCloseable {
private static final String UPLOAD_ID = "uploadId";
private static final Set<String> TRACE_QUERY_PARAMS =
ImmutableSet.of("retention", "legal-hold", "tagging", UPLOAD_ID, "acl", "attributes");

private static final ScheduledExecutorService RETRY_SCHEDULER =
Executors.newSingleThreadScheduledExecutor(
r -> {
Thread t = new Thread(r, "minio-retry-scheduler");
t.setDaemon(true);
return t;
});
Comment thread
allanrogerr marked this conversation as resolved.
Outdated

private PrintWriter traceStream;
protected final Map<String, String> regionCache = new ConcurrentHashMap<>();
protected String userAgent = Utils.getDefaultUserAgent();
Expand All @@ -111,6 +123,7 @@ public abstract class BaseS3Client implements AutoCloseable {
protected Provider provider;
protected OkHttpClient httpClient;
protected boolean closeHttpClient;
protected volatile int maxRetries = Retry.MAX_RETRY;

protected BaseS3Client(
Http.BaseUrl baseUrl, Provider provider, OkHttpClient httpClient, boolean closeHttpClient) {
Expand All @@ -125,6 +138,7 @@ protected BaseS3Client(BaseS3Client client) {
this.provider = client.provider;
this.httpClient = client.httpClient;
this.closeHttpClient = client.closeHttpClient;
this.maxRetries = client.maxRetries;
}

/** Closes underneath HTTP client. */
Expand All @@ -136,6 +150,18 @@ public void close() {
}
}

/**
* Sets the maximum number of retry attempts for failed S3 requests. Requests with non-seekable
* bodies are never retried regardless of this value. The default is {@code Retry.MAX_RETRY} (10).
* Pass 1 to disable automatic retries.
*
* @param maxRetries maximum attempts (must be >= 1).
*/
public void setMaxRetries(int maxRetries) {
if (maxRetries < 1) throw new IllegalArgumentException("maxRetries must be >= 1");
this.maxRetries = maxRetries;
}

/**
* Sets HTTP connect, write and read timeouts. A value of 0 means no timeout, otherwise values
* must be between 1 and Integer.MAX_VALUE when converted to milliseconds.
Expand Down Expand Up @@ -270,8 +296,47 @@ private String[] handleRedirectResponse(
return new String[] {code, message};
}

/** Execute HTTP request asynchronously for given parameters. */
/** Execute HTTP request asynchronously for given parameters, with automatic retry. */
protected CompletableFuture<Response> executeAsync(Http.S3Request s3request, String region) {
// Non-seekable bodies (raw okhttp3 RequestBody) cannot be replayed — single attempt only.
Http.Body body = s3request.body();
int maxAttempts = (body != null && body.isHttpRequestBody()) ? 1 : this.maxRetries;
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
return executeWithRetry(s3request, region, maxAttempts, 0);
}

private CompletableFuture<Response> executeWithRetry(
Http.S3Request s3request, String region, int maxAttempts, int attempt) {
return doExecuteAsync(s3request, region)
.handle(
(response, throwable) -> {
if (throwable == null) {
return CompletableFuture.completedFuture(response);
}
Throwable cause =
(throwable instanceof CompletionException) ? throwable.getCause() : throwable;
if (cause == null) cause = throwable;
if (attempt + 1 >= maxAttempts || !Retry.isRetryable(cause)) {
return Utils.<Response>failedFuture(cause);
}
long delayMs = Retry.computeBackoffMs(attempt + 1, RANDOM);
CompletableFuture<Response> retryFuture = new CompletableFuture<>();
RETRY_SCHEDULER.schedule(
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
() ->
executeWithRetry(s3request, region, maxAttempts, attempt + 1)
.whenComplete(
(r, t) -> {
if (t != null) retryFuture.completeExceptionally(t);
else retryFuture.complete(r);
}),
delayMs,
TimeUnit.MILLISECONDS);
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
return retryFuture;
})
.thenCompose(cf -> cf);
}

/** Execute single HTTP request attempt asynchronously for given parameters. */
private CompletableFuture<Response> doExecuteAsync(Http.S3Request s3request, String region) {
Credentials credentials = (provider == null) ? null : provider.fetch();
Http.Request request = null;
try {
Expand All @@ -285,11 +350,6 @@ protected CompletableFuture<Response> executeAsync(Http.S3Request s3request, Str
if (traceStream != null) traceStream.print(request.httpTraces());

OkHttpClient httpClient = this.httpClient;
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
Comment thread
allanrogerr marked this conversation as resolved.
// FIXME: enable retry for all request.
// if (!s3request.retryFailure()) {
// httpClient = httpClient.newBuilder().retryOnConnectionFailure(false).build();
// }

okhttp3.Request httpRequest = request.httpRequest();
CompletableFuture<Response> completableFuture = newCompleteableFuture();
httpClient
Expand Down
5 changes: 5 additions & 0 deletions api/src/main/java/io/minio/Http.java
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,11 @@ public String object() {
return object;
}

/** Returns the request body, or {@code null} if none was set. */
public Body body() {
return body;
}

private Request toRequest(
BaseUrl baseUrl, String region, Credentials credentials, Integer expiry)
throws MinioException {
Expand Down
16 changes: 15 additions & 1 deletion api/src/main/java/io/minio/MinioAsyncClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ public static final class Builder {
private Provider provider;
private OkHttpClient httpClient;
private boolean closeHttpClient;
private int maxRetries = Retry.MAX_RETRY;

public Builder baseUrl(Http.BaseUrl baseUrl) {
if (baseUrl.region() == null) {
Expand Down Expand Up @@ -217,6 +218,16 @@ public Builder httpClient(OkHttpClient httpClient, boolean close) {
return this;
}

/**
* Sets the maximum number of retry attempts per request. Pass 1 to disable automatic retries.
* Defaults to {@link Retry#MAX_RETRY} (10).
*/
public Builder maxRetries(int maxRetries) {
if (maxRetries < 1) throw new IllegalArgumentException("maxRetries must be >= 1");
this.maxRetries = maxRetries;
return this;
}
Comment thread
allanrogerr marked this conversation as resolved.

public MinioAsyncClient build() {
Utils.validateNotNull(baseUrl, "endpoint");

Expand All @@ -232,7 +243,10 @@ public MinioAsyncClient build() {
httpClient = Http.newDefaultClient();
}

return new MinioAsyncClient(baseUrl, provider, httpClient, closeHttpClient);
MinioAsyncClient client =
new MinioAsyncClient(baseUrl, provider, httpClient, closeHttpClient);
client.maxRetries = maxRetries;
return client;
}
}

Expand Down
18 changes: 18 additions & 0 deletions api/src/main/java/io/minio/MinioClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -1979,6 +1979,15 @@ public void setAwsS3Prefix(String awsS3Prefix) {
asyncClient.setAwsS3Prefix(awsS3Prefix);
}

/**
* Sets the maximum number of retry attempts. Pass 1 to disable automatic retries.
*
* @param maxRetries maximum attempts (must be >= 1).
*/
public void setMaxRetries(int maxRetries) {
asyncClient.setMaxRetries(maxRetries);
}

/** Closes underneath async client. */
@Override
public void close() throws Exception {
Expand Down Expand Up @@ -2043,6 +2052,15 @@ public Builder httpClient(OkHttpClient httpClient, boolean close) {
return this;
}

/**
* Sets the maximum number of retry attempts per request. Pass 1 to disable automatic retries.
* Defaults to {@link Retry#MAX_RETRY} (10).
*/
public Builder maxRetries(int maxRetries) {
asyncClientBuilder.maxRetries(maxRetries);
return this;
}
Comment thread
allanrogerr marked this conversation as resolved.

public MinioClient build() {
MinioAsyncClient asyncClient = asyncClientBuilder.build();
return new MinioClient(asyncClient);
Expand Down
152 changes: 152 additions & 0 deletions api/src/main/java/io/minio/Retry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
* MinIO Java SDK for Amazon S3 Compatible Cloud Storage, (C) 2026 MinIO, Inc.
*
* Licensed 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 io.minio;

import com.google.common.collect.ImmutableSet;
import io.minio.errors.ErrorResponseException;
import io.minio.errors.InvalidResponseException;
import io.minio.errors.ServerException;
import java.io.IOException;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.CompletionException;
import javax.net.ssl.SSLHandshakeException;

/** Retry configuration and helpers for S3 request execution. */
class Retry {
/** Default maximum number of retry attempts per request. */
static final int MAX_RETRY = 10;

/** Base sleep unit for exponential backoff (milliseconds). */
static final long RETRY_BASE_MS = 200L;

/** Maximum sleep cap for exponential backoff (milliseconds). */
static final long RETRY_CAP_MS = 1_000L;

/**
* S3 error codes that should trigger a retry. Matches the retryableS3Codes set from minio-go
* retry.go.
*/
private static final Set<String> RETRYABLE_S3_CODES =
ImmutableSet.of(
"RequestError",
"RequestTimeout",
"Throttling",
"ThrottlingException",
"RequestLimitExceeded",
"RequestThrottled",
"InternalError",
"ExpiredToken",
"ExpiredTokenException",
"SlowDown",
"SlowDownWrite",
"SlowDownRead");

/**
* HTTP status codes that should trigger a retry. Matches retryableHTTPStatusCodes from minio-go
* retry.go.
*/
private static final Set<Integer> RETRYABLE_HTTP_CODES =
ImmutableSet.of(
408, // Request Timeout
429, // Too Many Requests
499, // Client Closed Request (nginx)
500, // Internal Server Error
502, // Bad Gateway
503, // Service Unavailable
504, // Gateway Timeout
520 // Cloudflare unknown error
);
Comment thread
allanrogerr marked this conversation as resolved.
Outdated

static boolean isRetryableS3Code(String code) {
return code != null && RETRYABLE_S3_CODES.contains(code);
}

static boolean isRetryableHttpCode(int code) {
return RETRYABLE_HTTP_CODES.contains(code);
}

/**
* Returns true if the IOException is retryable. Non-retryable: TLS handshake failures, HTTP/HTTPS
* protocol mismatch. Everything else (connection reset, EOF, server closed idle connection) is
* retried.
*/
static boolean isRetryableIOException(IOException e) {
// TLS certificate / handshake failures are not retryable.
if (e instanceof SSLHandshakeException) return false;
String msg = e.getMessage();
// Protocol mismatch is not retryable.
if (msg != null && msg.contains("server gave HTTP response to HTTPS client")) return false;
return true;
}

/**
* Returns true if the throwable represents a retryable failure. Handles IOException,
* ErrorResponseException, ServerException, and InvalidResponseException.
*/
static boolean isRetryable(Throwable t) {
if (t instanceof CompletionException) t = t.getCause();
if (t == null) return false;

if (t instanceof IOException) {
return isRetryableIOException((IOException) t);
}

if (t instanceof ErrorResponseException) {
ErrorResponseException e = (ErrorResponseException) t;
String code = e.errorResponse().code();
// "RetryHead" is handled separately by executeHeadAsync — must not be swallowed here.
if ("RetryHead".equals(code)) return false;
if (isRetryableS3Code(code)) return true;
if (e.response() != null && isRetryableHttpCode(e.response().code())) return true;
return false;
}

if (t instanceof ServerException) {
return isRetryableHttpCode(((ServerException) t).statusCode());
}

if (t instanceof InvalidResponseException) {
return isRetryableHttpCode(((InvalidResponseException) t).responseCode());
}

return false;
}

/**
* Computes the full-jitter exponential backoff delay for retry {@code attempt} (1-indexed: 1 =
* first retry). Matches minio-go's {@code exponentialBackoffWait(i)}:
*
* <pre>
* attempt=1 → [0, 200 ms]
* attempt=2 → [0, 400 ms]
* attempt=3 → [0, 800 ms]
* attempt=4+→ [0, 1000 ms] (capped)
* </pre>
*
* Pass {@code attempt <= 0} to get 0 (no delay).
*/
static long computeBackoffMs(int attempt, Random random) {
if (attempt <= 0) return 0L;
// exp = attempt-1 so that attempt=1 maps to base*2^0=200ms cap
int exp = Math.min(attempt - 1, 30);
long cap = Math.min(RETRY_CAP_MS, RETRY_BASE_MS * (1L << exp));
return (long) (random.nextDouble() * cap);
}

private Retry() {}
}
Comment thread
allanrogerr marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
public class InvalidResponseException extends MinioException {
private static final long serialVersionUID = -4793742105569629274L;

private final int responseCode;

public InvalidResponseException(
int responseCode, String contentType, String body, String httpTrace) {
super(
Expand All @@ -30,5 +32,11 @@ public InvalidResponseException(
+ ", body: "
+ body,
httpTrace);
this.responseCode = responseCode;
}

/** Returns the HTTP response code that triggered this exception. */
public int responseCode() {
return responseCode;
}
}
Loading
Loading