This is an automated email from the ASF dual-hosted git repository.

capistrant pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 468d2dd2ce4 feat: Add operator access to configure AWS retry policy. 
Default to STANDARD, recommended by AWS (#19892)
468d2dd2ce4 is described below

commit 468d2dd2ce4e689a15b2738cb28fbd04e3117b5d
Author: Lucas Capistrant <[email protected]>
AuthorDate: Fri Aug 7 10:08:24 2026 -0500

    feat: Add operator access to configure AWS retry policy. Default to 
STANDARD, recommended by AWS (#19892)
---
 cloud/aws-common/pom.xml                           |  16 ++
 .../apache/druid/common/aws/AWSClientConfig.java   | 121 ++++++++++++++
 .../druid/common/aws/AWSClientConfigTest.java      | 177 +++++++++++++++------
 docs/development/extensions-core/s3.md             |   2 +
 extensions-core/s3-extensions/pom.xml              |   5 +
 .../storage/s3/ServerSideEncryptingAmazonS3.java   |  27 +++-
 .../druid/data/input/s3/S3InputSourceTest.java     |   5 +
 .../s3/ServerSideEncryptingAmazonS3Test.java       |  34 ++++
 8 files changed, 338 insertions(+), 49 deletions(-)

diff --git a/cloud/aws-common/pom.xml b/cloud/aws-common/pom.xml
index d1197a934d5..ab664679924 100644
--- a/cloud/aws-common/pom.xml
+++ b/cloud/aws-common/pom.xml
@@ -82,6 +82,17 @@
           <artifactId>sdk-core</artifactId>
           <version>${aws.sdk.v2.version}</version>
         </dependency>
+        <!-- Retry strategies configured by AWSClientConfig -->
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>retries-spi</artifactId>
+            <version>${aws.sdk.v2.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>retries</artifactId>
+            <version>${aws.sdk.v2.version}</version>
+        </dependency>
         <!-- WebIdentityTokenProvider requires runtime dependency on sts -->
         <dependency>
             <groupId>software.amazon.awssdk</groupId>
@@ -115,5 +126,10 @@
             <artifactId>junit-jupiter-api</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-params</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git 
a/cloud/aws-common/src/main/java/org/apache/druid/common/aws/AWSClientConfig.java
 
b/cloud/aws-common/src/main/java/org/apache/druid/common/aws/AWSClientConfig.java
index 9fdf9ba592f..67f31cfbe98 100644
--- 
a/cloud/aws-common/src/main/java/org/apache/druid/common/aws/AWSClientConfig.java
+++ 
b/cloud/aws-common/src/main/java/org/apache/druid/common/aws/AWSClientConfig.java
@@ -20,10 +20,18 @@
 package org.apache.druid.common.aws;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
+import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
+import org.apache.druid.java.util.common.IAE;
+import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.utils.RuntimeInfo;
+import software.amazon.awssdk.awscore.retry.AwsRetryStrategy;
+import software.amazon.awssdk.retries.api.RetryStrategy;
 
 import javax.annotation.Nullable;
+import javax.validation.constraints.Min;
+import java.util.Arrays;
 
 public class AWSClientConfig
 {
@@ -36,6 +44,61 @@ public class AWSClientConfig
   /** AWS SDK v2's own default. */
   private static final int DEFAULT_MAX_CONNECTIONS_FLOOR = 50;
 
+  /**
+   * Retry strategy family. Declared as an enum so an unrecognised value is 
rejected while the config is bound at
+   * startup, rather than when a client is first built.
+   */
+  public enum RetryMode
+  {
+    STANDARD {
+      @Override
+      RetryStrategy createStrategy()
+      {
+        // Pass true to ensure we get the new standard AWS SDKv2 retry 
behavior and not legacy behavior.
+        return AwsRetryStrategy.standardRetryStrategy(true);
+      }
+    },
+    ADAPTIVE {
+      @Override
+      RetryStrategy createStrategy()
+      {
+        // Standard plus a client-side rate limiter, which unlike standard can 
delay or block the initial request,
+        // not just retries. The limiter belongs to one client instance and 
covers every request that client makes,
+        // so throttling on one key prefix also slows requests to prefixes 
that are not being throttled.
+        return AwsRetryStrategy.adaptiveRetryStrategy(true);
+      }
+    },
+    LEGACY {
+      @Override
+      RetryStrategy createStrategy()
+      {
+        // Deliberately left on the pre-standard behavior: this mode exists so 
a deployment can get back to what it
+        // had before, which is the opposite of what the opt-in above asks for.
+        return AwsRetryStrategy.legacyRetryStrategy();
+      }
+    };
+
+    abstract RetryStrategy createStrategy();
+
+    @JsonValue
+    @Override
+    public String toString()
+    {
+      return StringUtils.toLowerCase(name());
+    }
+
+    @JsonCreator
+    public static RetryMode fromString(String value)
+    {
+      for (RetryMode mode : values()) {
+        if (mode.name().equalsIgnoreCase(value)) {
+          return mode;
+        }
+      }
+      throw new IAE("Invalid druid.s3.retryMode[%s]. Must be one of %s.", 
value, Arrays.toString(values()));
+    }
+  }
+
   /**
    * Used by {@link #getMaxConnections} to scale the default connection pool 
with host size so hosts large enough to
    * do a lot of concurrent deep-storage I/O (e.g. virtual-storage historicals 
fanning out on-demand loads to S3)
@@ -80,6 +143,26 @@ public class AWSClientConfig
   @Nullable
   private Integer maxConnections = null;
 
+  /**
+   * Retry strategy applied to every AWS client built from this config.
+   */
+  @JsonProperty
+  private RetryMode retryMode = RetryMode.STANDARD;
+
+  /**
+   * Total attempts per request, including the first. A value of 1 disables
+   * retries. Null leaves the count that {@link #retryMode} defines for 
itself, which AWS tunes alongside that mode's
+   * backoff and retry quota.
+   * <p>
+   * This counts HTTP requests. Druid layers its own retries on top (see 
{@code S3Utils#retryS3Operation}) and the two
+   * multiply, but they are not equivalent: an attempt here re-sends a single 
request, whereas a Druid-level retry
+   * repeats a whole operation, such as re-uploading an entire segment.
+   */
+  @JsonProperty
+  @Nullable
+  @Min(1)
+  private Integer maxAttempts = null;
+
   public String getProtocol()
   {
     return protocol;
@@ -146,6 +229,42 @@ public class AWSClientConfig
     return Math.max(DEFAULT_MAX_CONNECTIONS_FLOOR, 4 * 
runtimeInfo.getAvailableProcessors());
   }
 
+  public RetryMode getRetryMode()
+  {
+    return retryMode;
+  }
+
+  @Nullable
+  public Integer getMaxAttempts()
+  {
+    return maxAttempts;
+  }
+
+  /**
+   * Builds the strategy to hand to {@code 
ClientOverrideConfiguration.retryStrategy}. Kept as a plain function of the
+   * config because a built AWS client does not expose the strategy it was 
given, so this is the only place the
+   * mapping can be tested.
+   * <p>
+   * Returns a new instance per call; clients must not share one, since the 
strategies hold their circuit-breaker
+   * quota (and, for adaptive, their rate limiter) on the instance.
+   */
+  public RetryStrategy getRetryStrategy()
+  {
+    return withMaxAttempts(retryMode.createStrategy());
+  }
+
+  /**
+   * Overrides the attempt count only when one is configured, so an unset 
{@link #maxAttempts} leaves whatever
+   * the chosen mode defines for itself.
+   */
+  private RetryStrategy withMaxAttempts(RetryStrategy strategy)
+  {
+    if (maxAttempts == null) {
+      return strategy;
+    }
+    return strategy.toBuilder().maxAttempts(maxAttempts).build();
+  }
+
   @Override
   public String toString()
   {
@@ -157,6 +276,8 @@ public class AWSClientConfig
            ", connectionTimeout=" + connectionTimeout +
            ", socketTimeout=" + socketTimeout +
            ", maxConnections=" + getMaxConnections() +
+           ", retryMode='" + retryMode + '\'' +
+           ", maxRetryAttempts=" + maxAttempts +
            '}';
   }
 }
diff --git 
a/cloud/aws-common/src/test/java/org/apache/druid/common/aws/AWSClientConfigTest.java
 
b/cloud/aws-common/src/test/java/org/apache/druid/common/aws/AWSClientConfigTest.java
index 99b927efb2b..a86a2900af8 100644
--- 
a/cloud/aws-common/src/test/java/org/apache/druid/common/aws/AWSClientConfigTest.java
+++ 
b/cloud/aws-common/src/test/java/org/apache/druid/common/aws/AWSClientConfigTest.java
@@ -21,15 +21,42 @@ package org.apache.druid.common.aws;
 
 import com.fasterxml.jackson.databind.InjectableValues;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Throwables;
+import org.apache.druid.java.util.common.IAE;
 import org.apache.druid.utils.RuntimeInfo;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import software.amazon.awssdk.retries.AdaptiveRetryStrategy;
+import software.amazon.awssdk.retries.LegacyRetryStrategy;
+import software.amazon.awssdk.retries.StandardRetryStrategy;
+import software.amazon.awssdk.retries.api.RetryStrategy;
+
+import java.util.Map;
+import java.util.stream.Stream;
 
 public class AWSClientConfigTest
 {
-  private static final ObjectMapper MAPPER = new 
ObjectMapper().setInjectableValues(
-      new InjectableValues.Std().addValue(RuntimeInfo.class, new RuntimeInfo())
-  );
+  private static final ObjectMapper MAPPER = mapperWithRuntimeInfo(new 
RuntimeInfo());
+
+  /**
+   * Binds a property map the way {@code JsonConfigurator} binds {@code 
druid.s3.*} at startup, so behaviour that only
+   * exists during binding - defaults, unset versus explicitly set, rejection 
of bad values - is exercised here the
+   * same way it happens in a running process.
+   */
+  private static AWSClientConfig bind(Map<String, Object> properties)
+  {
+    return MAPPER.convertValue(properties, AWSClientConfig.class);
+  }
+
+  private static AWSClientConfig bind(Map<String, Object> properties, 
RuntimeInfo runtimeInfo)
+  {
+    return mapperWithRuntimeInfo(runtimeInfo).convertValue(properties, 
AWSClientConfig.class);
+  }
 
   private static ObjectMapper mapperWithRuntimeInfo(RuntimeInfo runtimeInfo)
   {
@@ -39,84 +66,144 @@ public class AWSClientConfigTest
   }
 
   @Test
-  public void testDefaultCrossRegionAccessEnabled() throws Exception
+  public void testDefaultRetryModeIsStandard()
   {
-    AWSClientConfig config = MAPPER.readValue("{}", AWSClientConfig.class);
-    Assertions.assertNull(config.isForceGlobalBucketAccessEnabled());
-    Assertions.assertFalse(config.isCrossRegionAccessEnabled());
+    final AWSClientConfig config = new AWSClientConfig();
+
+    Assertions.assertEquals(AWSClientConfig.RetryMode.STANDARD, 
config.getRetryMode());
+    Assertions.assertInstanceOf(StandardRetryStrategy.class, 
config.getRetryStrategy());
   }
 
-  @Test
-  public void testCrossRegionAccessEnabledExplicitlySet() throws Exception
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("retryModeStrategies")
+  public void testEachRetryModeBuildsItsStrategy(
+      AWSClientConfig.RetryMode mode,
+      Class<? extends RetryStrategy> expected
+  )
   {
-    AWSClientConfig config = MAPPER.readValue("{\"crossRegionAccessEnabled\": 
true}", AWSClientConfig.class);
-    Assertions.assertNull(config.isForceGlobalBucketAccessEnabled());
-    Assertions.assertTrue(config.isCrossRegionAccessEnabled());
+    Assertions.assertInstanceOf(expected, mode.createStrategy());
   }
 
+  /**
+   * Guards {@link #retryModeStrategies} against a mode being added without a 
strategy expectation.
+   */
   @Test
-  public void testNewConfigTakesPrecedenceOverDeprecatedWhenBothSet() throws 
Exception
+  public void testEveryRetryModeHasAStrategyExpectation()
   {
-    AWSClientConfig config = MAPPER.readValue(
-        "{\"forceGlobalBucketAccessEnabled\": true, 
\"crossRegionAccessEnabled\": false}",
-        AWSClientConfig.class
+    Assertions.assertEquals(AWSClientConfig.RetryMode.values().length, 
retryModeStrategies().count());
+  }
+
+  private static Stream<Arguments> retryModeStrategies()
+  {
+    return Stream.of(
+        Arguments.of(AWSClientConfig.RetryMode.STANDARD, 
StandardRetryStrategy.class),
+        Arguments.of(AWSClientConfig.RetryMode.ADAPTIVE, 
AdaptiveRetryStrategy.class),
+        Arguments.of(AWSClientConfig.RetryMode.LEGACY, 
LegacyRetryStrategy.class)
     );
-    Assertions.assertFalse(config.isCrossRegionAccessEnabled());
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"adaptive", "ADAPTIVE", "Adaptive"})
+  public void testRetryModeParsingIsCaseInsensitive(String value)
+  {
+    Assertions.assertEquals(AWSClientConfig.RetryMode.ADAPTIVE, 
AWSClientConfig.RetryMode.fromString(value));
   }
 
   @Test
-  public void testNewConfigTrueWinsOverDeprecatedFalse() throws Exception
+  public void testRetryModeBindsFromItsProperty()
   {
-    AWSClientConfig config = MAPPER.readValue(
-        "{\"forceGlobalBucketAccessEnabled\": false, 
\"crossRegionAccessEnabled\": true}",
-        AWSClientConfig.class
+    Assertions.assertEquals(
+        AWSClientConfig.RetryMode.ADAPTIVE,
+        bind(Map.of("retryMode", "adaptive")).getRetryMode()
     );
-    Assertions.assertTrue(config.isCrossRegionAccessEnabled());
   }
 
   @Test
-  public void testDeprecatedForceGlobalBucketAccessAloneTrue() throws Exception
+  public void testRetryModeSerializesToItsPropertyValue()
+  {
+    Assertions.assertEquals("adaptive", 
MAPPER.convertValue(AWSClientConfig.RetryMode.ADAPTIVE, String.class));
+  }
+
+  /**
+   * Binding the config is the last point at which a bad mode can be reported 
against the property that set it, so it
+   * has to fail here rather than when some client is first built.
+   */
+  @Test
+  public void testUnrecognizedRetryModeIsRejectedWhenConfigIsBound()
   {
-    AWSClientConfig config = MAPPER.readValue(
-        "{\"forceGlobalBucketAccessEnabled\": true}",
-        AWSClientConfig.class
+    final IllegalArgumentException e = Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> bind(Map.of("retryMode", "aggressive"))
     );
-    Assertions.assertTrue(config.isCrossRegionAccessEnabled());
+
+    final Throwable rootCause = Throwables.getRootCause(e);
+    Assertions.assertInstanceOf(IAE.class, rootCause);
+    Assertions.assertTrue(rootCause.getMessage().contains("aggressive"));
   }
 
   @Test
-  public void testDeprecatedNotSetFallsThroughToCrossRegion() throws Exception
+  public void testUnsetAttemptCountLeavesTheCountTheModeDefines()
   {
-    AWSClientConfig config = MAPPER.readValue(
-        "{\"crossRegionAccessEnabled\": true}",
-        AWSClientConfig.class
+    final AWSClientConfig config = new AWSClientConfig();
+
+    Assertions.assertNull(config.getMaxAttempts());
+    Assertions.assertEquals(
+        AWSClientConfig.RetryMode.STANDARD.createStrategy().maxAttempts(),
+        config.getRetryStrategy().maxAttempts()
     );
-    Assertions.assertNull(config.isForceGlobalBucketAccessEnabled());
-    Assertions.assertTrue(config.isCrossRegionAccessEnabled());
   }
 
   @Test
-  public void testDefaultMaxConnectionsKeepsAwsSdkFloorOnSmallHost() throws 
Exception
+  public void testConfiguredAttemptCountIsApplied()
   {
-    AWSClientConfig config = mapperWithRuntimeInfo(new 
FixedProcessorsRuntimeInfo(8))
-        .readValue("{}", AWSClientConfig.class);
-    Assertions.assertEquals(50, config.getMaxConnections());
+    Assertions.assertEquals(8, bind(Map.of("maxAttempts", 
8)).getRetryStrategy().maxAttempts());
   }
 
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("crossRegionAccessBindings")
+  public void testCrossRegionAccessResolution(Map<String, Object> properties, 
boolean expected)
+  {
+    Assertions.assertEquals(expected, 
bind(properties).isCrossRegionAccessEnabled());
+  }
+
+  private static Stream<Arguments> crossRegionAccessBindings()
+  {
+    return Stream.of(
+        Arguments.of(Map.of(), false),
+        Arguments.of(Map.of("crossRegionAccessEnabled", true), true),
+        Arguments.of(Map.of("forceGlobalBucketAccessEnabled", true), true),
+        // the new property wins whichever way the two disagree
+        Arguments.of(Map.of("forceGlobalBucketAccessEnabled", true, 
"crossRegionAccessEnabled", false), false),
+        Arguments.of(Map.of("forceGlobalBucketAccessEnabled", false, 
"crossRegionAccessEnabled", true), true)
+    );
+  }
+
+  /**
+   * The deprecated property is only ever populated by its own key, so code 
still reading it cannot be misled by the
+   * replacement being set.
+   */
   @Test
-  public void testDefaultMaxConnectionsScalesWithCoresOnLargeHost() throws 
Exception
+  @SuppressWarnings("deprecation")
+  public void testDeprecatedPropertyStaysUnsetWhenOnlyItsReplacementIsBound()
   {
-    AWSClientConfig config = mapperWithRuntimeInfo(new 
FixedProcessorsRuntimeInfo(32))
-        .readValue("{}", AWSClientConfig.class);
-    Assertions.assertEquals(128, config.getMaxConnections());
+    Assertions.assertNull(bind(Map.of()).isForceGlobalBucketAccessEnabled());
+    Assertions.assertNull(bind(Map.of("crossRegionAccessEnabled", 
true)).isForceGlobalBucketAccessEnabled());
+  }
+
+  @ParameterizedTest(name = "{0} processors -> {1} connections")
+  @CsvSource({"8, 50", "32, 128"})
+  public void testDefaultMaxConnectionsTakesTheSdkFloorOrFourPerCore(int 
processors, int expected)
+  {
+    Assertions.assertEquals(expected, bind(Map.of(), new 
FixedProcessorsRuntimeInfo(processors)).getMaxConnections());
   }
 
   @Test
-  public void testExplicitMaxConnectionsOverridesDefault() throws Exception
+  public void testExplicitMaxConnectionsOverridesDefault()
   {
-    AWSClientConfig config = mapperWithRuntimeInfo(new 
FixedProcessorsRuntimeInfo(64))
-        .readValue("{\"maxConnections\": 200}", AWSClientConfig.class);
-    Assertions.assertEquals(200, config.getMaxConnections());
+    Assertions.assertEquals(
+        200,
+        bind(Map.of("maxConnections", 200), new 
FixedProcessorsRuntimeInfo(64)).getMaxConnections()
+    );
   }
 
   private static final class FixedProcessorsRuntimeInfo extends RuntimeInfo
diff --git a/docs/development/extensions-core/s3.md 
b/docs/development/extensions-core/s3.md
index c59f12670fd..b60e80585e5 100644
--- a/docs/development/extensions-core/s3.md
+++ b/docs/development/extensions-core/s3.md
@@ -131,6 +131,8 @@ For example, to set the region to 'us-east-1' through 
system properties:
 |`druid.s3.disableChunkedEncoding`|Disables chunked encoding. See [AWS 
document](https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/services/s3/AmazonS3Builder.html#disableChunkedEncoding--)
 for details.|false|
 |`druid.s3.enablePathStyleAccess`|Enables path style access. See [AWS 
document](https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/services/s3/AmazonS3Builder.html#enablePathStyleAccess--)
 for details.|false|
 |`druid.s3.crossRegionAccessEnabled`|Enables cross-region access for S3 
requests. When enabled, the S3 client automatically detects the correct region 
for a bucket on first access and caches it for subsequent requests.|false|
+|`druid.s3.retryMode`|Retry strategy for AWS clients built from this 
configuration. One of `standard`, `adaptive` or `legacy`. See [AWS 
document](https://docs.aws.amazon.com/sdkref/latest/guide/feature-retry-behavior.html)
 for details about each mode.|`standard`|
+|`druid.s3.maxAttempts`|Total attempts per HTTP request, including the first, 
so `1` disables SDK retries. When unset, the SDK default count defined by 
`druid.s3.retryMode` applies.|null (the retry mode's own default)|
 |`druid.s3.forceGlobalBucketAccessEnabled`|**Deprecated.** Use 
`druid.s3.crossRegionAccessEnabled` instead. Only used as a fallback if 
`crossRegionAccessEnabled` is not explicitly set.|null|
 |`druid.s3.endpoint.url`|Service endpoint either with or without the 
protocol.|None|
 |`druid.s3.endpoint.signingRegion`|Region to use for SigV4 signing of requests 
(e.g. us-west-1).|None|
diff --git a/extensions-core/s3-extensions/pom.xml 
b/extensions-core/s3-extensions/pom.xml
index 40b3b549fd5..9b6581e8434 100644
--- a/extensions-core/s3-extensions/pom.xml
+++ b/extensions-core/s3-extensions/pom.xml
@@ -163,6 +163,11 @@
       <artifactId>utils</artifactId>
       <version>${aws.sdk.v2.version}</version>
     </dependency>
+    <dependency>
+      <groupId>software.amazon.awssdk</groupId>
+      <artifactId>retries-spi</artifactId>
+      <version>${aws.sdk.v2.version}</version>
+    </dependency>
     <dependency>
       <groupId>it.unimi.dsi</groupId>
       <artifactId>fastutil-core</artifactId>
diff --git 
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3.java
 
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3.java
index dd86e9e610d..b1e6cb2f043 100644
--- 
a/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3.java
+++ 
b/extensions-core/s3-extensions/src/main/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3.java
@@ -35,6 +35,7 @@ import 
software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
 import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
 import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
 import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
 import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.core.sync.RequestBody;
 import software.amazon.awssdk.http.apache.ApacheHttpClient;
@@ -376,9 +377,11 @@ public class ServerSideEncryptingAmazonS3
                                                 .build();
       clientBuilder.serviceConfiguration(s3Config)
                    .forcePathStyle(awsClientConfig.isEnablePathStyleAccess())
-                   
.crossRegionAccessEnabled(awsClientConfig.isCrossRegionAccessEnabled());
+                   
.crossRegionAccessEnabled(awsClientConfig.isCrossRegionAccessEnabled())
+                   .overrideConfiguration(retryOverride(awsClientConfig));
       final S3TransferConfig transferConfig = 
s3StorageConfig.getS3TransferConfig();
-      
asyncClientBuilder.forcePathStyle(awsClientConfig.isEnablePathStyleAccess())
+      asyncClientBuilder.overrideConfiguration(retryOverride(awsClientConfig))
+                        
.forcePathStyle(awsClientConfig.isEnablePathStyleAccess())
                         
.crossRegionAccessEnabled(awsClientConfig.isCrossRegionAccessEnabled())
                         
.httpClientBuilder(AsyncHttpClientType.fromString(transferConfig.getAsyncHttpClientType()).buildBuilder(awsClientConfig))
                         .multipartEnabled(true)
@@ -411,7 +414,8 @@ public class ServerSideEncryptingAmazonS3
           assumeRoleArn,
           assumeRoleExternalId,
           awsEndpointConfig,
-          credentialsProvider
+          credentialsProvider,
+          awsClientConfig
       );
     }
 
@@ -425,6 +429,17 @@ public class ServerSideEncryptingAmazonS3
                                        .setS3StorageConfig(s3StorageConfig);
   }
 
+  /**
+   * Builds the retry override for a single client. Every client needs its own 
{@code RetryStrategy} instance: the
+   * standard strategy holds its circuit-breaker token bucket on the instance, 
and the adaptive strategy additionally
+   * holds its client-side rate limiter there. Sharing one instance would let 
throttled TransferManager uploads drain
+   * the quota of, or throttle, synchronous reads and listings.
+   */
+  private static ClientOverrideConfiguration retryOverride(AWSClientConfig 
awsClientConfig)
+  {
+    return 
ClientOverrideConfiguration.builder().retryStrategy(awsClientConfig.getRetryStrategy()).build();
+  }
+
   @Nonnull
   private static StaticCredentialsProvider 
createStaticCredentialsProvider(S3InputSourceConfig s3InputSourceConfig)
   {
@@ -449,7 +464,8 @@ public class ServerSideEncryptingAmazonS3
       String assumeRoleArn,
       @Nullable String assumeRoleExternalId,
       @Nullable AWSEndpointConfig awsEndpointConfig,
-      AwsCredentialsProvider baseCredentialsProvider
+      AwsCredentialsProvider baseCredentialsProvider,
+      @Nullable AWSClientConfig awsClientConfig
   )
   {
     String roleSessionName = StringUtils.format("druid-s3-%s", 
UUID.randomUUID().toString());
@@ -459,6 +475,9 @@ public class ServerSideEncryptingAmazonS3
     if (awsEndpointConfig != null && awsEndpointConfig.getSigningRegion() != 
null) {
       stsBuilder.region(Region.of(awsEndpointConfig.getSigningRegion()));
     }
+    if (awsClientConfig != null) {
+      stsBuilder.overrideConfiguration(retryOverride(awsClientConfig));
+    }
 
     AssumeRoleRequest.Builder assumeRoleRequestBuilder =
         
AssumeRoleRequest.builder().roleArn(assumeRoleArn).roleSessionName(roleSessionName).durationSeconds(3600);
diff --git 
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/data/input/s3/S3InputSourceTest.java
 
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/data/input/s3/S3InputSourceTest.java
index 9840c4a68f9..ba3912c49a3 100644
--- 
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/data/input/s3/S3InputSourceTest.java
+++ 
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/data/input/s3/S3InputSourceTest.java
@@ -78,6 +78,7 @@ import 
software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
 import software.amazon.awssdk.core.ResponseInputStream;
 import software.amazon.awssdk.core.exception.SdkClientException;
 import software.amazon.awssdk.http.AbortableInputStream;
+import software.amazon.awssdk.retries.api.RetryStrategy;
 import software.amazon.awssdk.services.s3.S3Client;
 import software.amazon.awssdk.services.s3.S3ClientBuilder;
 import software.amazon.awssdk.services.s3.model.GetObjectRequest;
@@ -519,6 +520,10 @@ public class S3InputSourceTest extends 
InitializedNullHandlingTest
     
EasyMock.expect(mockAwsClientConfig.getConnectionTimeoutMillis()).andStubReturn(10_000);
     
EasyMock.expect(mockAwsClientConfig.getSocketTimeoutMillis()).andStubReturn(50_000);
     EasyMock.expect(mockAwsClientConfig.getMaxConnections()).andStubReturn(50);
+    // Once for the sync client and once for the async one, since the two must 
not share a strategy instance.
+    EasyMock.expect(mockAwsClientConfig.getRetryStrategy())
+            .andReturn(EasyMock.createMock(RetryStrategy.class))
+            .times(2);
 
     EasyMock.expect(mockAwsProxyConfig.getHost()).andStubReturn("");
     EasyMock.expect(mockAwsProxyConfig.getPort()).andStubReturn(-1);
diff --git 
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3Test.java
 
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3Test.java
index acc93a159df..6f16924c524 100644
--- 
a/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3Test.java
+++ 
b/extensions-core/s3-extensions/src/test/java/org/apache/druid/storage/s3/ServerSideEncryptingAmazonS3Test.java
@@ -19,13 +19,16 @@
 
 package org.apache.druid.storage.s3;
 
+import org.apache.druid.common.aws.AWSClientConfig;
 import org.easymock.Capture;
 import org.easymock.EasyMock;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider;
 import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.retries.api.RetryStrategy;
 import software.amazon.awssdk.services.s3.S3AsyncClient;
 import software.amazon.awssdk.services.s3.S3Client;
 import software.amazon.awssdk.services.s3.model.Grant;
@@ -40,6 +43,8 @@ import 
software.amazon.awssdk.transfer.s3.model.UploadFileRequest;
 import java.io.File;
 import java.io.IOException;
 import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.CompletableFuture;
 
 
@@ -255,6 +260,35 @@ public class ServerSideEncryptingAmazonS3Test
     Assertions.assertEquals(builtClient, s3.getS3Client());
   }
 
+  @Test
+  public void testEachClientGetsItsOwnRetryStrategy()
+  {
+    final List<RetryStrategy> issued = new ArrayList<>();
+    final AWSClientConfig clientConfig = new AWSClientConfig()
+    {
+      @Override
+      public RetryStrategy getRetryStrategy()
+      {
+        final RetryStrategy strategy = super.getRetryStrategy();
+        issued.add(strategy);
+        return strategy;
+      }
+    };
+
+    ServerSideEncryptingAmazonS3.builder(
+        AnonymousCredentialsProvider.create(),
+        new S3StorageConfig(new NoopServerSideEncryption(), new 
S3TransferConfig()),
+        null,
+        null,
+        clientConfig,
+        null,
+        null
+    );
+
+    Assertions.assertEquals(2, issued.size(), "one strategy per client, sync 
and async");
+    Assertions.assertNotSame(issued.get(0), issued.get(1));
+  }
+
   @Test
   public void testBuilderWithAsyncClient() throws NoSuchFieldException, 
IllegalAccessException
   {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to