Skip to content

Commit b88ce5c

Browse files
Merge pull request #76 from folio-org/FOLS3CL-52
FOLS3CL-52: Expose idle connection timeout http client property
1 parent 9f6825f commit b88ce5c

3 files changed

Lines changed: 115 additions & 0 deletions

File tree

src/main/java/org/folio/s3/client/MinioS3Client.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
import io.minio.messages.DeleteObject;
4848
import io.minio.messages.Part;
4949
import lombok.extern.log4j.Log4j2;
50+
import okhttp3.ConnectionPool;
51+
import okhttp3.OkHttpClient;
5052

5153
@Log4j2
5254
// 2142: we wrap and rethrow InterruptedException as S3ClientException
@@ -90,6 +92,16 @@ static ExtendedMinioAsyncClient createClient(S3ClientProperties properties) {
9092
builder.region(region);
9193
}
9294

95+
var idleKeepAliveSeconds = properties.getIdleKeepAliveSeconds();
96+
if (idleKeepAliveSeconds != null) {
97+
log.info("Configuring OkHttp connection pool with idle keep-alive of {}s", idleKeepAliveSeconds);
98+
// 5 = OkHttp default maxIdleConnections; only the keep-alive duration is customised
99+
var httpClient = new OkHttpClient.Builder()
100+
.connectionPool(new ConnectionPool(5, idleKeepAliveSeconds, TimeUnit.SECONDS))
101+
.build();
102+
builder.httpClient(httpClient);
103+
}
104+
93105
Provider provider;
94106
if (StringUtils.isNotBlank(accessKey) && StringUtils.isNotBlank(secretKey)) {
95107
provider = new StaticProvider(accessKey, secretKey, null);

src/main/java/org/folio/s3/client/S3ClientProperties.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,15 @@ public class S3ClientProperties {
4646
* True for bucket name in the path, false for bucket name in the virtual host name.
4747
*/
4848
private boolean forcePathStyle;
49+
50+
/**
51+
* Maximum time, in seconds, that an idle HTTP connection in the OkHttp connection pool
52+
* (used by the underlying Minio client) is kept alive before being evicted.
53+
*
54+
* <p>If {@code null}, the OkHttp default of 5 minutes is used. Set this to a value smaller
55+
* than the server-side idle timeout (AWS S3 closes idle connections after ~20s) to avoid
56+
* "unexpected end of stream" / connection-reset errors, especially during multipart uploads
57+
* — which always go through the Minio client, even when {@link AwsS3Client} is used.
58+
*/
59+
private Integer idleKeepAliveSeconds;
4960
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package org.folio.s3.client;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNotNull;
5+
6+
import java.lang.reflect.Field;
7+
import java.util.concurrent.TimeUnit;
8+
9+
import org.junit.jupiter.api.DisplayName;
10+
import org.junit.jupiter.api.Test;
11+
12+
import io.minio.MinioAsyncClient;
13+
import okhttp3.OkHttpClient;
14+
15+
/**
16+
* Unit tests for the OkHttp connection-pool wiring driven by
17+
* {@link S3ClientProperties#getIdleKeepAliveSeconds()} in {@link MinioS3Client#createClient}.
18+
*
19+
* <p>Uses reflection to inspect the {@link OkHttpClient} instance held by the underlying
20+
* {@link MinioAsyncClient}, since neither minio-java nor OkHttp expose it publicly.
21+
*/
22+
class MinioS3ClientHttpClientTest {
23+
24+
private static final String ENDPOINT = "http://localhost:9000";
25+
26+
@Test
27+
@DisplayName("Default OkHttp keep-alive (5 minutes) is used when idleKeepAliveSeconds is not set")
28+
void defaultKeepAliveWhenPropertyNotSet() throws Exception {
29+
var props = baseProps().build();
30+
31+
var client = MinioS3Client.createClient(props);
32+
var okHttp = extractOkHttpClient(client);
33+
34+
assertNotNull(okHttp, "Minio client must always have an OkHttpClient");
35+
// OkHttp default ConnectionPool keep-alive is 5 minutes
36+
assertEquals(TimeUnit.MINUTES.toNanos(5), keepAliveNanos(okHttp),
37+
"Default OkHttp keep-alive should be 5 minutes when property is null");
38+
}
39+
40+
@Test
41+
@DisplayName("Custom OkHttp keep-alive is applied when idleKeepAliveSeconds is set")
42+
void customKeepAliveWhenPropertySet() throws Exception {
43+
var props = baseProps().idleKeepAliveSeconds(15).build();
44+
45+
var client = MinioS3Client.createClient(props);
46+
var okHttp = extractOkHttpClient(client);
47+
48+
assertNotNull(okHttp);
49+
assertEquals(TimeUnit.SECONDS.toNanos(15), keepAliveNanos(okHttp),
50+
"OkHttp keep-alive must match the configured idleKeepAliveSeconds");
51+
}
52+
53+
/**
54+
* OkHttp 5 doesn't expose the keep-alive duration on the public {@link okhttp3.ConnectionPool}
55+
* API anymore, but {@code getDelegate$okhttp()} returns the {@code RealConnectionPool} which
56+
* does expose {@code getKeepAliveDurationNs$okhttp()} (both are JVM-public despite the suffix).
57+
*/
58+
private static long keepAliveNanos(OkHttpClient okHttp) throws Exception {
59+
var pool = okHttp.connectionPool();
60+
var delegate = pool.getClass().getMethod("getDelegate$okhttp").invoke(pool);
61+
return (long) delegate.getClass().getMethod("getKeepAliveDurationNs$okhttp").invoke(delegate);
62+
}
63+
64+
private static S3ClientProperties.S3ClientPropertiesBuilder baseProps() {
65+
return S3ClientProperties.builder()
66+
.endpoint(ENDPOINT)
67+
.region("us-east-1")
68+
.bucket("test-bucket")
69+
.accessKey("ak")
70+
.secretKey("sk")
71+
.forcePathStyle(true);
72+
}
73+
74+
/**
75+
* Pulls the private {@code httpClient} field from {@link MinioAsyncClient}'s class hierarchy.
76+
* The field is declared on {@code io.minio.S3Base} (parent of MinioAsyncClient).
77+
*/
78+
private static OkHttpClient extractOkHttpClient(MinioAsyncClient client) throws IllegalAccessException {
79+
Class<?> c = client.getClass();
80+
while (c != null) {
81+
for (Field f : c.getDeclaredFields()) {
82+
if (OkHttpClient.class.isAssignableFrom(f.getType())) {
83+
f.setAccessible(true);
84+
return (OkHttpClient) f.get(client);
85+
}
86+
}
87+
c = c.getSuperclass();
88+
}
89+
throw new IllegalStateException("OkHttpClient field not found on MinioAsyncClient hierarchy");
90+
}
91+
}
92+

0 commit comments

Comments
 (0)