diff --git a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
index 05606dc6d574..ee91803fdf91 100644
--- a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
+++ b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml
@@ -348,6 +348,9 @@
+
+
+
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmer.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmer.java
new file mode 100644
index 000000000000..6f22214e7194
--- /dev/null
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmer.java
@@ -0,0 +1,146 @@
+/*
+ * 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.core.internal.http.loader;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import org.reactivestreams.Publisher;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.annotations.SdkTestInternalApi;
+import software.amazon.awssdk.core.internal.crac.RegionEndpointResolver;
+import software.amazon.awssdk.core.internal.crac.WarmUpDiscovery;
+import software.amazon.awssdk.core.internal.http.async.SimpleHttpContentPublisher;
+import software.amazon.awssdk.http.SdkHttpFullRequest;
+import software.amazon.awssdk.http.SdkHttpMethod;
+import software.amazon.awssdk.http.SdkHttpResponse;
+import software.amazon.awssdk.http.async.AsyncExecuteRequest;
+import software.amazon.awssdk.http.async.SdkAsyncHttpClient;
+import software.amazon.awssdk.http.async.SdkAsyncHttpResponseHandler;
+import software.amazon.awssdk.http.async.SdkAsyncHttpService;
+import software.amazon.awssdk.http.async.SimpleSubscriber;
+import software.amazon.awssdk.utils.AttributeMap;
+import software.amazon.awssdk.utils.IoUtils;
+import software.amazon.awssdk.utils.Logger;
+
+/**
+ * Warms every async {@link SdkAsyncHttpService} on the classpath for CRaC priming: builds each client and sends a best-effort
+ * {@code GET} to the resolved STS endpoint, draining the reactive response body, so the HTTP/DNS/TLS/cert-chain code is
+ * JIT-compiled into the snapshot.
+ */
+@SdkInternalApi
+public final class AsyncHttpClientWarmer implements HttpClientWarmer {
+
+ private static final Logger log = Logger.loggerFor(AsyncHttpClientWarmer.class);
+
+ private static final long WARM_UP_TIMEOUT_SECONDS = 5;
+
+ private final Iterable services;
+ private final Supplier endpointProvider;
+
+ @SdkTestInternalApi
+ AsyncHttpClientWarmer(Iterable services, Supplier endpointProvider) {
+ this.services = services;
+ this.endpointProvider = endpointProvider;
+ }
+
+ /**
+ * Warms a single {@code service} against {@code endpointProvider}.
+ */
+ @SdkTestInternalApi
+ public static AsyncHttpClientWarmer forService(Supplier endpointProvider, SdkAsyncHttpService service) {
+ return new AsyncHttpClientWarmer(Collections.singletonList(service), endpointProvider);
+ }
+
+ public static AsyncHttpClientWarmer create() {
+ return new AsyncHttpClientWarmer(discoverServices(), () -> RegionEndpointResolver.create().endpoint());
+ }
+
+ /**
+ * Like {@link #create()}, but warms against {@code endpointProvider}.
+ */
+ @SdkTestInternalApi
+ public static AsyncHttpClientWarmer create(Supplier endpointProvider) {
+ return new AsyncHttpClientWarmer(discoverServices(), endpointProvider);
+ }
+
+ private static Iterable discoverServices() {
+ return () -> SdkServiceLoader.INSTANCE.loadServices(SdkAsyncHttpService.class);
+ }
+
+ @Override
+ public void warmAll() {
+ URI endpoint = endpointProvider.get();
+ WarmUpDiscovery.forEachDiscovered(services.iterator(), service -> {
+ SdkAsyncHttpClient client = service.createAsyncHttpClientFactory().buildWithDefaults(AttributeMap.empty());
+ warmClient(client, endpoint);
+ });
+ }
+
+ /**
+ * Sends the warm-up {@code GET} to {@code endpoint}, drains the response body, and closes the client. Best-effort: any
+ * failure or timeout is logged and swallowed. We block on the execute future (bounded) because the bundled async
+ * clients complete it only after the body is drained, so its completion implies the full path was exercised.
+ */
+ private void warmClient(SdkAsyncHttpClient client, URI endpoint) {
+ try {
+ SdkHttpFullRequest httpRequest = SdkHttpFullRequest.builder()
+ .method(SdkHttpMethod.GET)
+ .uri(endpoint)
+ .build();
+ AsyncExecuteRequest request = AsyncExecuteRequest.builder()
+ .request(httpRequest)
+ .requestContentPublisher(new SimpleHttpContentPublisher(httpRequest))
+ .responseHandler(new WarmUpResponseHandler())
+ .build();
+ client.execute(request).get(WARM_UP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.debug(() -> "Async HTTP client warm-up call was interrupted (ignored).", e);
+ } catch (Exception e) {
+ // Includes ExecutionException for a failed warm-up request; best-effort, so swallow.
+ log.debug(() -> "Async HTTP client warm-up call failed (ignored).", e);
+ } finally {
+ IoUtils.closeQuietlyV2(client, log);
+ }
+ }
+
+ /**
+ * Subscribes a {@link SimpleSubscriber} to drain and discard the response body, exercising the body-read/TLS path.
+ * The subscription is required: some clients complete the execute future only once the body is consumed.
+ */
+ private static final class WarmUpResponseHandler implements SdkAsyncHttpResponseHandler {
+
+ @Override
+ public void onHeaders(SdkHttpResponse headers) {
+ // No-op: warm-up only needs the body drained, not the headers.
+ }
+
+ @Override
+ public void onStream(Publisher stream) {
+ stream.subscribe(new SimpleSubscriber(byteBuffer -> {
+ // Discard the bytes; warm-up only needs the path exercised.
+ }));
+ }
+
+ @Override
+ public void onError(Throwable error) {
+ // No-op: failure is surfaced via the execute future, which the caller blocks on.
+ }
+ }
+}
diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/ClasspathHttpWarmupInvoker.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/ClasspathHttpWarmupInvoker.java
index 3cc15194e7c3..89610b136283 100644
--- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/ClasspathHttpWarmupInvoker.java
+++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/loader/ClasspathHttpWarmupInvoker.java
@@ -15,7 +15,7 @@
package software.amazon.awssdk.core.internal.http.loader;
-import java.util.Collections;
+import java.util.Arrays;
import java.util.List;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.annotations.SdkTestInternalApi;
@@ -37,7 +37,8 @@ public final class ClasspathHttpWarmupInvoker implements HttpWarmupInvoker {
* @return an invoker over the HTTP-client warmers on the classpath.
*/
public static HttpWarmupInvoker create() {
- return new ClasspathHttpWarmupInvoker(Collections.singletonList(SyncHttpClientWarmer.create()));
+ return new ClasspathHttpWarmupInvoker(
+ Arrays.asList(SyncHttpClientWarmer.create(), AsyncHttpClientWarmer.create()));
}
@Override
diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmerTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmerTest.java
new file mode 100644
index 000000000000..9ca0343e6328
--- /dev/null
+++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/loader/AsyncHttpClientWarmerTest.java
@@ -0,0 +1,245 @@
+/*
+ * 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.core.internal.http.loader;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Predicate;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.core.LogEvent;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.reactivestreams.Publisher;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+import software.amazon.awssdk.http.SdkHttpMethod;
+import software.amazon.awssdk.http.SdkHttpResponse;
+import software.amazon.awssdk.http.async.AsyncExecuteRequest;
+import software.amazon.awssdk.http.async.SdkAsyncHttpClient;
+import software.amazon.awssdk.http.async.SdkAsyncHttpResponseHandler;
+import software.amazon.awssdk.http.async.SdkAsyncHttpService;
+import software.amazon.awssdk.testutils.LogCaptor;
+
+/**
+ * Unit tests for {@link AsyncHttpClientWarmer}. Every test drives the real {@link AsyncHttpClientWarmer#warmAll()} with an
+ * injected list of stub {@link SdkAsyncHttpService}s and a fixed endpoint. Stubbed clients drive the response handler so the
+ * drain path runs; most settle the execute future immediately, while a couple settle it after a delay or never to cover the
+ * blocking and timeout paths.
+ */
+class AsyncHttpClientWarmerTest {
+
+ private static final URI ENDPOINT = URI.create("https://sts.us-east-1.amazonaws.com/");
+
+ @Test
+ void warmAll_whenResponseHasBody_drainsAndClosesIt() {
+ AtomicBoolean drained = new AtomicBoolean(false);
+ Publisher body = bodyPublisher(drained, ByteBuffer.wrap("denied".getBytes()));
+ SdkAsyncHttpClient client = stubClient(body);
+
+ warmer(serviceFor(client)).warmAll();
+
+ assertThat(drained).isTrue(); // subscribed, drained to completion
+ verify(client).close();
+ }
+
+ @Test
+ void warmAll_whenInvoked_issuesGetToResolvedEndpoint() {
+ SdkAsyncHttpClient client = stubClient(emptyBody());
+ ArgumentCaptor request = ArgumentCaptor.forClass(AsyncExecuteRequest.class);
+
+ warmer(serviceFor(client)).warmAll();
+
+ verify(client).execute(request.capture());
+ assertThat(request.getValue().request().method()).isEqualTo(SdkHttpMethod.GET);
+ assertThat(request.getValue().request().getUri()).isEqualTo(ENDPOINT);
+ }
+
+ @Test
+ void warmAll_whenRequestFails_swallowsAndStillClosesClient() {
+ SdkAsyncHttpClient client = mock(SdkAsyncHttpClient.class);
+ when(client.execute(any(AsyncExecuteRequest.class))).thenThrow(new RuntimeException("offline"));
+
+ try (LogCaptor logCaptor = LogCaptor.create(Level.DEBUG)) {
+ assertThatCode(() -> warmer(serviceFor(client)).warmAll()).doesNotThrowAnyException();
+
+ verify(client).close();
+ assertThat(logCaptor.loggedEvents())
+ .filteredOn(loggedFromWarmer())
+ .anyMatch(event -> event.getLevel() == Level.DEBUG
+ && event.getMessage().getFormattedMessage().contains("warm-up call failed (ignored)"));
+ }
+ }
+
+ @Test
+ void warmAll_whenNoResponseBody_stillClosesClient() {
+ SdkAsyncHttpClient client = stubClient(emptyBody());
+
+ assertThatCode(() -> warmer(serviceFor(client)).warmAll()).doesNotThrowAnyException();
+ verify(client).close();
+ }
+
+ @Test
+ void warmAll_whenExecuteTakesLongerThanTimeout_timesOutSwallowsAndClosesClient() {
+ // execute() returns a future that never completes, so the bounded get() times out.
+ SdkAsyncHttpClient client = mock(SdkAsyncHttpClient.class);
+ when(client.execute(any(AsyncExecuteRequest.class))).thenReturn(new CompletableFuture<>());
+
+ try (LogCaptor logCaptor = LogCaptor.create(Level.DEBUG)) {
+ assertThatCode(() -> warmer(serviceFor(client)).warmAll()).doesNotThrowAnyException();
+
+ verify(client).close();
+ assertThat(logCaptor.loggedEvents())
+ .filteredOn(loggedFromWarmer())
+ .anyMatch(event -> event.getLevel() == Level.DEBUG
+ && event.getMessage().getFormattedMessage().contains("warm-up call failed (ignored)"));
+ }
+ }
+
+ @Test
+ void warmAll_whenStreamReadTakesLongerThanTimeout_timesOutSwallowsAndClosesClient() {
+ // The handler subscribes to a body that never finishes, so the execute future stays pending and get() times out.
+ SdkAsyncHttpClient client = mock(SdkAsyncHttpClient.class);
+ when(client.execute(any(AsyncExecuteRequest.class))).thenAnswer(invocation -> {
+ AsyncExecuteRequest request = invocation.getArgument(0);
+ request.responseHandler().onStream(neverEndingBody());
+ return new CompletableFuture();
+ });
+
+ try (LogCaptor logCaptor = LogCaptor.create(Level.DEBUG)) {
+ assertThatCode(() -> warmer(serviceFor(client)).warmAll()).doesNotThrowAnyException();
+
+ verify(client).close();
+ assertThat(logCaptor.loggedEvents())
+ .filteredOn(loggedFromWarmer())
+ .anyMatch(event -> event.getLevel() == Level.DEBUG
+ && event.getMessage().getFormattedMessage().contains("warm-up call failed (ignored)"));
+ }
+ }
+
+ @Test
+ void warmAll_whenMultipleServicesDiscovered_warmsEach() {
+ SdkAsyncHttpClient first = stubClient(emptyBody());
+ SdkAsyncHttpClient second = stubClient(emptyBody());
+
+ warmer(serviceFor(first), serviceFor(second)).warmAll();
+
+ verify(first).execute(any(AsyncExecuteRequest.class));
+ verify(second).execute(any(AsyncExecuteRequest.class));
+ }
+
+ @Test
+ void warmAll_whenOneServiceFailsToBuild_stillWarmsOthers() {
+ SdkAsyncHttpService failing = mock(SdkAsyncHttpService.class);
+ when(failing.createAsyncHttpClientFactory()).thenThrow(new RuntimeException("bad service"));
+ SdkAsyncHttpClient healthy = stubClient(emptyBody());
+
+ warmer(failing, serviceFor(healthy)).warmAll();
+
+ verify(healthy).execute(any(AsyncExecuteRequest.class));
+ }
+
+ @Test
+ void warmAll_whenNoServices_isNoOp() {
+ assertThatCode(() -> warmer().warmAll()).doesNotThrowAnyException();
+ }
+
+ private static AsyncHttpClientWarmer warmer(SdkAsyncHttpService... services) {
+ return new AsyncHttpClientWarmer(Arrays.asList(services), () -> ENDPOINT);
+ }
+
+ private static Predicate loggedFromWarmer() {
+ return event -> AsyncHttpClientWarmer.class.getName().equals(event.getLoggerName());
+ }
+
+ /** A service whose builder yields the given client. */
+ private static SdkAsyncHttpService serviceFor(SdkAsyncHttpClient client) {
+ SdkAsyncHttpClient.Builder> builder = mock(SdkAsyncHttpClient.Builder.class);
+ when(builder.buildWithDefaults(any())).thenReturn(client);
+
+ SdkAsyncHttpService service = mock(SdkAsyncHttpService.class);
+ when(service.createAsyncHttpClientFactory()).thenReturn(builder);
+ return service;
+ }
+
+ /**
+ * A client whose {@code execute} drives the response handler with the given body and completes the returned future, so
+ * the warmer's drain path runs and its bounded wait settles immediately.
+ */
+ private static SdkAsyncHttpClient stubClient(Publisher body) {
+ SdkAsyncHttpClient client = mock(SdkAsyncHttpClient.class);
+ when(client.execute(any(AsyncExecuteRequest.class))).thenAnswer(invocation -> {
+ AsyncExecuteRequest request = invocation.getArgument(0);
+ SdkAsyncHttpResponseHandler handler = request.responseHandler();
+ handler.onHeaders(SdkHttpResponse.builder().statusCode(403).build());
+ handler.onStream(body);
+ return CompletableFuture.completedFuture(null);
+ });
+ return client;
+ }
+
+ /** A publisher that delivers the given chunks then completes, flipping {@code drained} once the stream completes. */
+ private static Publisher bodyPublisher(AtomicBoolean drained, ByteBuffer... chunks) {
+ return subscriber -> subscriber.onSubscribe(new Subscription() {
+ private boolean done;
+
+ @Override
+ public void request(long n) {
+ if (done) {
+ return;
+ }
+ done = true;
+ for (ByteBuffer chunk : chunks) {
+ subscriber.onNext(chunk);
+ }
+ drained.set(true);
+ subscriber.onComplete();
+ }
+
+ @Override
+ public void cancel() {
+ done = true;
+ }
+ });
+ }
+
+ private static Publisher emptyBody() {
+ return bodyPublisher(new AtomicBoolean(false));
+ }
+
+ /** A publisher that subscribes but never signals onNext/onComplete/onError, so the stream read never finishes. */
+ private static Publisher neverEndingBody() {
+ return subscriber -> subscriber.onSubscribe(new Subscription() {
+ @Override
+ public void request(long n) {
+ // No terminal signal: the read never completes.
+ }
+
+ @Override
+ public void cancel() {
+ }
+ });
+ }
+}
diff --git a/test/warmup-tests/pom.xml b/test/warmup-tests/pom.xml
index 1f898dd20ae4..be684f52b9c9 100644
--- a/test/warmup-tests/pom.xml
+++ b/test/warmup-tests/pom.xml
@@ -68,6 +68,12 @@
${awsjavasdk.version}
test
+
+ software.amazon.awssdk
+ netty-nio-client
+ ${awsjavasdk.version}
+ test
+
software.amazon.awssdk
url-connection-client
diff --git a/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/AsyncHttpClientWarmUpTest.java b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/AsyncHttpClientWarmUpTest.java
new file mode 100644
index 000000000000..85d32367bea7
--- /dev/null
+++ b/test/warmup-tests/src/test/java/software/amazon/awssdk/http/warmup/AsyncHttpClientWarmUpTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.http.warmup;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.any;
+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.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+import java.net.URI;
+import java.util.ServiceLoader;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.awssdk.core.internal.http.loader.AsyncHttpClientWarmer;
+import software.amazon.awssdk.http.async.SdkAsyncHttpService;
+import software.amazon.awssdk.http.crt.AwsCrtSdkHttpService;
+import software.amazon.awssdk.http.nio.netty.NettySdkAsyncHttpService;
+
+/**
+ * Verifies the CRaC async warm-up sends its GET through each real async HTTP client. Every async client is exercised here, in
+ * one place, instead of a separate test in each client module. The stub mirrors a real STS {@code GET} (302 redirect, empty
+ * body); the warm-up must not follow the redirect, so exactly one request is expected.
+ */
+class AsyncHttpClientWarmUpTest {
+
+ // Minimum async HTTP clients expected on this module's classpath: netty-nio, aws-crt async. Used as a floor so a broken
+ // ServiceLoader that discovers nothing fails the test instead of passing a trivial verify(0).
+ private static final int MIN_ASYNC_CLIENT_COUNT = 2;
+
+ private WireMockServer mockServer;
+
+ static SdkAsyncHttpService[] asyncClients() {
+ return new SdkAsyncHttpService[] {
+ new NettySdkAsyncHttpService(),
+ new AwsCrtSdkHttpService()
+ };
+ }
+
+ @BeforeEach
+ void setUp() {
+ mockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort());
+ mockServer.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockServer.stop();
+ }
+
+ @ParameterizedTest
+ @MethodSource("asyncClients")
+ void warmAll_sendsWarmUpGetThroughClient(SdkAsyncHttpService service) {
+ mockServer.stubFor(any(anyUrl()).willReturn(aResponse()
+ .withStatus(302)
+ .withHeader("Location", "https://aws.amazon.com/iam")));
+
+ URI endpoint = URI.create("http://localhost:" + mockServer.port() + "/");
+ AsyncHttpClientWarmer.forService(() -> endpoint, service).warmAll();
+
+ mockServer.verify(1, getRequestedFor(urlPathEqualTo("/")));
+ mockServer.verify(1, anyRequestedFor(anyUrl()));
+ }
+
+ /**
+ * Exercises the real classpath-discovery path used by {@code prime()}: {@code warmAll()} discovers every async
+ * {@link SdkAsyncHttpService} on the classpath via {@link ServiceLoader} and warms each. Confirms discovery finds all
+ * {@value #MIN_ASYNC_CLIENT_COUNT} clients and that each receives exactly one warm-up GET.
+ */
+ @Test
+ void warmAll_whenDiscoveringFromClasspath_warmsEveryAsyncClient() {
+ mockServer.stubFor(any(anyUrl()).willReturn(aResponse()
+ .withStatus(302)
+ .withHeader("Location", "https://aws.amazon.com/iam")));
+
+ int discoveredClients = 0;
+ for (SdkAsyncHttpService ignored : ServiceLoader.load(SdkAsyncHttpService.class)) {
+ discoveredClients++;
+ }
+ // Fail if discovery finds fewer clients than expected (e.g. a broken ServiceLoader finding none).
+ assertThat(discoveredClients).isGreaterThanOrEqualTo(MIN_ASYNC_CLIENT_COUNT);
+
+ URI endpoint = URI.create("http://localhost:" + mockServer.port() + "/");
+ AsyncHttpClientWarmer.create(() -> endpoint).warmAll();
+
+ // Verify against the actual discovered count, not the constant, so this stays correct as clients are added.
+ mockServer.verify(discoveredClients, getRequestedFor(urlPathEqualTo("/")));
+ mockServer.verify(discoveredClients, anyRequestedFor(anyUrl()));
+ }
+}