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"; + } + } +}