Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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
91 changes: 85 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,49 @@ 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
() ->
CompletableFuture.runAsync(
() ->
executeWithRetry(s3request, region, maxAttempts, attempt + 1)
.whenComplete(
(r, t) -> {
if (t != null) retryFuture.completeExceptionally(t);
else retryFuture.complete(r);
})),
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
delayMs,
TimeUnit.MILLISECONDS);
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 +352,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 Expand Up @@ -1225,6 +1287,15 @@ private Object[] createBody(PutObjectAPIBaseArgs args, MediaType contentType)
boolean checksumHeader = headers.namePrefixAny("x-amz-checksum-");
String md5Hash = headers.getFirst(Http.Headers.CONTENT_MD5);

long fileStartPos = 0;
if (args.file() != null) {
try {
fileStartPos = args.file().getFilePointer();
} catch (IOException e) {
throw new MinioException(e);
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why do we need?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Each file.read(...) advances the file pointer. After hashing a length-byte file, the pointer is at fileStartPos + length. So whatever position the pointer is at when new Http.Body(...) runs becomes the offset that subsequent retries seek back to before each network attempt. Without the restore at line 1335, that captured offset would be fileStartPos + length instead of fileStartPos, and every PUT — first attempt and retries alike — would write from the wrong offset (typically EOF, producing an empty or truncated upload). Http.Body doesnt handle this.

minio-go does this in api-put-object-streaming.go:682-705

This comment was marked as duplicate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removing this. in order to do so, Checksum.update's RandomAccessFile branch must be rewritten to use FileChannel.read(ByteBuffer, long position) so it no longer mutates the file's pointer.
PTAL @balamurugana


if (sha256HexString == null && sha256Base64String == null) {
if (!baseUrl.isHttps()) {
Checksum.Hasher hasher = Checksum.Algorithm.SHA256.hasher();
Expand Down Expand Up @@ -1280,6 +1351,14 @@ private Object[] createBody(PutObjectAPIBaseArgs args, MediaType contentType)
}
}

if (args.file() != null) {
try {
args.file().seek(fileStartPos);
} catch (IOException e) {
throw new MinioException(e);
}
}
Comment thread
allanrogerr marked this conversation as resolved.
Outdated

Http.Body body = null;
if (args.file() != null) {
body = new Http.Body(args.file(), args.length(), contentType, sha256HexString, md5Hash);
Expand Down
20 changes: 19 additions & 1 deletion api/src/main/java/io/minio/Http.java
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ public static OkHttpClient setTimeout(
public static class Body {
private okhttp3.RequestBody requestBody;
private RandomAccessFile file;
private long fileOffset;
private ByteBuffer buffer;
private byte[] data;
private Long length;
Expand All @@ -713,6 +714,11 @@ public Body(
String md5Hash) {
if (length < 0) throw new IllegalArgumentException("valid length must be provided");
this.file = file;
try {
this.fileOffset = file.getFilePointer();
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
} catch (IOException e) {
throw new IllegalStateException("failed to read file position", e);
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
}
set(length, contentType, sha256Hash, md5Hash);
}

Expand Down Expand Up @@ -786,7 +792,14 @@ public Headers headers() {
/** Creates HTTP RequestBody for this body. */
public RequestBody toRequestBody() throws MinioException {
if (requestBody != null) return new RequestBody(requestBody);
if (file != null) return new RequestBody(file, length, contentType);
if (file != null) {
try {
file.seek(fileOffset);
} catch (IOException e) {
throw new MinioException(e);
}
return new RequestBody(file, length, contentType);
}
if (buffer != null) return new RequestBody(buffer, contentType);
return new RequestBody(data, length.intValue(), contentType);
}
Expand Down Expand Up @@ -1503,6 +1516,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 {@code 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 {@code 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
Loading
Loading