Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
41 changes: 27 additions & 14 deletions api/src/main/java/io/minio/BaseS3Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,11 @@
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
Expand Down Expand Up @@ -92,7 +90,6 @@ public abstract class BaseS3Client implements AutoCloseable {
"ServerSideEncryptionConfigurationNotFoundError";
// maximum allowed bucket policy size is 20KiB
protected static final int MAX_BUCKET_POLICY_SIZE = 20 * 1024;
protected static final Random RANDOM = new Random(new SecureRandom().nextLong());
Comment thread
allanrogerr marked this conversation as resolved.
protected static final ObjectMapper OBJECT_MAPPER =
JsonMapper.builder()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
Expand All @@ -104,6 +101,7 @@ 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 PrintWriter traceStream;
protected final Map<String, String> regionCache = new ConcurrentHashMap<>();
protected String userAgent = Utils.getDefaultUserAgent();
Expand Down Expand Up @@ -268,7 +266,10 @@ private String[] handleRedirectResponse(
return new String[] {code, message};
}

/** Execute HTTP request asynchronously for given parameters. */
/**
* Execute HTTP request asynchronously for given parameters. Retries on retryable HTTP status
* codes are handled by {@link Http.RetryInterceptor} installed on the default HTTP client.
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
*/
protected CompletableFuture<Response> executeAsync(Http.S3Request s3request, String region) {
Credentials credentials = (provider == null) ? null : provider.fetch();
Http.Request request = null;
Expand All @@ -282,15 +283,9 @@ protected CompletableFuture<Response> executeAsync(Http.S3Request s3request, Str
PrintWriter traceStream = this.traceStream;
if (traceStream != null) traceStream.print(request.httpTraces());

OkHttpClient httpClient = this.httpClient;
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
this.httpClient
.newCall(httpRequest)
.enqueue(
new Callback() {
Expand Down Expand Up @@ -469,9 +464,10 @@ private void onResponse(final Response response) throws IOException {
response.header("x-amz-id-2"));
}

// invalidate region cache if needed
if (errorResponse.code().equals(NO_SUCH_BUCKET)
|| errorResponse.code().equals(RETRY_HEAD)) {
// invalidate region cache if needed (bucket may be null for e.g. listBuckets)
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
if (s3request.bucket() != null
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
&& (errorResponse.code().equals(NO_SUCH_BUCKET)
|| errorResponse.code().equals(RETRY_HEAD))) {
regionCache.remove(s3request.bucket());
}

Expand Down Expand Up @@ -1223,6 +1219,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 @@ -1278,6 +1283,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
69 changes: 68 additions & 1 deletion api/src/main/java/io/minio/Http.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.stream.Collectors;
Expand All @@ -65,6 +66,7 @@
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
Expand Down Expand Up @@ -620,6 +622,7 @@ public static OkHttpClient newDefaultClient() {
.writeTimeout(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS)
.readTimeout(DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS)
.protocols(Arrays.asList(Protocol.HTTP_1_1))
.addInterceptor(new RetryInterceptor())
.build();
try {
return enableExternalCertificatesFromEnv(client);
Expand Down Expand Up @@ -683,10 +686,57 @@ public static OkHttpClient setTimeout(
.build();
}

/**
* OkHttp interceptor that retries requests on retryable HTTP status codes with exponential
* backoff and full jitter.
*/
public static class RetryInterceptor implements Interceptor {
Comment thread
allanrogerr marked this conversation as resolved.
private static final int MAX_RETRIES = 5;
private static final long BASE_DELAY_MS = 200L;
private static final long MAX_DELAY_MS = 30_000L;
private static final Set<Integer> RETRYABLE_STATUS_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

@Override
public okhttp3.Response intercept(Chain chain) throws IOException {
okhttp3.Request request = chain.request();
okhttp3.Response response = chain.proceed(request);

int tryCount = 0;
while (RETRYABLE_STATUS_CODES.contains(response.code()) && tryCount < MAX_RETRIES) {
tryCount++;
response.close();

// Cap exponent to avoid bit-shift overflow at high attempt counts.
int exp = Math.min(tryCount, 30);
long backoffCap = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * (1L << exp));
long jittered = ThreadLocalRandom.current().nextLong(0, Math.max(1, backoffCap));
try {
Thread.sleep(jittered);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new IOException("retry interrupted", ie);
}
Comment thread
allanrogerr marked this conversation as resolved.
Outdated

response = chain.proceed(request);
}
return response;
}
}

/** HTTP body of {@link RandomAccessFile}, {@link ByteBuffer} or {@link byte} array. */
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 @@ -710,6 +760,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 @@ -783,7 +838,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 @@ -1500,6 +1562,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
3 changes: 2 additions & 1 deletion api/src/main/java/io/minio/MinioAsyncClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadLocalRandom;
Comment thread
allanrogerr marked this conversation as resolved.
Outdated
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
Expand Down Expand Up @@ -3356,7 +3357,7 @@ public CompletableFuture<PutObjectFanOutResponse> putObjectFanOut(PutObjectFanOu
// Build POST object data
String objectName =
"fan-out-"
+ new BigInteger(32, RANDOM).toString(32)
+ new BigInteger(32, ThreadLocalRandom.current()).toString(32)
+ "-"
+ System.currentTimeMillis();
PostPolicy policy =
Expand Down
89 changes: 89 additions & 0 deletions api/src/test/java/io/minio/RetryTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 io.minio.errors.ErrorResponseException;
import io.minio.errors.MinioException;
import java.io.IOException;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okio.Buffer;
import org.junit.Assert;
import org.junit.Test;

/** Integration tests for {@link Http.RetryInterceptor}. */
public class RetryTest {
private static final String LIST_BUCKETS_OK =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<ListAllMyBucketsResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">"
+ "<Owner><ID>test</ID><DisplayName>test</DisplayName></Owner>"
+ "<Buckets/></ListAllMyBucketsResult>";

private MockResponse successResponse() {
return new MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/xml")
.setBody(new Buffer().writeUtf8(LIST_BUCKETS_OK));
}

private MockResponse retryableServerError() {
return new MockResponse()
.setResponseCode(503)
.setHeader("Content-Type", "text/html")
.setBody(new Buffer().writeUtf8("<html>Service Unavailable</html>"));
}

@Test
public void testRetryOnRetryableStatusThenSuccess() throws IOException, MinioException {
try (MockWebServer server = new MockWebServer()) {
server.enqueue(retryableServerError());
server.enqueue(successResponse());
server.start();

MinioClient client = MinioClient.builder().endpoint(server.url("").toString()).build();
client.listBuckets();

Assert.assertEquals(2, server.getRequestCount());
}
}
Comment thread
allanrogerr marked this conversation as resolved.

@Test
public void testNoRetryOn4xx() throws IOException, MinioException {
String notFoundXml =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<Error><Code>NoSuchBucket</Code><Message>not found</Message>"
+ "<Resource>/</Resource><RequestId>abc</RequestId></Error>";

try (MockWebServer server = new MockWebServer()) {
server.enqueue(
new MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/xml")
.setBody(new Buffer().writeUtf8(notFoundXml)));
server.start();

MinioClient client = MinioClient.builder().endpoint(server.url("").toString()).build();
try {
client.listBuckets();
Assert.fail("expected ErrorResponseException");
} catch (ErrorResponseException e) {
Assert.assertEquals("NoSuchBucket", e.errorResponse().code());
}
Comment thread
allanrogerr marked this conversation as resolved.
Assert.assertEquals(1, server.getRequestCount());
}
Comment thread
allanrogerr marked this conversation as resolved.
}
}
Loading