From 3e9b8ef7269ba3123abc7b17309490e14f7f6d92 Mon Sep 17 00:00:00 2001
From: Zoe Wang <33073555+zoewangg@users.noreply.github.com>
Date: Thu, 27 Aug 2026 14:13:19 -0700
Subject: [PATCH] feat: bake per-service read timeout exemptions
Codegen bakes each listed service's default read/write timeout
tier into its generated serviceHttpConfig from a checked-in
exemption artifact; aws-core applies the rollout gate to the
baked value.
---
.../DefaultCustomizationProcessor.java | 3 +-
...ultReadWriteTimeoutExemptionProcessor.java | 117 ++++++++
.../codegen/model/intermediate/Metadata.java | 20 ++
.../poet/builder/BaseClientBuilderClass.java | 42 ++-
...default-read-write-timeout-exemptions.json | 85 ++++++
...eadWriteTimeoutExemptionProcessorTest.java | 147 ++++++++++
...lientBuilderClassReadWriteTimeoutTest.java | 67 +++++
.../builder/BaseClientBuilderClassTest.java | 9 +
...-timeout-service-client-builder-class.java | 250 ++++++++++++++++++
.../builder/AwsDefaultClientBuilder.java | 48 +++-
...aultClientBuilderReadWriteTimeoutTest.java | 205 ++++++++++++++
11 files changed, 979 insertions(+), 14 deletions(-)
create mode 100644 codegen/src/main/java/software/amazon/awssdk/codegen/customization/processors/DefaultReadWriteTimeoutExemptionProcessor.java
create mode 100644 codegen/src/main/resources/software/amazon/awssdk/codegen/default-read-write-timeout-exemptions.json
create mode 100644 codegen/src/test/java/software/amazon/awssdk/codegen/customization/processors/DefaultReadWriteTimeoutExemptionProcessorTest.java
create mode 100644 codegen/src/test/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClassReadWriteTimeoutTest.java
create mode 100644 codegen/src/test/resources/software/amazon/awssdk/codegen/poet/builder/test-read-write-timeout-service-client-builder-class.java
create mode 100644 core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilderReadWriteTimeoutTest.java
diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/customization/processors/DefaultCustomizationProcessor.java b/codegen/src/main/java/software/amazon/awssdk/codegen/customization/processors/DefaultCustomizationProcessor.java
index 1511bf6be282..d0fbd360dbf3 100644
--- a/codegen/src/main/java/software/amazon/awssdk/codegen/customization/processors/DefaultCustomizationProcessor.java
+++ b/codegen/src/main/java/software/amazon/awssdk/codegen/customization/processors/DefaultCustomizationProcessor.java
@@ -43,7 +43,8 @@ public static CodegenCustomizationProcessor getProcessorFor(
new S3ControlRemoveAccountIdHostPrefixProcessor(),
new ExplicitStringPayloadQueryProtocolProcessor(),
new LowercaseShapeValidatorProcessor(),
- new LongPollingOperationProcessor()
+ new LongPollingOperationProcessor(),
+ new DefaultReadWriteTimeoutExemptionProcessor()
);
}
}
diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/customization/processors/DefaultReadWriteTimeoutExemptionProcessor.java b/codegen/src/main/java/software/amazon/awssdk/codegen/customization/processors/DefaultReadWriteTimeoutExemptionProcessor.java
new file mode 100644
index 000000000000..87766c3cfb85
--- /dev/null
+++ b/codegen/src/main/java/software/amazon/awssdk/codegen/customization/processors/DefaultReadWriteTimeoutExemptionProcessor.java
@@ -0,0 +1,117 @@
+/*
+ * 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.codegen.customization.processors;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import software.amazon.awssdk.annotations.SdkTestInternalApi;
+import software.amazon.awssdk.codegen.customization.CodegenCustomizationProcessor;
+import software.amazon.awssdk.codegen.model.intermediate.IntermediateModel;
+import software.amazon.awssdk.codegen.model.service.ServiceModel;
+import software.amazon.awssdk.protocols.jsoncore.JsonNode;
+import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser;
+import software.amazon.awssdk.utils.Validate;
+
+/**
+ * Bakes the per-service default read/write inactivity timeout tier into the generated service HTTP config. The tiers come from a
+ * checked-in copy of the shared exemption artifact ({@code default-read-write-timeout-exemptions.json}), keyed by the service's
+ * sdkId ({@link software.amazon.awssdk.codegen.model.intermediate.Metadata#getServiceId()}).
+ *
+ *
An artifact value of {@code -1} marks a fully-exempt service (no default timeout applies); a positive value is the applied
+ * timeout in milliseconds. A service absent from the artifact has nothing baked, and {@code aws-core} supplies the flat default
+ * when the rollout gate is on. The rollout gate itself is applied later, in {@code aws-core}; this processor only bakes the
+ * per-service tier, which is the same regardless of whether the gate is on.
+ */
+public class DefaultReadWriteTimeoutExemptionProcessor implements CodegenCustomizationProcessor {
+
+ private static final String EXEMPTIONS_RESOURCE = "software/amazon/awssdk/codegen/default-read-write-timeout-exemptions.json";
+
+ private static final Map SERVICE_ID_TO_TIMEOUT_MILLIS = loadExemptions();
+
+ private final Map serviceIdToTimeoutMillis;
+
+ public DefaultReadWriteTimeoutExemptionProcessor() {
+ this(SERVICE_ID_TO_TIMEOUT_MILLIS);
+ }
+
+ @SdkTestInternalApi
+ DefaultReadWriteTimeoutExemptionProcessor(Map serviceIdToTimeoutMillis) {
+ this.serviceIdToTimeoutMillis = serviceIdToTimeoutMillis;
+ }
+
+ @Override
+ public void preprocess(ServiceModel serviceModel) {
+ // no-op
+ }
+
+ @Override
+ public void postprocess(IntermediateModel intermediateModel) {
+ String serviceId = intermediateModel.getMetadata().getServiceId();
+ Long timeoutMillis = serviceIdToTimeoutMillis.get(serviceId);
+ if (timeoutMillis != null) {
+ intermediateModel.getMetadata().setDefaultReadWriteTimeoutMillis(timeoutMillis);
+ }
+ }
+
+ /**
+ * Fails if any artifact key does not match one of {@code knownServiceIds}. Matching is exact (case-sensitive), so a stale
+ * key (no such service) or a mis-cased key both surface here: either would otherwise silently leave the intended service
+ * unlisted and wrongly apply the flat default instead of its exempt/partial tier.
+ *
+ * Codegen processes one service per run, so this whole-artifact cross-check cannot run inside {@link #postprocess} (a
+ * single run never sees every serviceId). It is invoked at build time by the coverage test against the full set of service
+ * sdkIds.
+ */
+ void validateArtifactKeys(Set knownServiceIds) {
+ List unknownKeys = serviceIdToTimeoutMillis.keySet().stream()
+ .filter(key -> !knownServiceIds.contains(key))
+ .sorted()
+ .collect(Collectors.toList());
+ if (!unknownKeys.isEmpty()) {
+ throw new IllegalStateException(
+ "Read/write timeout exemption artifact " + EXEMPTIONS_RESOURCE + " contains key(s) matching no service sdkId "
+ + "(a stale or mis-cased key silently leaves that service unlisted): " + unknownKeys);
+ }
+ }
+
+ private static Map loadExemptions() {
+ Map exemptions = new HashMap<>();
+ try (InputStream stream = DefaultReadWriteTimeoutExemptionProcessor.class.getClassLoader()
+ .getResourceAsStream(EXEMPTIONS_RESOURCE)) {
+ Validate.notNull(stream, "Failed to load read/write timeout exemption artifact: %s", EXEMPTIONS_RESOURCE);
+ JsonNode root = JsonNodeParser.create().parse(stream);
+ root.asObject().forEach((serviceId, value) -> exemptions.put(serviceId, parseTimeoutMillis(serviceId, value)));
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to read read/write timeout exemption artifact: " + EXEMPTIONS_RESOURCE, e);
+ }
+ return Collections.unmodifiableMap(exemptions);
+ }
+
+ private static long parseTimeoutMillis(String serviceId, JsonNode value) {
+ try {
+ return Long.parseLong(value.asNumber());
+ } catch (RuntimeException e) {
+ throw new IllegalArgumentException(
+ "Invalid numeric value for key '" + serviceId + "' in " + EXEMPTIONS_RESOURCE + ": " + value, e);
+ }
+ }
+}
diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/model/intermediate/Metadata.java b/codegen/src/main/java/software/amazon/awssdk/codegen/model/intermediate/Metadata.java
index 07b5e319d60d..5916fb1ee4de 100644
--- a/codegen/src/main/java/software/amazon/awssdk/codegen/model/intermediate/Metadata.java
+++ b/codegen/src/main/java/software/amazon/awssdk/codegen/model/intermediate/Metadata.java
@@ -115,6 +115,8 @@ public class Metadata {
private String serviceId;
+ private Long defaultReadWriteTimeoutMillis;
+
private List auth;
public List getAuth() {
@@ -710,6 +712,24 @@ public Metadata withServiceId(String serviceId) {
return this;
}
+ /**
+ * The default read/write inactivity timeout baked for this service by the exemption processor, in milliseconds, or
+ * {@code null} when the service is not listed in the exemption artifact. A value of {@code -1} marks a fully-exempt service
+ * (no default timeout); a positive value is the applied timeout in milliseconds.
+ */
+ public Long getDefaultReadWriteTimeoutMillis() {
+ return defaultReadWriteTimeoutMillis;
+ }
+
+ public void setDefaultReadWriteTimeoutMillis(Long defaultReadWriteTimeoutMillis) {
+ this.defaultReadWriteTimeoutMillis = defaultReadWriteTimeoutMillis;
+ }
+
+ public Metadata withDefaultReadWriteTimeoutMillis(Long defaultReadWriteTimeoutMillis) {
+ setDefaultReadWriteTimeoutMillis(defaultReadWriteTimeoutMillis);
+ return this;
+ }
+
public String getWaitersPackageName() {
return waitersPackageName;
}
diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClass.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClass.java
index 8e583ae119c3..77f4eb8485ee 100644
--- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClass.java
+++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClass.java
@@ -31,6 +31,7 @@
import com.squareup.javapoet.TypeVariableName;
import com.squareup.javapoet.WildcardTypeName;
import java.net.URI;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -842,24 +843,27 @@ private void addServiceHttpConfigIfNeeded(TypeSpec.Builder builder, Intermediate
String serviceDefaultFqcn = model.getCustomizationConfig().getServiceSpecificHttpConfig();
boolean supportsH2 = model.getMetadata().supportsH2();
boolean usePriorKnowledgeForH2 = model.getCustomizationConfig().isUsePriorKnowledgeForH2();
+ Long readWriteTimeoutMillis = model.getMetadata().getDefaultReadWriteTimeoutMillis();
- if (serviceDefaultFqcn != null || supportsH2) {
- builder.addMethod(serviceSpecificHttpConfigMethod(serviceDefaultFqcn, supportsH2, usePriorKnowledgeForH2));
+ if (serviceDefaultFqcn != null || supportsH2 || readWriteTimeoutMillis != null) {
+ builder.addMethod(serviceSpecificHttpConfigMethod(serviceDefaultFqcn, supportsH2, usePriorKnowledgeForH2,
+ readWriteTimeoutMillis));
}
}
private MethodSpec serviceSpecificHttpConfigMethod(String serviceDefaultFqcn, boolean supportsH2,
- boolean usePriorKnowledgeForH2) {
+ boolean usePriorKnowledgeForH2, Long readWriteTimeoutMillis) {
return MethodSpec.methodBuilder("serviceHttpConfig")
.addAnnotation(Override.class)
.addModifiers(PROTECTED, FINAL)
.returns(AttributeMap.class)
- .addCode(serviceSpecificHttpConfigMethodBody(serviceDefaultFqcn, supportsH2, usePriorKnowledgeForH2))
+ .addCode(serviceSpecificHttpConfigMethodBody(serviceDefaultFqcn, supportsH2, usePriorKnowledgeForH2,
+ readWriteTimeoutMillis))
.build();
}
private CodeBlock serviceSpecificHttpConfigMethodBody(String serviceDefaultFqcn, boolean supportsH2,
- boolean usePriorKnowledgeForH2) {
+ boolean usePriorKnowledgeForH2, Long readWriteTimeoutMillis) {
CodeBlock.Builder builder = CodeBlock.builder();
if (serviceDefaultFqcn != null) {
@@ -870,14 +874,28 @@ private CodeBlock serviceSpecificHttpConfigMethodBody(String serviceDefaultFqcn,
builder.addStatement("$1T result = $1T.empty()", AttributeMap.class);
}
- if (supportsH2) {
- builder.add("return result.merge(AttributeMap.builder()"
- + ".put($T.PROTOCOL, $T.HTTP2)",
- SdkHttpConfigurationOption.class, Protocol.class);
+ if (supportsH2 || readWriteTimeoutMillis != null) {
+ builder.add("return result.merge(AttributeMap.builder()");
- if (!usePriorKnowledgeForH2) {
- builder.add(".put($T.PROTOCOL_NEGOTIATION, $T.ALPN)",
- SdkHttpConfigurationOption.class, ProtocolNegotiation.class);
+ if (supportsH2) {
+ builder.add(".put($T.PROTOCOL, $T.HTTP2)", SdkHttpConfigurationOption.class, Protocol.class);
+
+ if (!usePriorKnowledgeForH2) {
+ builder.add(".put($T.PROTOCOL_NEGOTIATION, $T.ALPN)",
+ SdkHttpConfigurationOption.class, ProtocolNegotiation.class);
+ }
+ }
+
+ if (readWriteTimeoutMillis != null) {
+ // A negative artifact value marks a fully-exempt service: bake Duration.ZERO, which means apply no
+ // read/write timeout. A positive value is the timeout in milliseconds.
+ if (readWriteTimeoutMillis < 0) {
+ builder.add(".put($T.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT, $T.ZERO)",
+ SdkHttpConfigurationOption.class, Duration.class);
+ } else {
+ builder.add(".put($T.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT, $T.ofMillis($L))",
+ SdkHttpConfigurationOption.class, Duration.class, readWriteTimeoutMillis + "L");
+ }
}
builder.addStatement(".build())");
diff --git a/codegen/src/main/resources/software/amazon/awssdk/codegen/default-read-write-timeout-exemptions.json b/codegen/src/main/resources/software/amazon/awssdk/codegen/default-read-write-timeout-exemptions.json
new file mode 100644
index 000000000000..89bbc5e49239
--- /dev/null
+++ b/codegen/src/main/resources/software/amazon/awssdk/codegen/default-read-write-timeout-exemptions.json
@@ -0,0 +1,85 @@
+{
+ "Bedrock Runtime": -1,
+ "CloudSearch Domain": -1,
+ "codeartifact": -1,
+ "ConnectHealth": -1,
+ "EBS": -1,
+ "Glacier": -1,
+ "Lambda": -1,
+ "Lex Runtime Service": -1,
+ "Lex Runtime V2": -1,
+ "MediaStore Data": -1,
+ "Omics": -1,
+ "Polly": -1,
+ "QBusiness": -1,
+ "S3": -1,
+ "SageMaker Runtime HTTP2": -1,
+ "Transcribe Streaming": -1,
+ "b2bi": 900000,
+ "Bedrock Agent Runtime": 900000,
+ "Bedrock AgentCore": 900000,
+ "Bedrock Data Automation Runtime": 900000,
+ "Data Pipeline": 900000,
+ "DataExchange": 900000,
+ "ECS": 900000,
+ "Glue": 900000,
+ "Kinesis": 900000,
+ "Kinesis Analytics V2": 900000,
+ "Kinesis Video Archived Media": 900000,
+ "Kinesis Video Media": 900000,
+ "Kinesis Video Signaling": 900000,
+ "Kinesis Video WebRTC Storage": 900000,
+ "Neptune Graph": 900000,
+ "neptunedata": 900000,
+ "Nova Act": 900000,
+ "QApps": 900000,
+ "QConnect": 900000,
+ "QuickSight": 900000,
+ "SageMaker Runtime": 900000,
+ "SagemakerJobRuntime": 900000,
+ "SFN": 900000,
+ "SQS": 900000,
+ "SWF": 900000,
+ "Timestream Query": 900000,
+ "Wisdom": 900000,
+ "API Gateway": 900000,
+ "ApiGatewayV2": 900000,
+ "AppIntegrations": 900000,
+ "AppStream": 900000,
+ "Athena": 900000,
+ "Auto Scaling": 900000,
+ "Batch": 900000,
+ "Bedrock": 900000,
+ "Bedrock Agent": 900000,
+ "Bedrock AgentCore Control": 900000,
+ "CloudFormation": 900000,
+ "CloudWatch": 900000,
+ "CodeBuild": 900000,
+ "CodeCatalyst": 900000,
+ "CodeDeploy": 900000,
+ "Config Service": 900000,
+ "Connect": 900000,
+ "DataBrew": 900000,
+ "DataZone": 900000,
+ "Device Farm": 900000,
+ "EC2": 900000,
+ "Elastic Load Balancing v2": 900000,
+ "EMR Serverless": 900000,
+ "GameLift": 900000,
+ "GameLiftStreams": 900000,
+ "IoT": 900000,
+ "IoT Data Plane": 900000,
+ "IoT Jobs Data Plane": 900000,
+ "IoTSecureTunneling": 900000,
+ "Lex Model Building Service": 900000,
+ "Lex Models V2": 900000,
+ "mgn": 900000,
+ "RDS": 900000,
+ "RDS Data": 900000,
+ "RTBFabric": 900000,
+ "SageMaker": 900000,
+ "SSM": 900000,
+ "Storage Gateway": 900000,
+ "WorkSpaces": 900000,
+ "WorkSpaces Web": 900000
+}
diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/customization/processors/DefaultReadWriteTimeoutExemptionProcessorTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/customization/processors/DefaultReadWriteTimeoutExemptionProcessorTest.java
new file mode 100644
index 000000000000..eb42cc3ca5e8
--- /dev/null
+++ b/codegen/src/test/java/software/amazon/awssdk/codegen/customization/processors/DefaultReadWriteTimeoutExemptionProcessorTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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.codegen.customization.processors;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+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.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import software.amazon.awssdk.codegen.model.intermediate.IntermediateModel;
+import software.amazon.awssdk.codegen.model.intermediate.Metadata;
+import software.amazon.awssdk.protocols.jsoncore.JsonNode;
+import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser;
+
+class DefaultReadWriteTimeoutExemptionProcessorTest {
+
+ private static final String EXEMPTIONS_RESOURCE = "software/amazon/awssdk/codegen/default-read-write-timeout-exemptions.json";
+ private static final Pattern SERVICE_ID = Pattern.compile("\"serviceId\"\\s*:\\s*\"([^\"]+)\"");
+
+ @ParameterizedTest
+ @MethodSource("exemptionEntries")
+ void postprocess_serviceInArtifact_bakesExpectedTier(String serviceId, long expectedMillis) {
+ IntermediateModel model = modelWithServiceId(serviceId);
+
+ new DefaultReadWriteTimeoutExemptionProcessor().postprocess(model);
+
+ assertThat(model.getMetadata().getDefaultReadWriteTimeoutMillis()).isEqualTo(expectedMillis);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"sqs", "s3", "lambda", "kinesis", "CODEARTIFACT", "MGN", "Sqs"})
+ void postprocess_misCasedServiceId_bakesNothing(String misCasedServiceId) {
+ IntermediateModel model = modelWithServiceId(misCasedServiceId);
+
+ new DefaultReadWriteTimeoutExemptionProcessor().postprocess(model);
+
+ assertThat(model.getMetadata().getDefaultReadWriteTimeoutMillis()).isNull();
+ }
+
+ @Test
+ void postprocess_serviceNotInArtifact_bakesNothing() {
+ IntermediateModel model = modelWithServiceId("Not A Real Service");
+
+ new DefaultReadWriteTimeoutExemptionProcessor().postprocess(model);
+
+ assertThat(model.getMetadata().getDefaultReadWriteTimeoutMillis()).isNull();
+ }
+
+ @Test
+ void validateArtifactKeys_everyKeyMatchesARealServiceId() throws IOException {
+ new DefaultReadWriteTimeoutExemptionProcessor().validateArtifactKeys(realServiceIds());
+ }
+
+ @Test
+ void validateArtifactKeys_keyMatchingNoServiceId_throws() {
+ DefaultReadWriteTimeoutExemptionProcessor processor =
+ new DefaultReadWriteTimeoutExemptionProcessor(Collections.singletonMap("sqs", 900000L));
+
+ assertThatThrownBy(() -> processor.validateArtifactKeys(Collections.singleton("SQS")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("sqs");
+ }
+
+ private static IntermediateModel modelWithServiceId(String serviceId) {
+ IntermediateModel model = new IntermediateModel();
+ model.setMetadata(new Metadata().withServiceId(serviceId));
+ return model;
+ }
+
+ private static Stream exemptionEntries() throws IOException {
+ try (InputStream stream = DefaultReadWriteTimeoutExemptionProcessorTest.class.getClassLoader()
+ .getResourceAsStream(EXEMPTIONS_RESOURCE)) {
+ Map artifact = JsonNodeParser.create().parse(stream).asObject();
+ return artifact.entrySet().stream()
+ .map(e -> Arguments.of(e.getKey(), Long.parseLong(e.getValue().asNumber())));
+ }
+ }
+
+ private static Set realServiceIds() throws IOException {
+ Path servicesDir = locateServicesDir();
+ Set serviceIds = new HashSet<>();
+ try (DirectoryStream modules = Files.newDirectoryStream(servicesDir)) {
+ for (Path module : modules) {
+ Path model = module.resolve("src/main/resources/codegen-resources/service-2.json");
+ if (Files.isRegularFile(model)) {
+ extractServiceId(model).ifPresent(serviceIds::add);
+ }
+ }
+ }
+ assertThat(serviceIds).as("expected to harvest serviceIds from the service models").isNotEmpty();
+ return serviceIds;
+ }
+
+ private static Optional extractServiceId(Path serviceModel) throws IOException {
+ try (BufferedReader reader = Files.newBufferedReader(serviceModel, StandardCharsets.UTF_8)) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ Matcher matcher = SERVICE_ID.matcher(line);
+ if (matcher.find()) {
+ return Optional.of(matcher.group(1));
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static Path locateServicesDir() {
+ for (Path candidate : new Path[] {Paths.get("..", "services"), Paths.get("services"), Paths.get("..", "..", "services")}) {
+ if (Files.isDirectory(candidate)) {
+ return candidate;
+ }
+ }
+ throw new IllegalStateException("Could not locate the services/ directory from " + Paths.get("").toAbsolutePath());
+ }
+}
diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClassReadWriteTimeoutTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClassReadWriteTimeoutTest.java
new file mode 100644
index 000000000000..9dabf03da41e
--- /dev/null
+++ b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClassReadWriteTimeoutTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.codegen.poet.builder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static software.amazon.awssdk.codegen.poet.PoetUtils.buildJavaFile;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.codegen.model.intermediate.IntermediateModel;
+import software.amazon.awssdk.codegen.poet.ClientTestModels;
+
+/**
+ * Emission cases for the codegen-baked read/write timeout tier.
+ */
+class BaseClientBuilderClassReadWriteTimeoutTest {
+
+ @Test
+ void serviceHttpConfig_fullyExemptTier_bakesDurationZero() {
+ IntermediateModel model = ClientTestModels.queryServiceModels();
+ model.getMetadata().setDefaultReadWriteTimeoutMillis(-1L);
+
+ assertThat(generate(model))
+ .contains(".put(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT, Duration.ZERO)");
+ }
+
+ @Test
+ void serviceHttpConfig_partialTier_bakesDurationMillis() {
+ IntermediateModel model = ClientTestModels.queryServiceModels();
+ model.getMetadata().setDefaultReadWriteTimeoutMillis(900000L);
+
+ assertThat(generate(model))
+ .contains(".put(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT, Duration.ofMillis(900000L))");
+ }
+
+ @Test
+ void serviceHttpConfig_noTierBaked_omitsFallbackTimeout() {
+ IntermediateModel model = ClientTestModels.queryServiceModels();
+ model.getMetadata().setDefaultReadWriteTimeoutMillis(null);
+
+ assertThat(generate(model)).doesNotContain("SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT");
+ }
+
+ private static String generate(IntermediateModel model) {
+ StringBuilder output = new StringBuilder();
+ try {
+ buildJavaFile(new BaseClientBuilderClass(model)).writeTo(output);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ return output.toString();
+ }
+}
diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClassTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClassTest.java
index 3a0f184b1462..a48a74c9163c 100644
--- a/codegen/src/test/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClassTest.java
+++ b/codegen/src/test/java/software/amazon/awssdk/codegen/poet/builder/BaseClientBuilderClassTest.java
@@ -107,6 +107,15 @@ void baseClientBuilderClass_noRegionEndpointRules() {
"test-no-region-client-builder-class.java");
}
+ @Test
+ void baseClientBuilderClassWithReadWriteTimeout() {
+ IntermediateModel model = serviceWithH2();
+ // Simulate the exemption processor baking a 15-minute partial tier; verify it composes with the existing H2
+ // serviceHttpConfig content.
+ model.getMetadata().setDefaultReadWriteTimeoutMillis(900000L);
+ validateBaseClientBuilderClassGeneration(model, "test-read-write-timeout-service-client-builder-class.java");
+ }
+
private void validateBaseClientBuilderClassGeneration(IntermediateModel model, String expectedClassName) {
validateGeneration(BaseClientBuilderClass::new, model, expectedClassName);
}
diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/builder/test-read-write-timeout-service-client-builder-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/builder/test-read-write-timeout-service-client-builder-class.java
new file mode 100644
index 000000000000..765fa5789e03
--- /dev/null
+++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/builder/test-read-write-timeout-service-client-builder-class.java
@@ -0,0 +1,250 @@
+package software.amazon.awssdk.services.h2;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Consumer;
+import software.amazon.awssdk.annotations.Generated;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.awscore.auth.AuthSchemePreferenceResolver;
+import software.amazon.awssdk.awscore.client.builder.AwsDefaultClientBuilder;
+import software.amazon.awssdk.awscore.client.config.AwsClientOption;
+import software.amazon.awssdk.awscore.endpoint.AwsClientEndpointProvider;
+import software.amazon.awssdk.awscore.endpoints.AwsEndpointAttribute;
+import software.amazon.awssdk.awscore.endpoints.authscheme.EndpointAuthScheme;
+import software.amazon.awssdk.awscore.endpoints.authscheme.SigV4AuthScheme;
+import software.amazon.awssdk.awscore.retry.AwsRetryStrategy;
+import software.amazon.awssdk.core.ClientEndpointProvider;
+import software.amazon.awssdk.core.SdkPlugin;
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
+import software.amazon.awssdk.core.client.config.SdkClientConfiguration;
+import software.amazon.awssdk.core.client.config.SdkClientOption;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.core.interceptor.ClasspathInterceptorChainFactory;
+import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
+import software.amazon.awssdk.core.retry.RetryMode;
+import software.amazon.awssdk.endpoints.Endpoint;
+import software.amazon.awssdk.http.Protocol;
+import software.amazon.awssdk.http.ProtocolNegotiation;
+import software.amazon.awssdk.http.SdkHttpConfigurationOption;
+import software.amazon.awssdk.http.auth.aws.scheme.AwsV4AuthScheme;
+import software.amazon.awssdk.http.auth.scheme.NoAuthAuthScheme;
+import software.amazon.awssdk.http.auth.spi.scheme.AuthScheme;
+import software.amazon.awssdk.identity.spi.IdentityProvider;
+import software.amazon.awssdk.identity.spi.IdentityProviders;
+import software.amazon.awssdk.protocols.json.internal.unmarshall.SdkClientJsonProtocolAdvancedOption;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.retries.api.RetryStrategy;
+import software.amazon.awssdk.services.h2.auth.scheme.H2AuthSchemeProvider;
+import software.amazon.awssdk.services.h2.endpoints.H2EndpointParams;
+import software.amazon.awssdk.services.h2.endpoints.H2EndpointProvider;
+import software.amazon.awssdk.services.h2.internal.H2ServiceClientConfigurationBuilder;
+import software.amazon.awssdk.utils.AttributeMap;
+import software.amazon.awssdk.utils.CollectionUtils;
+import software.amazon.awssdk.utils.CompletableFutureUtils;
+
+/**
+ * Internal base class for {@link DefaultH2ClientBuilder} and {@link DefaultH2AsyncClientBuilder}.
+ */
+@Generated("software.amazon.awssdk:codegen")
+@SdkInternalApi
+abstract class DefaultH2BaseClientBuilder, C> extends AwsDefaultClientBuilder {
+ private final Map> additionalAuthSchemes = new HashMap<>();
+
+ @Override
+ protected final String serviceEndpointPrefix() {
+ return "h2-service";
+ }
+
+ @Override
+ protected final String serviceName() {
+ return "H2";
+ }
+
+ @Override
+ protected final SdkClientConfiguration mergeServiceDefaults(SdkClientConfiguration config) {
+ return config.merge(c -> {
+ c.option(SdkClientOption.ENDPOINT_PROVIDER, defaultEndpointProvider())
+ .option(SdkClientOption.AUTH_SCHEME_PROVIDER, defaultAuthSchemeProvider(config))
+ .option(SdkClientOption.AUTH_SCHEMES, authSchemes())
+ .option(SdkClientOption.CRC32_FROM_COMPRESSED_DATA_ENABLED, false);
+ });
+ }
+
+ @Override
+ protected final SdkClientConfiguration finalizeServiceConfiguration(SdkClientConfiguration config) {
+ List endpointInterceptors = new ArrayList<>();
+ ClasspathInterceptorChainFactory interceptorFactory = new ClasspathInterceptorChainFactory();
+ List interceptors = interceptorFactory
+ .getInterceptors("software/amazon/awssdk/services/h2/execution.interceptors");
+ List additionalInterceptors = new ArrayList<>();
+ interceptors = CollectionUtils.mergeLists(endpointInterceptors, interceptors);
+ interceptors = CollectionUtils.mergeLists(interceptors, additionalInterceptors);
+ interceptors = CollectionUtils.mergeLists(interceptors, config.option(SdkClientOption.EXECUTION_INTERCEPTORS));
+ SdkClientConfiguration.Builder builder = config.toBuilder();
+ builder.lazyOption(SdkClientOption.IDENTITY_PROVIDERS, c -> {
+ IdentityProviders.Builder result = IdentityProviders.builder();
+ IdentityProvider> credentialsIdentityProvider = c.get(AwsClientOption.CREDENTIALS_IDENTITY_PROVIDER);
+ if (credentialsIdentityProvider != null) {
+ result.putIdentityProvider(credentialsIdentityProvider);
+ }
+ return result.build();
+ });
+ builder.option(SdkClientOption.EXECUTION_INTERCEPTORS, interceptors);
+ builder.lazyOptionIfAbsent(
+ SdkClientOption.CLIENT_ENDPOINT_PROVIDER,
+ c -> {
+ Optional overrideEndpoint = AwsClientEndpointProvider.builder()
+ .serviceEndpointOverrideEnvironmentVariable("AWS_ENDPOINT_URL_H2_SERVICE")
+ .serviceEndpointOverrideSystemProperty("aws.endpointUrlH2").serviceProfileProperty("h2_service")
+ .profileFile(c.get(SdkClientOption.PROFILE_FILE_SUPPLIER))
+ .profileName(c.get(SdkClientOption.PROFILE_NAME)).resolveFromOverrides();
+ if (overrideEndpoint.isPresent()) {
+ return ClientEndpointProvider.create(overrideEndpoint.get(), true);
+ }
+ URI clientEndpointUri = null;
+ Region region = c.get(AwsClientOption.AWS_REGION);
+ try {
+ H2EndpointParams endpointParams = H2EndpointParams.builder().region(region).build();
+ Endpoint endpoint = CompletableFutureUtils.joinLikeSync(defaultEndpointProvider().resolveEndpoint(
+ endpointParams));
+ clientEndpointUri = endpoint.url();
+ } catch (Exception e) {
+ // Endpoint resolution failed. This is expected for services with required parameters
+ // beyond region, dualstack, and FIPS. Use a placeholder that will be replaced at request time.
+ return ClientEndpointProvider.create(URI.create("https://localhost"), false);
+ }
+ if (clientEndpointUri.getHost() == null) {
+ throw SdkClientException.create("Configured region (" + region + ") resulted in an invalid URI: "
+ + clientEndpointUri + ". This is usually caused by an invalid region configuration.");
+ }
+ return ClientEndpointProvider.create(clientEndpointUri, false);
+ });
+ builder.lazyOptionIfAbsent(
+ AwsClientOption.SIGNING_REGION,
+ c -> {
+ Region region = c.get(AwsClientOption.AWS_REGION);
+ try {
+ H2EndpointParams endpointParams = H2EndpointParams.builder().region(region).build();
+ Endpoint endpoint = CompletableFutureUtils.joinLikeSync(defaultEndpointProvider().resolveEndpoint(
+ endpointParams));
+ List authSchemes = endpoint.attribute(AwsEndpointAttribute.AUTH_SCHEMES);
+ if (authSchemes != null && !authSchemes.isEmpty()) {
+ EndpointAuthScheme firstScheme = authSchemes.get(0);
+ if (firstScheme instanceof SigV4AuthScheme) {
+ String signingRegion = ((SigV4AuthScheme) firstScheme).signingRegion();
+ if (signingRegion != null) {
+ return Region.of(signingRegion);
+ }
+ }
+ }
+ } catch (Exception e) {
+ // Endpoint resolution failed. Fall back to using the client region as signing region.
+ }
+ return region;
+ });
+ builder.option(SdkClientJsonProtocolAdvancedOption.ENABLE_FAST_UNMARSHALLER, true);
+ return builder.build();
+ }
+
+ @Override
+ protected final String signingName() {
+ return "h2-service";
+ }
+
+ private H2EndpointProvider defaultEndpointProvider() {
+ return H2EndpointProvider.defaultProvider();
+ }
+
+ public B authSchemeProvider(H2AuthSchemeProvider authSchemeProvider) {
+ clientConfiguration.option(SdkClientOption.AUTH_SCHEME_PROVIDER, authSchemeProvider);
+ return thisBuilder();
+ }
+
+ private H2AuthSchemeProvider defaultAuthSchemeProvider(SdkClientConfiguration config) {
+ AuthSchemePreferenceResolver authSchemePreferenceProvider = AuthSchemePreferenceResolver.builder()
+ .profileFile(config.option(SdkClientOption.PROFILE_FILE_SUPPLIER))
+ .profileName(config.option(SdkClientOption.PROFILE_NAME)).build();
+ List preferences = authSchemePreferenceProvider.resolveAuthSchemePreference();
+ if (!preferences.isEmpty()) {
+ return H2AuthSchemeProvider.defaultProvider(preferences);
+ }
+ return H2AuthSchemeProvider.defaultProvider();
+ }
+
+ @Override
+ public B putAuthScheme(AuthScheme> authScheme) {
+ additionalAuthSchemes.put(authScheme.schemeId(), authScheme);
+ return thisBuilder();
+ }
+
+ private Map> authSchemes() {
+ Map> schemes = new HashMap<>(2 + this.additionalAuthSchemes.size());
+ AwsV4AuthScheme awsV4AuthScheme = AwsV4AuthScheme.create();
+ schemes.put(awsV4AuthScheme.schemeId(), awsV4AuthScheme);
+ NoAuthAuthScheme noAuthAuthScheme = NoAuthAuthScheme.create();
+ schemes.put(noAuthAuthScheme.schemeId(), noAuthAuthScheme);
+ schemes.putAll(this.additionalAuthSchemes);
+ return schemes;
+ }
+
+ @Override
+ protected final AttributeMap serviceHttpConfig() {
+ AttributeMap result = AttributeMap.empty();
+ return result.merge(AttributeMap.builder().put(SdkHttpConfigurationOption.PROTOCOL, Protocol.HTTP2)
+ .put(SdkHttpConfigurationOption.PROTOCOL_NEGOTIATION, ProtocolNegotiation.ALPN)
+ .put(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT, Duration.ofMillis(900000L)).build());
+ }
+
+ @Override
+ protected SdkClientConfiguration invokePlugins(SdkClientConfiguration config) {
+ List internalPlugins = internalPlugins(config);
+ List externalPlugins = plugins();
+ if (internalPlugins.isEmpty() && externalPlugins.isEmpty()) {
+ return config;
+ }
+ List plugins = CollectionUtils.mergeLists(internalPlugins, externalPlugins);
+ SdkClientConfiguration.Builder configuration = config.toBuilder();
+ H2ServiceClientConfigurationBuilder serviceConfigBuilder = new H2ServiceClientConfigurationBuilder(configuration);
+ for (SdkPlugin plugin : plugins) {
+ plugin.configureClient(serviceConfigBuilder);
+ }
+ updateRetryStrategyClientConfiguration(configuration);
+ return configuration.build();
+ }
+
+ private void updateRetryStrategyClientConfiguration(SdkClientConfiguration.Builder configuration) {
+ ClientOverrideConfiguration.Builder builder = configuration.asOverrideConfigurationBuilder();
+ RetryMode retryMode = builder.retryMode();
+ if (retryMode != null) {
+ configuration.option(SdkClientOption.RETRY_STRATEGY, AwsRetryStrategy.forRetryMode(retryMode));
+ } else {
+ Consumer> configurator = builder.retryStrategyConfigurator();
+ if (configurator != null) {
+ RetryStrategy.Builder, ?> defaultBuilder = AwsRetryStrategy.defaultRetryStrategy().toBuilder();
+ configurator.accept(defaultBuilder);
+ configuration.option(SdkClientOption.RETRY_STRATEGY, defaultBuilder.build());
+ } else {
+ RetryStrategy retryStrategy = builder.retryStrategy();
+ if (retryStrategy != null) {
+ configuration.option(SdkClientOption.RETRY_STRATEGY, retryStrategy);
+ }
+ }
+ }
+ configuration.option(SdkClientOption.CONFIGURED_RETRY_MODE, null);
+ configuration.option(SdkClientOption.CONFIGURED_RETRY_STRATEGY, null);
+ configuration.option(SdkClientOption.CONFIGURED_RETRY_CONFIGURATOR, null);
+ }
+
+ private List internalPlugins(SdkClientConfiguration config) {
+ return Collections.emptyList();
+ }
+
+ protected static void validateClientOptions(SdkClientConfiguration c) {
+ }
+}
diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java
index 92036575eeb1..2f4a77e7e9a0 100644
--- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java
+++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilder.java
@@ -21,6 +21,7 @@
import static software.amazon.awssdk.core.client.config.SdkClientOption.RETRY_STRATEGY;
import java.net.URI;
+import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@@ -50,6 +51,7 @@
import software.amazon.awssdk.core.client.config.SdkAdvancedClientOption;
import software.amazon.awssdk.core.client.config.SdkClientConfiguration;
import software.amazon.awssdk.core.client.config.SdkClientOption;
+import software.amazon.awssdk.core.http.EnableDefaultReadTimeout2026Resolver;
import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
import software.amazon.awssdk.core.internal.SdkInternalTestAdvancedClientOption;
import software.amazon.awssdk.core.internal.retry.SdkDefaultRetryStrategy;
@@ -57,6 +59,7 @@
import software.amazon.awssdk.core.retry.RetryMode;
import software.amazon.awssdk.core.retry.RetryPolicy;
import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.SdkHttpConfigurationOption;
import software.amazon.awssdk.http.async.SdkAsyncHttpClient;
import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity;
import software.amazon.awssdk.identity.spi.IdentityProvider;
@@ -99,6 +102,12 @@ public abstract class AwsDefaultClientBuilder
*/
private AttributeMap resolveHttpClientConfig(LazyValueSource config) {
- AttributeMap attributeMap = serviceHttpConfig();
+ AttributeMap attributeMap = applyDefaultReadWriteTimeout(config, serviceHttpConfig());
return mergeSmartHttpDefaults(config, attributeMap);
}
+ /**
+ * Applies the {@code AWS_ENABLE_DEFAULT_READ_TIMEOUT_2026} rollout gate to the codegen-baked
+ * {@link SdkHttpConfigurationOption#SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT} contributed by {@link #serviceHttpConfig()}.
+ * The gate resolves from the {@code AWS_ENABLE_DEFAULT_READ_TIMEOUT_2026} environment variable/system property, else the
+ * codegen-baked {@link SdkClientOption#DEFAULT_ENABLE_READ_TIMEOUT_2026} default, else off.
+ *
+ * When the gate is on, an unlisted service (nothing baked) gets the flat 5-minute default and a baked tier is kept as-is
+ * ({@link Duration#ZERO} for fully-exempt, 15 minutes for partial). When the gate is off, a baked positive tier (partial)
+ * is forced to {@link Duration#ZERO} so it cannot apply; otherwise the option is left untouched. Leaving it absent for an
+ * unlisted, gated-off service is equivalent to {@link Duration#ZERO}: the gate being off means the environment variable is
+ * not truthy, so the HTTP client's option-absent path applies nothing either way.
+ */
+ private AttributeMap applyDefaultReadWriteTimeout(LazyValueSource config, AttributeMap serviceHttpConfig) {
+ boolean gateEnabled = new EnableDefaultReadTimeout2026Resolver()
+ .defaultEnableReadTimeout2026(config.get(SdkClientOption.DEFAULT_ENABLE_READ_TIMEOUT_2026))
+ .resolve();
+
+ Duration bakedTier = serviceHttpConfig.get(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT);
+
+ if (gateEnabled) {
+ if (bakedTier != null) {
+ return serviceHttpConfig;
+ }
+ return serviceHttpConfig.toBuilder()
+ .put(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT,
+ DEFAULT_READ_WRITE_TIMEOUT)
+ .build();
+ }
+
+ if (bakedTier != null && !bakedTier.isZero()) {
+ return serviceHttpConfig.toBuilder()
+ .put(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT, Duration.ZERO)
+ .build();
+ }
+ return serviceHttpConfig;
+ }
+
/**
* Optionally overridden by child classes to define service-specific HTTP configuration defaults.
*/
diff --git a/core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilderReadWriteTimeoutTest.java b/core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilderReadWriteTimeoutTest.java
new file mode 100644
index 000000000000..9bdec6b844a0
--- /dev/null
+++ b/core/aws-core/src/test/java/software/amazon/awssdk/awscore/client/builder/AwsDefaultClientBuilderReadWriteTimeoutTest.java
@@ -0,0 +1,205 @@
+/*
+ * 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.awscore.client.builder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static software.amazon.awssdk.awscore.client.config.AwsAdvancedClientOption.ENABLE_DEFAULT_REGION_DETECTION;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import software.amazon.awssdk.auth.credentials.AnonymousCredentialsProvider;
+import software.amazon.awssdk.awscore.client.config.AwsClientOption;
+import software.amazon.awssdk.awscore.internal.defaultsmode.AutoDefaultsModeDiscovery;
+import software.amazon.awssdk.core.ClientEndpointProvider;
+import software.amazon.awssdk.core.SdkSystemSetting;
+import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;
+import software.amazon.awssdk.core.client.config.SdkClientConfiguration;
+import software.amazon.awssdk.core.client.config.SdkClientOption;
+import software.amazon.awssdk.http.SdkHttpClient;
+import software.amazon.awssdk.http.SdkHttpConfigurationOption;
+import software.amazon.awssdk.http.async.SdkAsyncHttpClient;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.utils.AttributeMap;
+
+/**
+ * Verifies the {@code AWS_ENABLE_DEFAULT_READ_TIMEOUT_2026} rollout gate applied in
+ * {@link AwsDefaultClientBuilder#resolveHttpClientConfig} to the codegen-baked
+ * {@link SdkHttpConfigurationOption#SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT}.
+ */
+@ExtendWith(MockitoExtension.class)
+class AwsDefaultClientBuilderReadWriteTimeoutTest {
+
+ private static final String GATE_PROPERTY = SdkSystemSetting.AWS_ENABLE_DEFAULT_READ_TIMEOUT_2026.property();
+ private static final Duration PARTIAL_TIER = Duration.ofMinutes(15);
+ private static final Duration FLAT_DEFAULT = Duration.ofMinutes(5);
+
+ @Mock(lenient = true)
+ private SdkHttpClient.Builder defaultHttpClientBuilder;
+
+ @Mock(lenient = true)
+ private SdkAsyncHttpClient.Builder defaultAsyncHttpClientFactory;
+
+ @Mock(lenient = true)
+ private AutoDefaultsModeDiscovery autoModeDiscovery;
+
+ private String savedProperty;
+
+ @BeforeEach
+ void setup() {
+ savedProperty = System.getProperty(GATE_PROPERTY);
+ System.clearProperty(GATE_PROPERTY);
+ }
+
+ @AfterEach
+ void teardown() {
+ if (savedProperty != null) {
+ System.setProperty(GATE_PROPERTY, savedProperty);
+ } else {
+ System.clearProperty(GATE_PROPERTY);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("gateScenarios")
+ void resolvesReadWriteTimeout(String scenario, String gateProperty, boolean codegenGateDefault,
+ Duration bakedTier, Duration expected) {
+ if (gateProperty != null) {
+ System.setProperty(GATE_PROPERTY, gateProperty);
+ }
+ AttributeMap serviceHttpConfig = bakedTier == null ? AttributeMap.empty() : bakedServiceHttpConfig(bakedTier);
+
+ AttributeMap resolved = resolvedServiceDefaults(serviceHttpConfig, codegenGateDefault);
+
+ assertThat(resolved.get(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT)).isEqualTo(expected);
+ }
+
+ @Test
+ void unlistedService_gateOff_leavesOptionAbsent() {
+ AttributeMap resolved = resolvedServiceDefaults(AttributeMap.empty(), false);
+
+ assertThat(resolved.containsKey(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT)).isFalse();
+ }
+
+ private static Stream gateScenarios() {
+ return Stream.of(
+ Arguments.of("gate off, partial tier baked -> overridden to ZERO", null, false, PARTIAL_TIER, Duration.ZERO),
+ Arguments.of("gate on, unlisted service -> flat 5-minute default", "true", false, null, FLAT_DEFAULT),
+ Arguments.of("gate on, fully-exempt tier baked -> ZERO", "true", false, Duration.ZERO, Duration.ZERO),
+ Arguments.of("gate on, partial tier baked -> 15 minutes", "true", false, PARTIAL_TIER, PARTIAL_TIER),
+ Arguments.of("gate on via codegen default, unlisted -> flat 5-minute default", null, true, null, FLAT_DEFAULT),
+ Arguments.of("gate property false overrides codegen default -> ZERO", "false", true, PARTIAL_TIER, Duration.ZERO)
+ );
+ }
+
+ private static AttributeMap bakedServiceHttpConfig(Duration tier) {
+ return AttributeMap.builder()
+ .put(SdkHttpConfigurationOption.SDK_INTERNAL_FALLBACK_READ_WRITE_TIMEOUT, tier)
+ .build();
+ }
+
+ private AttributeMap resolvedServiceDefaults(AttributeMap bakedServiceHttpConfig, boolean codegenGateDefault) {
+ AtomicReference captured = new AtomicReference<>();
+ ClientOverrideConfiguration overrideConfig =
+ ClientOverrideConfiguration.builder()
+ .putAdvancedOption(ENABLE_DEFAULT_REGION_DETECTION, false)
+ .build();
+
+ new TestClientBuilder(bakedServiceHttpConfig, codegenGateDefault)
+ .credentialsProvider(AnonymousCredentialsProvider.create())
+ .overrideConfiguration(overrideConfig)
+ .region(Region.US_WEST_1)
+ .httpClientBuilder((SdkHttpClient.Builder) serviceDefaults -> {
+ captured.set(serviceDefaults);
+ return mock(SdkHttpClient.class);
+ })
+ .build();
+
+ return captured.get();
+ }
+
+ private static class TestClient {
+ }
+
+ private class TestClientBuilder extends AwsDefaultClientBuilder
+ implements AwsClientBuilder {
+
+ private final AttributeMap bakedServiceHttpConfig;
+ private final boolean codegenGateDefault;
+
+ TestClientBuilder(AttributeMap bakedServiceHttpConfig, boolean codegenGateDefault) {
+ super(defaultHttpClientBuilder, defaultAsyncHttpClientFactory, autoModeDiscovery);
+ this.bakedServiceHttpConfig = bakedServiceHttpConfig;
+ this.codegenGateDefault = codegenGateDefault;
+ }
+
+ @Override
+ protected TestClient buildClient() {
+ syncClientConfiguration();
+ return new TestClient();
+ }
+
+ @Override
+ protected SdkClientConfiguration mergeInternalDefaults(SdkClientConfiguration config) {
+ if (!codegenGateDefault) {
+ return config;
+ }
+ return config.merge(c -> c.option(SdkClientOption.DEFAULT_ENABLE_READ_TIMEOUT_2026, true));
+ }
+
+ @Override
+ protected SdkClientConfiguration finalizeServiceConfiguration(SdkClientConfiguration config) {
+ return config.toBuilder()
+ .lazyOptionIfAbsent(SdkClientOption.CLIENT_ENDPOINT_PROVIDER, c -> {
+ URI endpoint = URI.create("https://" + serviceEndpointPrefix() + "."
+ + c.get(AwsClientOption.AWS_REGION) + ".amazonaws.com");
+ return ClientEndpointProvider.create(endpoint, false);
+ })
+ .build();
+ }
+
+ @Override
+ protected AttributeMap serviceHttpConfig() {
+ return bakedServiceHttpConfig;
+ }
+
+ @Override
+ protected String serviceEndpointPrefix() {
+ return "test";
+ }
+
+ @Override
+ protected String signingName() {
+ return "test";
+ }
+
+ @Override
+ protected String serviceName() {
+ return "test";
+ }
+ }
+}