diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/retry/AwsRetryStrategy.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/retry/AwsRetryStrategy.java index f649cf59e17a..16892c74ad1f 100644 --- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/retry/AwsRetryStrategy.java +++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/retry/AwsRetryStrategy.java @@ -18,6 +18,7 @@ import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.awscore.exception.AwsServiceException; import software.amazon.awssdk.awscore.internal.AwsErrorCode; +import software.amazon.awssdk.core.internal.retry.RetryPolicyAdapter; import software.amazon.awssdk.core.internal.retry.SdkDefaultRetryStrategy; import software.amazon.awssdk.core.retry.RetryMode; import software.amazon.awssdk.retries.AdaptiveRetryStrategy; @@ -54,10 +55,12 @@ private AwsRetryStrategy() { switch (mode) { case STANDARD: return standardRetryStrategy(); - case ADAPTIVE: + case ADAPTIVE_V2: return adaptiveRetryStrategy(); case LEGACY: return legacyRetryStrategy(); + case ADAPTIVE: + return legacyAdaptiveRetryStrategy(); default: throw new IllegalArgumentException("unknown retry mode: " + mode); } @@ -84,7 +87,6 @@ private AwsRetryStrategy() { return DefaultRetryStrategy.none(); } - /** * Returns a {@link StandardRetryStrategy} with AWS-specific conditions added. * @@ -121,8 +123,8 @@ public static AdaptiveRetryStrategy adaptiveRetryStrategy() { * Configures a retry strategy using its builder to add AWS-specific retry exceptions. * * @param builder The builder to add the AWS-specific retry exceptions + * @param The type of the builder extending {@link RetryStrategy.Builder} * @return The given builder - * @param The type of the builder extending {@link RetryStrategy.Builder} */ public static > T configure(T builder) { return builder.retryOnException(AwsRetryStrategy::retryOnAwsRetryableErrors); @@ -135,6 +137,9 @@ public static AdaptiveRetryStrategy adaptiveRetryStrategy() { * @return The given builder */ public static RetryStrategy.Builder configureStrategy(RetryStrategy.Builder builder) { + if (builder instanceof RetryPolicyAdapter.Builder) { + return builder; + } return builder.retryOnException(AwsRetryStrategy::retryOnAwsRetryableErrors); } @@ -145,4 +150,16 @@ private static boolean retryOnAwsRetryableErrors(Throwable ex) { } return false; } + + /** + * Returns a {@link RetryStrategy} that implements the legacy {@link RetryMode#ADAPTIVE} mode. + * + * @return a {@link RetryStrategy} that implements the legacy {@link RetryMode#ADAPTIVE} mode. + */ + private static RetryStrategy legacyAdaptiveRetryStrategy() { + return RetryPolicyAdapter.builder() + .retryPolicy(AwsRetryPolicy.forRetryMode(RetryMode.ADAPTIVE)) + .build(); + } + } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper2.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper2.java index 8022ee50ab5c..ce478cd4b84c 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper2.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper2.java @@ -60,7 +60,6 @@ public final class RetryableStageHelper2 { public static final String SDK_RETRY_INFO_HEADER = "amz-sdk-request"; private final SdkHttpFullRequest request; private final RequestExecutionContext context; - private final RetryPolicy retryPolicy; private RetryPolicyAdapter retryPolicyAdapter; private final RetryStrategy retryStrategy; private final HttpClientDependencies dependencies; @@ -74,8 +73,16 @@ public RetryableStageHelper2(SdkHttpFullRequest request, HttpClientDependencies dependencies) { this.request = request; this.context = context; - this.retryPolicy = dependencies.clientConfiguration().option(SdkClientOption.RETRY_POLICY); - this.retryStrategy = dependencies.clientConfiguration().option(SdkClientOption.RETRY_STRATEGY); + RetryPolicy retryPolicy = dependencies.clientConfiguration().option(SdkClientOption.RETRY_POLICY); + RetryStrategy retryStrategy = dependencies.clientConfiguration().option(SdkClientOption.RETRY_STRATEGY); + if (retryPolicy != null) { + retryPolicyAdapter = RetryPolicyAdapter.builder() + .retryPolicy(retryPolicy) + .build(); + } else if (retryStrategy instanceof RetryPolicyAdapter) { + retryPolicyAdapter = (RetryPolicyAdapter) retryStrategy; + } + this.retryStrategy = retryStrategy; this.dependencies = dependencies; } @@ -256,15 +263,14 @@ private int retriesAttemptedSoFar() { * calling code. */ private RetryStrategy retryStrategy() { - if (retryPolicy != null) { - if (retryPolicyAdapter == null) { - retryPolicyAdapter = RetryPolicyAdapter.builder() - .retryPolicy(this.retryPolicy) + if (retryPolicyAdapter != null) { + if (retryPolicyAdapter.isInitialized()) { + retryPolicyAdapter = retryPolicyAdapter.toBuilder() .retryPolicyContext(retryPolicyContext()) .build(); } else { retryPolicyAdapter = retryPolicyAdapter.toBuilder() - .retryPolicyContext(retryPolicyContext()) + .initialize(retryPolicyContext()) .build(); } return retryPolicyAdapter; diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/RetryPolicyAdapter.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/RetryPolicyAdapter.java index 0cc388a343b6..e556446f3091 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/RetryPolicyAdapter.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/RetryPolicyAdapter.java @@ -42,25 +42,26 @@ */ @SdkInternalApi public final class RetryPolicyAdapter implements RetryStrategy { - private final RetryPolicy retryPolicy; private final RetryPolicyContext retryPolicyContext; private final RateLimitingTokenBucket rateLimitingTokenBucket; private RetryPolicyAdapter(Builder builder) { this.retryPolicy = Validate.paramNotNull(builder.retryPolicy, "retryPolicy"); - this.retryPolicyContext = Validate.paramNotNull(builder.retryPolicyContext, "retryPolicyContext"); + this.retryPolicyContext = builder.retryPolicyContext; this.rateLimitingTokenBucket = builder.rateLimitingTokenBucket; } @Override public AcquireInitialTokenResponse acquireInitialToken(AcquireInitialTokenRequest request) { + validateState(); RetryPolicyAdapterToken token = new RetryPolicyAdapterToken(request.scope()); return AcquireInitialTokenResponse.create(token, rateLimitingTokenAcquire()); } @Override public RefreshRetryTokenResponse refreshRetryToken(RefreshRetryTokenRequest request) { + validateState(); RetryPolicyAdapterToken token = getToken(request.token()); boolean willRetry = retryPolicy.aggregateRetryCondition().shouldRetry(retryPolicyContext); if (!willRetry) { @@ -73,6 +74,7 @@ public RefreshRetryTokenResponse refreshRetryToken(RefreshRetryTokenRequest requ @Override public RecordSuccessResponse recordSuccess(RecordSuccessRequest request) { + validateState(); RetryPolicyAdapterToken token = getToken(request.token()); retryPolicy.aggregateRetryCondition().requestSucceeded(retryPolicyContext); return RecordSuccessResponse.create(token); @@ -88,6 +90,16 @@ public Builder toBuilder() { return new Builder(this); } + public boolean isInitialized() { + return retryPolicyContext != null; + } + + void validateState() { + if (retryPolicyContext == null) { + throw new IllegalStateException("This RetryPolicyAdapter instance has not been initialized."); + } + } + RetryPolicyAdapterToken getToken(RetryToken token) { return Validate.isInstanceOf(RetryPolicyAdapterToken.class, token, "Object of class %s was not created by this retry " + "strategy", token.getClass().getName()); @@ -146,7 +158,6 @@ public static class Builder implements RetryStrategy.Builder shouldRetry) { @Override public Builder maxAttempts(int maxAttempts) { - throw new UnsupportedOperationException("RetryPolicyAdapter does not support calling retryOnException"); + throw new UnsupportedOperationException("RetryPolicyAdapter does not support calling maxAttempts"); } @Override @@ -175,13 +186,14 @@ public Builder retryPolicy(RetryPolicy retryPolicy) { return this; } - public Builder rateLimitingTokenBucket(RateLimitingTokenBucket rateLimitingTokenBucket) { - this.rateLimitingTokenBucket = rateLimitingTokenBucket; + public Builder retryPolicyContext(RetryPolicyContext retryPolicyContext) { + this.retryPolicyContext = retryPolicyContext; return this; } - public Builder retryPolicyContext(RetryPolicyContext retryPolicyContext) { + public Builder initialize(RetryPolicyContext retryPolicyContext) { this.retryPolicyContext = retryPolicyContext; + this.rateLimitingTokenBucket = new RateLimitingTokenBucket(); return this; } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/SdkDefaultRetryStrategy.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/SdkDefaultRetryStrategy.java index ee81eb023a7a..37cb716d230c 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/SdkDefaultRetryStrategy.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/retry/SdkDefaultRetryStrategy.java @@ -19,6 +19,7 @@ import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.core.exception.SdkServiceException; import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.core.retry.RetryPolicy; import software.amazon.awssdk.core.retry.RetryUtils; import software.amazon.awssdk.retries.AdaptiveRetryStrategy; import software.amazon.awssdk.retries.DefaultRetryStrategy; @@ -55,6 +56,8 @@ private SdkDefaultRetryStrategy() { case STANDARD: return standardRetryStrategy(); case ADAPTIVE: + return legacyAdaptiveRetryStrategy(); + case ADAPTIVE_V2: return adaptiveRetryStrategy(); case LEGACY: return legacyRetryStrategy(); @@ -74,11 +77,14 @@ public static RetryMode retryMode(RetryStrategy retryStrategy) { return RetryMode.STANDARD; } if (retryStrategy instanceof AdaptiveRetryStrategy) { - return RetryMode.ADAPTIVE; + return RetryMode.ADAPTIVE_V2; } if (retryStrategy instanceof LegacyRetryStrategy) { return RetryMode.LEGACY; } + if (retryStrategy instanceof RetryPolicyAdapter) { + return RetryMode.ADAPTIVE; + } throw new IllegalArgumentException("unknown retry strategy class: " + retryStrategy.getClass().getName()); } @@ -193,4 +199,16 @@ private static boolean retryOnThrottlingCondition(Throwable ex) { } return false; } + + /** + * Returns a {@link RetryStrategy} that implements the legacy {@link RetryMode#ADAPTIVE} mode. + * + * @return a {@link RetryStrategy} that implements the legacy {@link RetryMode#ADAPTIVE} mode. + */ + private static RetryStrategy legacyAdaptiveRetryStrategy() { + return RetryPolicyAdapter.builder() + .retryPolicy(RetryPolicy.forRetryMode(RetryMode.ADAPTIVE)) + .build(); + } } + diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryMode.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryMode.java index 9cc333df2780..cdcc5ac0bf81 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryMode.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryMode.java @@ -73,7 +73,7 @@ public enum RetryMode { STANDARD, /** - * Adaptive retry mode builds on {@code STANDARD} mode. + * Adaptive retry mode builds on {@link #STANDARD} mode. *

* Adaptive retry mode dynamically limits the rate of AWS requests to maximize success rate. This may be at the * expense of request latency. Adaptive retry mode is not recommended when predictable latency is important. @@ -84,9 +84,31 @@ public enum RetryMode { * the same client. When using adaptive retry mode, we recommend using a single client per resource. * * @see RetryPolicy#isFastFailRateLimiting() + * @deprecated As of 2.25.xx, replaced by {@link #ADAPTIVE_V2}. The ADAPTIVE implementation has a bug that prevents it + * from remembering its state across requests which is needed to correctly estimate its sending rate. Given that + * this bug has been present since its introduction and that correct version might change the traffic patterns of the SDK we + * deemed too risky to fix this implementation. */ + @Deprecated ADAPTIVE, + /** + * Adaptive V2 retry mode builds on {@link #STANDARD} mode. + *

+ * Adaptive retry mode qdynamically limits the rate of AWS requests to maximize success rate. This may be at the + * expense of request latency. Adaptive V2 retry mode is not recommended when predictable latency is important. + *

+ * {@code ADAPTIVE_V2} mode differs from {@link #ADAPTIVE} mode in the computed delays between calls, including the first + * attempt + * that might be delayed if the algorithm considers that it's needed to increase the odds of a successful response. + *

+ * Warning: Adaptive V2 retry mode assumes that the client is working against a single resource (e.g. one + * DynamoDB Table or one S3 Bucket). If you use a single client for multiple resources, throttling or outages + * associated with one resource will result in increased latency and failures when accessing all other resources via + * the same client. When using adaptive retry mode, we recommend using a single client per resource. + */ + ADAPTIVE_V2, + ; /** @@ -176,6 +198,8 @@ private static Optional fromString(String string) { return Optional.of(STANDARD); case "adaptive": return Optional.of(ADAPTIVE); + case "adaptive_v2": + return Optional.of(ADAPTIVE_V2); default: throw new IllegalStateException("Unsupported retry policy mode configured: " + string); } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryPolicy.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryPolicy.java index 12678c98b6c4..0999798b3c14 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryPolicy.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/retry/RetryPolicy.java @@ -372,6 +372,10 @@ private static final class BuilderImpl implements Builder { private Boolean fastFailRateLimiting; private BuilderImpl(RetryMode retryMode) { + if (retryMode == RetryMode.ADAPTIVE_V2) { + throw new UnsupportedOperationException("ADAPTIVE_V2 is not supported by retry policies, use a RetryStrategy " + + "instead"); + } this.retryMode = retryMode; this.numRetries = SdkDefaultRetrySetting.maxAttempts(retryMode) - 1; this.additionalRetryConditionsAllowed = true; diff --git a/services/dynamodb/src/main/java/software/amazon/awssdk/services/dynamodb/DynamoDbRetryPolicy.java b/services/dynamodb/src/main/java/software/amazon/awssdk/services/dynamodb/DynamoDbRetryPolicy.java index e85220e02d61..c2f06acf36e0 100644 --- a/services/dynamodb/src/main/java/software/amazon/awssdk/services/dynamodb/DynamoDbRetryPolicy.java +++ b/services/dynamodb/src/main/java/software/amazon/awssdk/services/dynamodb/DynamoDbRetryPolicy.java @@ -23,6 +23,7 @@ import software.amazon.awssdk.awscore.retry.AwsRetryStrategy; import software.amazon.awssdk.core.client.config.SdkClientConfiguration; import software.amazon.awssdk.core.client.config.SdkClientOption; +import software.amazon.awssdk.core.internal.retry.RetryPolicyAdapter; import software.amazon.awssdk.core.internal.retry.SdkDefaultRetrySetting; import software.amazon.awssdk.core.retry.RetryMode; import software.amazon.awssdk.core.retry.RetryPolicy; @@ -76,18 +77,8 @@ public static RetryPolicy resolveRetryPolicy(SdkClientConfiguration config) { return configuredRetryPolicy; } - RetryMode retryMode = RetryMode.resolver() - .profileFile(config.option(SdkClientOption.PROFILE_FILE_SUPPLIER)) - .profileName(config.option(SdkClientOption.PROFILE_NAME)) - .defaultRetryMode(config.option(SdkClientOption.DEFAULT_RETRY_MODE)) - .resolve(); - - return AwsRetryPolicy.forRetryMode(retryMode) - .toBuilder() - .additionalRetryConditionsAllowed(false) - .numRetries(MAX_ERROR_RETRY) - .backoffStrategy(BACKOFF_STRATEGY) - .build(); + RetryMode retryMode = resolveRetryMode(config); + return retryPolicyFor(retryMode); } public static RetryStrategy resolveRetryStrategy(SdkClientConfiguration config) { @@ -96,11 +87,13 @@ public static RetryPolicy resolveRetryPolicy(SdkClientConfiguration config) { return configuredRetryStrategy; } - RetryMode retryMode = RetryMode.resolver() - .profileFile(config.option(SdkClientOption.PROFILE_FILE_SUPPLIER)) - .profileName(config.option(SdkClientOption.PROFILE_NAME)) - .defaultRetryMode(config.option(SdkClientOption.DEFAULT_RETRY_MODE)) - .resolve(); + RetryMode retryMode = resolveRetryMode(config); + + if (retryMode == RetryMode.ADAPTIVE) { + return RetryPolicyAdapter.builder() + .retryPolicy(retryPolicyFor(retryMode)) + .build(); + } return AwsRetryStrategy.forRetryMode(retryMode) .toBuilder() @@ -108,4 +101,21 @@ public static RetryPolicy resolveRetryPolicy(SdkClientConfiguration config) { .backoffStrategy(exponentialDelay(BASE_DELAY, SdkDefaultRetrySetting.MAX_BACKOFF)) .build(); } + + private static RetryPolicy retryPolicyFor(RetryMode retryMode) { + return AwsRetryPolicy.forRetryMode(retryMode) + .toBuilder() + .additionalRetryConditionsAllowed(false) + .numRetries(MAX_ERROR_RETRY) + .backoffStrategy(BACKOFF_STRATEGY) + .build(); + } + + private static RetryMode resolveRetryMode(SdkClientConfiguration config) { + return RetryMode.resolver() + .profileFile(config.option(SdkClientOption.PROFILE_FILE_SUPPLIER)) + .profileName(config.option(SdkClientOption.PROFILE_NAME)) + .defaultRetryMode(config.option(SdkClientOption.DEFAULT_RETRY_MODE)) + .resolve(); + } } diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/Adaptive2ModeCorrectnessTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/Adaptive2ModeCorrectnessTest.java new file mode 100644 index 000000000000..242f77cf004c --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/Adaptive2ModeCorrectnessTest.java @@ -0,0 +1,190 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.services.retry; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.withinPercentage; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.common.FileSource; +import com.github.tomakehurst.wiremock.extension.Parameters; +import com.github.tomakehurst.wiremock.extension.ResponseTransformer; +import com.github.tomakehurst.wiremock.http.Request; +import com.github.tomakehurst.wiremock.http.Response; +import java.net.URI; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; +import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonClient; +import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonClientBuilder; + +/** + * Tests that the ADAPTIVE2 mode behaves as designed. The setup is an API that is rate limited, and a single client is used by + * multiple threads to make calls to this API. The ADAPTIVE2 mode should "adapt" its calling rate to closely match the expected + * rate of the API with little overhead (wasted calls). + * + * This test might be brittle depending on the hardware is run-on. If proven so we should + * remove it or tweak the expected assertions. + */ +public class Adaptive2ModeCorrectnessTest { + private WireMockServer wireMock; + private AtomicInteger successful; + private AtomicInteger failed; + + @Test + public void adaptive2RetryModeBehavesCorrectly() throws InterruptedException { + stubResponse(); + ExecutorService executor = Executors.newFixedThreadPool(20); + CapturingInterceptor interceptor = new CapturingInterceptor(); + ProtocolRestJsonClient client = clientBuilder() + .overrideConfiguration(o -> o.addExecutionInterceptor(interceptor) + .retryStrategy(RetryMode.ADAPTIVE_V2)) + .build(); + + int totalRequests = 250; + for (int i = 0; i < totalRequests; ++i) { + executor.execute(callAllTypes(client)); + } + executor.shutdown(); + assertThat(executor.awaitTermination(120, TimeUnit.SECONDS)).isTrue(); + double perceivedAvailability = ((double) successful.get() / totalRequests) * 100; + double overhead = ((double) interceptor.attemptsCount.get() / totalRequests) * 100 - 100; + assertThat(perceivedAvailability).isCloseTo(100.0, withinPercentage(20.0)); + assertThat(overhead).isCloseTo(10.0, withinPercentage(100.0)); + } + + private Runnable callAllTypes(ProtocolRestJsonClient client) { + return () -> { + try { + client.allTypes(); + successful.incrementAndGet(); + } catch (SdkException e) { + failed.incrementAndGet(); + } + }; + } + + private ProtocolRestJsonClientBuilder clientBuilder() { + return ProtocolRestJsonClient + .builder() + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create("akid", + "skid"))) + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://localhost:" + wireMock.port())); + } + + @Before + public void setup() { + successful = new AtomicInteger(0); + failed = new AtomicInteger(0); + wireMock = new WireMockServer(wireMockConfig() + .extensions(RateLimiterResponseTransformer.class)); + wireMock.start(); + } + + @After + public void tearDown() { + wireMock.stop(); + } + + private void stubResponse() { + wireMock.stubFor(post(anyUrl()) + .willReturn(aResponse() + .withTransformers("rate-limiter-transformer"))); + } + + public static class RateLimiterResponseTransformer extends ResponseTransformer { + private final RateLimiter rateLimiter = new RateLimiter(); + + @Override + public String getName() { + return "rate-limiter-transformer"; + } + + @Override + public Response transform(Request request, Response response, FileSource files, Parameters parameters) { + if (rateLimiter.allowRequest()) { + return Response.Builder.like(response) + .but().body("{}") + .status(200) + .build(); + } + return Response.Builder.like(response) + .but().body("{}") + .status(429) + .build(); + } + } + + static class RateLimiter { + private final long capacity; + private final long refillRate; + private final AtomicLong tokens; + private long lastRefillTimestamp; + + public RateLimiter() { + this.capacity = 50; + this.refillRate = 50; + this.tokens = new AtomicLong(capacity); + this.lastRefillTimestamp = System.currentTimeMillis(); + } + + public synchronized boolean allowRequest() { + int tokensRequested = 1; + refillTokens(); + long currentTokens = tokens.get(); + if (currentTokens >= tokensRequested) { + tokens.getAndAdd(-tokensRequested); + return true; + } + return false; + } + + private void refillTokens() { + long now = System.currentTimeMillis(); + long elapsed = now - lastRefillTimestamp; + long refillAmount = elapsed * refillRate / 1000; + tokens.set(Math.min(capacity, tokens.get() + refillAmount)); + lastRefillTimestamp = now; + } + } + + static class CapturingInterceptor implements ExecutionInterceptor { + private AtomicInteger attemptsCount = new AtomicInteger(); + + @Override + public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { + attemptsCount.incrementAndGet(); + } + } +} diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AsyncRetrySetupTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AsyncRetrySetupTest.java new file mode 100644 index 000000000000..d5d428a5c80a --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AsyncRetrySetupTest.java @@ -0,0 +1,47 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.services.retry; + +import java.util.List; +import java.util.concurrent.CompletionException; +import software.amazon.awssdk.core.SdkPlugin; +import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonAsyncClient; +import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonAsyncClientBuilder; +import software.amazon.awssdk.services.protocolrestjson.model.AllTypesResponse; + +public class AsyncRetrySetupTest extends BaseRetrySetupTest { + @Override + protected ProtocolRestJsonAsyncClientBuilder newClientBuilder() { + return ProtocolRestJsonAsyncClient.builder(); + } + + @Override + protected AllTypesResponse callAllTypes(ProtocolRestJsonAsyncClient client, List requestPlugins) { + try { + return client.allTypes(r -> r.overrideConfiguration(c -> { + for (SdkPlugin plugin : requestPlugins) { + c.addPlugin(plugin); + } + })).join(); + } catch (CompletionException e) { + if (e.getCause() instanceof RuntimeException) { + throw (RuntimeException) e.getCause(); + } + + throw e; + } + } +} \ No newline at end of file diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/BaseRetrySetupTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/BaseRetrySetupTest.java new file mode 100644 index 000000000000..2e7ff6f8abd1 --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/BaseRetrySetupTest.java @@ -0,0 +1,463 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.services.retry; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.anyRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.anyUrl; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.github.tomakehurst.wiremock.WireMockServer; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder; +import software.amazon.awssdk.awscore.retry.AwsRetryPolicy; +import software.amazon.awssdk.awscore.retry.AwsRetryStrategy; +import software.amazon.awssdk.core.SdkPlugin; +import software.amazon.awssdk.core.SdkServiceClientConfiguration; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; +import software.amazon.awssdk.core.internal.retry.RetryPolicyAdapter; +import software.amazon.awssdk.core.internal.retry.SdkDefaultRetryStrategy; +import software.amazon.awssdk.core.retry.RetryMode; +import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.profiles.ProfileFile; +import software.amazon.awssdk.regions.Region; +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 software.amazon.awssdk.services.protocolrestjson.model.AllTypesResponse; +import software.amazon.awssdk.utils.StringInputStream; + +public abstract class BaseRetrySetupTest> { + + protected WireMockServer wireMock = new WireMockServer(0); + + protected abstract BuilderT newClientBuilder(); + + protected abstract AllTypesResponse callAllTypes(ClientT client, List requestPlugins); + + @ParameterizedTest(name = "{index} {0}") + @MethodSource("allScenarios") + public void testAllScenarios(RetryScenario scenario) { + stubThrottlingResponse(); + setupScenarioBefore(scenario); + ClientT client = setupClientBuilder(scenario).build(); + List requestPlugins = setupRequestPlugins(scenario); + assertThatThrownBy(() -> callAllTypes(client, requestPlugins)) + .isInstanceOf(SdkException.class); + verifyRequestCount(expectedCount(scenario.mode())); + } + + private BuilderT setupClientBuilder(RetryScenario scenario) { + BuilderT builder = clientBuilder(); + RetryImplementation kind = scenario.retryImplementation(); + if (kind == RetryImplementation.POLICY) { + setupRetryPolicy(builder, scenario); + } else if (kind == RetryImplementation.STRATEGY) { + setupRetryStrategy(builder, scenario); + } else { + throw new IllegalArgumentException(); + } + return builder; + } + + private void setupRetryPolicy(BuilderT builder, RetryScenario scenario) { + RetryMode mode = scenario.mode(); + RetryModeSetup setup = scenario.setup(); + switch (setup) { + case PROFILE_USING_MODE: + setupProfile(builder, scenario.mode()); + break; + case CLIENT_OVERRIDE_USING_MODE: + builder.overrideConfiguration(o -> o.retryPolicy(mode)); + break; + case CLIENT_OVERRIDE_USING_INSTANCE: + builder.overrideConfiguration(o -> o.retryPolicy(AwsRetryPolicy.forRetryMode(mode))); + break; + case CLIENT_PLUGIN_OVERRIDE_USING_INSTANCE: + case CLIENT_PLUGIN_OVERRIDE_USING_MODE: + builder.addPlugin(new ConfigureRetryScenario(scenario)); + break; + } + } + + private void setupRetryStrategy(BuilderT builder, RetryScenario scenario) { + RetryMode mode = scenario.mode(); + // Note, we don't setup the request level plugins, those need to be added at request time and not when we build the + // client. + switch (scenario.setup()) { + case PROFILE_USING_MODE: + setupProfile(builder, scenario.mode()); + break; + case CLIENT_OVERRIDE_USING_MODE: + builder.overrideConfiguration(o -> o.retryStrategy(mode)); + break; + case CLIENT_OVERRIDE_USING_INSTANCE: + builder.overrideConfiguration(o -> o.retryStrategy(AwsRetryStrategy.forRetryMode(mode))); + break; + case CLIENT_PLUGIN_OVERRIDE_USING_INSTANCE: + case CLIENT_PLUGIN_OVERRIDE_USING_MODE: + builder.addPlugin(new ConfigureRetryScenario(scenario)); + break; + } + } + + private void setupProfile(BuilderT builder, RetryMode mode) { + String modeName = mode.toString().toLowerCase(Locale.ROOT); + ProfileFile profileFile = ProfileFile.builder() + .content(new StringInputStream("[profile retry_test]\n" + + "retry_mode = " + modeName)) + .type(ProfileFile.Type.CONFIGURATION) + .build(); + builder.overrideConfiguration(o -> o.defaultProfileFile(profileFile) + .defaultProfileName("retry_test")).build(); + + } + + private List setupRequestPlugins(RetryScenario scenario) { + List plugins = new ArrayList<>(); + RetryModeSetup setup = scenario.setup(); + if (setup == RetryModeSetup.REQUEST_PLUGIN_OVERRIDE_USING_MODE + || setup == RetryModeSetup.REQUEST_PLUGIN_OVERRIDE_USING_INSTANCE) { + plugins.add(new ConfigureRetryScenario(scenario)); + } + // Plugin to validate the scenarios, must go after plugin to the configure the retry + // scenario. + plugins.add(new ValidateRetryScenario(scenario)); + return plugins; + } + + private BuilderT clientBuilder() { + StaticCredentialsProvider credentialsProvider = + StaticCredentialsProvider.create(AwsBasicCredentials.create("akid", "skid")); + return newClientBuilder().credentialsProvider(credentialsProvider) + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://localhost:" + wireMock.port())); + } + + private void setupScenarioBefore(RetryScenario scenario) { + if (scenario.setup() == RetryModeSetup.SYSTEM_PROPERTY_USING_MODE) { + System.setProperty("aws.retryMode", scenario.mode().name().toLowerCase(Locale.ROOT)); + } + } + + @BeforeEach + private void beforeEach() { + wireMock.start(); + } + + @AfterEach + private void afterEach() { + System.clearProperty("aws.retryMode"); + wireMock.stop(); + } + + private static int expectedCount(RetryMode mode) { + switch (mode) { + case ADAPTIVE: + case ADAPTIVE_V2: + case STANDARD: + return 3; + case LEGACY: + return 4; + default: + throw new IllegalArgumentException(); + } + } + + private void verifyRequestCount(int count) { + wireMock.verify(count, anyRequestedFor(anyUrl())); + } + + private void stubThrottlingResponse() { + wireMock.stubFor(post(anyUrl()) + .willReturn(aResponse().withStatus(429))); + } + + /** + * For each base scenario we add each possible setup of the retry mode. + */ + private static List allScenarios() { + List result = new ArrayList<>(); + for (RetryScenario scenario : baseScenarios()) { + for (RetryModeSetup setupMode : RetryModeSetup.values()) { + RetryScenario newScenario = scenario.toBuilder().setup(setupMode).build(); + if (isSupportedScenario(newScenario)) { + result.add(newScenario); + } + } + } + return result; + } + + /** + * Not all scenarios are supported, this methods filter those that are not. + */ + private static boolean isSupportedScenario(RetryScenario scenario) { + // Profile now only returns strategies, not policies, except for ADAPTIVE mode for which an adapter + // is used. That case is tested using RetryImplementation.STRATEGY. + if (scenario.retryImplementation() == RetryImplementation.POLICY + && scenario.setup() == RetryModeSetup.PROFILE_USING_MODE) { + return false; + } + + // Using system properties only returns strategies, not policies, except for ADAPTIVE mode for + // which an adapter is used. That case is tested using RetryImplementation.STRATEGY. + if (scenario.retryImplementation() == RetryImplementation.POLICY + && scenario.setup() == RetryModeSetup.SYSTEM_PROPERTY_USING_MODE) { + return false; + } + + // Retry policies only support the legacy ADAPTIVE mode. + if (scenario.retryImplementation() == RetryImplementation.POLICY + && scenario.mode() == RetryMode.ADAPTIVE_V2) { + return false; + } + + return true; + } + + /** + * Base retry scenarios. + */ + private static List baseScenarios() { + return Arrays.asList( + // Retry Policy + RetryScenario.builder() + .mode(RetryMode.LEGACY) + .retryImplementation(RetryImplementation.POLICY) + .expectedClass(RetryPolicy.class) + .build() + , RetryScenario.builder() + .mode(RetryMode.STANDARD) + .retryImplementation(RetryImplementation.POLICY) + .expectedClass(RetryPolicy.class) + .build() + , RetryScenario.builder() + .mode(RetryMode.ADAPTIVE) + .retryImplementation(RetryImplementation.POLICY) + .expectedClass(RetryPolicy.class) + .build() + // Retry Strategy + , RetryScenario.builder() + .mode(RetryMode.LEGACY) + .retryImplementation(RetryImplementation.STRATEGY) + .expectedClass(LegacyRetryStrategy.class) + .build() + , RetryScenario.builder() + .mode(RetryMode.STANDARD) + .retryImplementation(RetryImplementation.STRATEGY) + .expectedClass(StandardRetryStrategy.class) + .build() + , RetryScenario.builder() + .mode(RetryMode.ADAPTIVE) + .retryImplementation(RetryImplementation.STRATEGY) + .expectedClass(RetryPolicyAdapter.class) + .build() + , RetryScenario.builder() + .mode(RetryMode.ADAPTIVE_V2) + .retryImplementation(RetryImplementation.STRATEGY) + .expectedClass(AdaptiveRetryStrategy.class) + .build() + ); + } + + static class RetryScenario { + private final RetryMode mode; + private final Class expectedClass; + private final RetryModeSetup setup; + private final RetryImplementation retryImplementation; + + RetryScenario(Builder builder) { + this.mode = builder.mode; + this.expectedClass = builder.expectedClass; + this.setup = builder.setup; + this.retryImplementation = builder.retryImplementation; + } + + public RetryMode mode() { + return mode; + } + + public Class expectedClass() { + return expectedClass; + } + + public RetryModeSetup setup() { + return setup; + } + + public RetryImplementation retryImplementation() { + return retryImplementation; + } + + public Builder toBuilder() { + return new Builder(this); + } + + @Override + public String toString() { + return mode + " " + retryImplementation + " " + setup; + } + + public static Builder builder() { + return new Builder(); + } + + static class Builder { + private RetryMode mode; + private Class expectedClass; + private RetryModeSetup setup; + private RetryImplementation retryImplementation; + + public Builder() { + } + + public Builder(RetryScenario retrySetup) { + this.mode = retrySetup.mode; + this.expectedClass = retrySetup.expectedClass; + this.setup = retrySetup.setup; + this.retryImplementation = retrySetup.retryImplementation; + } + + public Builder mode(RetryMode mode) { + this.mode = mode; + return this; + } + + public Builder expectedClass(Class expectedClass) { + this.expectedClass = expectedClass; + return this; + } + + public Builder setup(RetryModeSetup setup) { + this.setup = setup; + return this; + } + + public Builder retryImplementation(RetryImplementation retryImplementation) { + this.retryImplementation = retryImplementation; + return this; + } + + public RetryScenario build() { + return new RetryScenario(this); + } + } + } + + enum RetryModeSetup { + CLIENT_OVERRIDE_USING_MODE, + CLIENT_OVERRIDE_USING_INSTANCE, + CLIENT_PLUGIN_OVERRIDE_USING_MODE, + CLIENT_PLUGIN_OVERRIDE_USING_INSTANCE, + REQUEST_PLUGIN_OVERRIDE_USING_MODE, + REQUEST_PLUGIN_OVERRIDE_USING_INSTANCE, + PROFILE_USING_MODE, + SYSTEM_PROPERTY_USING_MODE, + } + + enum RetryImplementation { + POLICY, STRATEGY + } + + static class ConfigureRetryScenario implements SdkPlugin { + private RetryScenario scenario; + + ConfigureRetryScenario(RetryScenario scenario) { + this.scenario = scenario; + } + + @Override + public void configureClient(SdkServiceClientConfiguration.Builder config) { + RetryModeSetup setup = scenario.setup(); + if (setup == RetryModeSetup.CLIENT_PLUGIN_OVERRIDE_USING_MODE + || setup == RetryModeSetup.REQUEST_PLUGIN_OVERRIDE_USING_MODE) { + if (scenario.retryImplementation() == RetryImplementation.POLICY) { + config.overrideConfiguration(o -> o.retryPolicy(scenario.mode())); + } else if (scenario.retryImplementation() == RetryImplementation.STRATEGY) { + config.overrideConfiguration(o -> o.retryStrategy(scenario.mode())); + } else { + throw new IllegalArgumentException(); + } + } else if (setup == RetryModeSetup.CLIENT_PLUGIN_OVERRIDE_USING_INSTANCE + || setup == RetryModeSetup.REQUEST_PLUGIN_OVERRIDE_USING_INSTANCE) { + if (scenario.retryImplementation() == RetryImplementation.POLICY) { + config.overrideConfiguration(o -> o.retryPolicy(AwsRetryPolicy.forRetryMode(scenario.mode()))); + } else if (scenario.retryImplementation() == RetryImplementation.STRATEGY) { + config.overrideConfiguration(o -> o.retryStrategy(AwsRetryStrategy.forRetryMode(scenario.mode()))); + } else { + throw new IllegalArgumentException(); + } + } + } + } + + static class ValidateRetryScenario implements SdkPlugin { + private RetryScenario scenario; + + public ValidateRetryScenario(RetryScenario scenario) { + this.scenario = scenario; + } + + @Override + public void configureClient(SdkServiceClientConfiguration.Builder config) { + if (scenario.retryImplementation() == RetryImplementation.POLICY) { + assertThat(config.overrideConfiguration().retryPolicy()).isNotEmpty(); + RetryPolicy policy = config.overrideConfiguration().retryPolicy().get(); + assertThat(policy.retryMode()).isEqualTo(scenario.mode()); + assertThat(policy).isInstanceOf(scenario.expectedClass()); + } else if (scenario.retryImplementation() == RetryImplementation.STRATEGY) { + assertThat(config.overrideConfiguration().retryPolicy()).isEmpty(); + assertThat(config.overrideConfiguration().retryStrategy()).isNotEmpty(); + RetryStrategy strategy = config.overrideConfiguration().retryStrategy().get(); + assertThat(SdkDefaultRetryStrategy.retryMode(strategy)).isEqualTo(scenario.mode()); + assertThat(strategy).isInstanceOf(scenario.expectedClass()); + } + } + } + + public static class CapturingInterceptor implements ExecutionInterceptor { + private Context.BeforeTransmission context; + private ExecutionAttributes executionAttributes; + + @Override + public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { + this.context = context; + this.executionAttributes = executionAttributes; + throw new RuntimeException("boom!"); + } + + public ExecutionAttributes executionAttributes() { + return executionAttributes; + } + } +} diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/SyncRetrySetupTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/SyncRetrySetupTest.java new file mode 100644 index 000000000000..a71323c5a713 --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/SyncRetrySetupTest.java @@ -0,0 +1,38 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.awssdk.services.retry; + +import java.util.List; +import software.amazon.awssdk.core.SdkPlugin; +import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonClient; +import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonClientBuilder; +import software.amazon.awssdk.services.protocolrestjson.model.AllTypesResponse; + +public class SyncRetrySetupTest extends BaseRetrySetupTest { + @Override + protected ProtocolRestJsonClientBuilder newClientBuilder() { + return ProtocolRestJsonClient.builder(); + } + + @Override + protected AllTypesResponse callAllTypes(ProtocolRestJsonClient client, List requestPlugins) { + return client.allTypes(r -> r.overrideConfiguration(c -> { + for (SdkPlugin plugin : requestPlugins) { + c.addPlugin(plugin); + } + })); + } +}