From e8b1e1679ca162fa6a6d107fdf40e66ed705bea0 Mon Sep 17 00:00:00 2001 From: John Viegas Date: Thu, 4 Dec 2025 16:53:33 -0800 Subject: [PATCH 1/5] Skip modifying User-Agent in ApplyUserAgentStage if the user has already passed custom User-Agent in the request --- .../bugfix-AWSSDKforJavav2-f9f830e.json | 6 + .../pipeline/stages/ApplyUserAgentStage.java | 14 ++ .../stages/ApplyUserAgentStageTest.java | 80 ++++++++- .../useragent/CustomUserAgentHeaderTest.java | 164 ++++++++++++++++++ 4 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 .changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json create mode 100644 test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java diff --git a/.changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json b/.changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json new file mode 100644 index 000000000000..51543ef5bc4f --- /dev/null +++ b/.changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "Skip User-Agent header modification in ApplyUserAgentStage when custom User-Agent is already provided." +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java index 583634c7288c..bad9129788df 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java @@ -40,6 +40,7 @@ import software.amazon.awssdk.core.useragent.BusinessMetricCollection; import software.amazon.awssdk.http.SdkHttpFullRequest; import software.amazon.awssdk.identity.spi.Identity; +import software.amazon.awssdk.utils.CollectionUtils; import software.amazon.awssdk.utils.CompletableFutureUtils; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.Pair; @@ -66,10 +67,23 @@ public ApplyUserAgentStage(HttpClientDependencies dependencies) { @Override public SdkHttpFullRequest.Builder execute(SdkHttpFullRequest.Builder request, RequestExecutionContext context) throws Exception { + + if (hasNonNullUserAgentHeader(request)) { + return request; + } String headerValue = finalizeUserAgent(context); return request.putHeader(HEADER_USER_AGENT, headerValue); } + /** + * Checks if User-Agent header exists with a non-null value (including empty string). + * This is done to maintain backward compatibility since MergeCustomHeadersStage merges non-null headers only. + */ + private boolean hasNonNullUserAgentHeader(SdkHttpFullRequest.Builder request) { + List userAgentValues = request.matchingHeaders(HEADER_USER_AGENT); + return CollectionUtils.firstIfPresent(userAgentValues) != null; + } + /** * The final value sent in the user agent header consists of *
    diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java index 4db0103b7e3c..d2758296f447 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java @@ -152,6 +152,85 @@ public void when_identityContainsProvider_authSourceIsPresent() throws Exception assertThat(userAgentHeaders.get(0)).contains("m/w"); } + @Test + public void when_userAgentHeaderAlreadyPresent_doesNotOverwrite() throws Exception { + ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); + + String existingUserAgent = "CustomUserAgent/1.0"; + SdkHttpFullRequest.Builder requestWithExistingHeader = SdkHttpFullRequest.builder() + .putHeader(HEADER_USER_AGENT, existingUserAgent); + + RequestExecutionContext ctx = requestExecutionContext(executionAttributes(IDENTITY_WITHOUT_SOURCE), noOpRequest()); + SdkHttpFullRequest.Builder result = stage.execute(requestWithExistingHeader, ctx); + + List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + assertThat(userAgentHeaders.get(0)).isEqualTo(existingUserAgent); + // Verify it does NOT contain SDK user agent values + assertThat(userAgentHeaders.get(0)).doesNotContain("aws-sdk-java"); + } + + @Test + public void when_userAgentHeaderPresentButEmpty_EmptyHeaderIsPreserved() throws Exception { + ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); + + SdkHttpFullRequest.Builder requestWithEmptyHeader = SdkHttpFullRequest.builder() + .putHeader(HEADER_USER_AGENT, ""); + + RequestExecutionContext ctx = requestExecutionContext(executionAttributes(IDENTITY_WITHOUT_SOURCE), noOpRequest()); + SdkHttpFullRequest.Builder result = stage.execute(requestWithEmptyHeader, ctx); + + List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + assertThat(userAgentHeaders.get(0)).doesNotContain("aws-sdk-java"); + } + + @Test + public void when_userAgentHeaderPresentButNull_sdkAddsHeader() throws Exception { + ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); + String headerValue = null; + SdkHttpFullRequest.Builder requestWithEmptyHeader = SdkHttpFullRequest.builder() + .putHeader(HEADER_USER_AGENT, headerValue); + + RequestExecutionContext ctx = requestExecutionContext(executionAttributes(IDENTITY_WITHOUT_SOURCE), noOpRequest()); + SdkHttpFullRequest.Builder result = stage.execute(requestWithEmptyHeader, ctx); + + List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + assertThat(userAgentHeaders.get(0)).startsWith("aws-sdk-java"); + } + + @Test + public void when_userAgentHeaderAbsent_addsHeader() throws Exception { + ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); + + SdkHttpFullRequest.Builder requestWithoutHeader = SdkHttpFullRequest.builder(); + + RequestExecutionContext ctx = requestExecutionContext(executionAttributes(IDENTITY_WITHOUT_SOURCE), noOpRequest()); + SdkHttpFullRequest.Builder result = stage.execute(requestWithoutHeader, ctx); + + List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); + assertThat(userAgentHeaders).isNotNull().hasSize(1); + assertThat(userAgentHeaders.get(0)).startsWith("aws-sdk-java"); + } + + @Test + public void when_multipleUserAgentHeadersPresent_doesNotOverwrite() throws Exception { + ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); + + SdkHttpFullRequest.Builder requestWithMultipleHeaders = + SdkHttpFullRequest.builder() + .putHeader(HEADER_USER_AGENT, "CustomAgent/1.0") + .appendHeader(HEADER_USER_AGENT, "AnotherAgent/2.0"); + + RequestExecutionContext ctx = requestExecutionContext(executionAttributes(IDENTITY_WITHOUT_SOURCE), noOpRequest()); + SdkHttpFullRequest.Builder result = stage.execute(requestWithMultipleHeaders, ctx); + + List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); + assertThat(userAgentHeaders).hasSize(2); + assertThat(userAgentHeaders).containsExactly("CustomAgent/1.0", "AnotherAgent/2.0"); + } + private static HttpClientDependencies dependencies(String clientUserAgent) { return dependencies(clientUserAgent, null, null); } @@ -219,6 +298,5 @@ private RequestExecutionContext requestExecutionContext(ExecutionAttributes exec return RequestExecutionContext.builder() .executionContext(executionContext) .originalRequest(request).build(); - } } diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java new file mode 100644 index 000000000000..181d21b0fdab --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java @@ -0,0 +1,164 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.services.useragent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +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 software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.interceptor.Context; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.restjsonendpointproviders.RestJsonEndpointProvidersClient; +import software.amazon.awssdk.services.restjsonendpointproviders.RestJsonEndpointProvidersClientBuilder; + +/** + * Functional tests to verify that custom User-Agent headers provided via + * {@link software.amazon.awssdk.core.client.config.ClientOverrideConfiguration.Builder#putHeader(String, String)} + * are preserved and not overwritten by the SDK's default User-Agent generation logic. + */ +class CustomUserAgentHeaderTest { + + private static final String USER_AGENT_HEADER = "User-Agent"; + private static final String SDK_USER_AGENT_PREFIX = "aws-sdk-java"; + private static final String TEST_API_NAME = "TestApiName"; + private static final String TEST_API_VERSION = "1.0"; + + private CapturingInterceptor interceptor; + + @BeforeEach + void setUp() { + interceptor = new CapturingInterceptor(); + } + + @Test + void execute_withoutCustomUserAgent_shouldAddSdkDefaultUserAgent() { + + RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); + executeRequestExpectingInterception(client); + String userAgent = getCapturedUserAgent(); + assertThat(userAgent).contains(SDK_USER_AGENT_PREFIX); + } + + @Test + void execute_withEmptyCustomUserAgent_shouldPreserveEmptyValue() { + + RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(""); + executeRequestExpectingInterception(client); + String userAgent = getCapturedUserAgent(); + assertThat(userAgent).isEmpty(); + } + + @ParameterizedTest(name = "{index}: userAgent={0}") + @MethodSource("customUserAgentValues") + void execute_withCustomUserAgent_shouldPreserveAndNotOverwrite(String customUserAgent) { + + RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(customUserAgent); + executeRequestExpectingInterception(client); + + String userAgent = getCapturedUserAgent(); + assertThat(userAgent) + .isEqualTo(customUserAgent) + .doesNotContain(SDK_USER_AGENT_PREFIX); + } + + private static Stream customUserAgentValues() { + return Stream.of( + Arguments.of("CustomUserAgentHeaderValue"), + Arguments.of("MyApplication/1.0.0"), + Arguments.of("CustomClient/2.0 (Linux; x86_64)") + ); + } + + @Test + void execute_withCustomUserAgentAndApiName_shouldNotAppendApiName() { + + String customUserAgent = "CustomUserAgentHeaderValue"; + RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(customUserAgent); + executeRequestWithApiName(client); + String userAgent = getCapturedUserAgent(); + assertThat(userAgent) + .isEqualTo(customUserAgent) + .doesNotContain(TEST_API_NAME); + } + + @Test + void execute_withoutCustomUserAgentAndWithApiName_shouldAppendApiName() { + + RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); + executeRequestWithApiName(client); + String userAgent = getCapturedUserAgent(); + assertThat(userAgent).contains(TEST_API_NAME + "/" + TEST_API_VERSION); + } + + private RestJsonEndpointProvidersClientBuilder defaultClientBuilder() { + return RestJsonEndpointProvidersClient.builder() + .region(Region.US_WEST_2) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("akid", "skid"))) + .overrideConfiguration(c -> c.addExecutionInterceptor(interceptor)); + } + + private RestJsonEndpointProvidersClient clientWithCustomUserAgent(String customUserAgent) { + return RestJsonEndpointProvidersClient.builder() + .region(Region.US_WEST_2) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("akid", "skid"))) + .overrideConfiguration(c -> c + .addExecutionInterceptor(interceptor) + .putHeader(USER_AGENT_HEADER, customUserAgent)) + .build(); + } + + private void executeRequestExpectingInterception(RestJsonEndpointProvidersClient client) { + assertThatThrownBy(() -> client.allTypes(r -> {})) + .hasMessageContaining("stop"); + } + + private void executeRequestWithApiName(RestJsonEndpointProvidersClient client) { + assertThatThrownBy(() -> client.allTypes(r -> r + .overrideConfiguration(o -> o.addApiName(api -> api + .name(TEST_API_NAME) + .version(TEST_API_VERSION))))) + .hasMessageContaining("stop"); + } + + private String getCapturedUserAgent() { + Map> headers = interceptor.context.httpRequest().headers(); + assertThat(headers).containsKey(USER_AGENT_HEADER); + return headers.get(USER_AGENT_HEADER).get(0); + } + + private static class CapturingInterceptor implements ExecutionInterceptor { + private Context.BeforeTransmission context; + + @Override + public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { + this.context = context; + throw new RuntimeException("stop"); + } + } +} From 80c666472130d3c902162dc7500415ce473d1c6a Mon Sep 17 00:00:00 2001 From: John Viegas Date: Fri, 5 Dec 2025 08:36:52 -0800 Subject: [PATCH 2/5] Added changes to handle EmptyString and Empty list to add SDK User Agent --- .../pipeline/stages/ApplyUserAgentStage.java | 2 +- .../stages/ApplyUserAgentStageTest.java | 2 +- .../useragent/CustomUserAgentHeaderTest.java | 192 +++++++++++++----- 3 files changed, 146 insertions(+), 50 deletions(-) diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java index bad9129788df..3909301732b5 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java @@ -68,7 +68,7 @@ public ApplyUserAgentStage(HttpClientDependencies dependencies) { public SdkHttpFullRequest.Builder execute(SdkHttpFullRequest.Builder request, RequestExecutionContext context) throws Exception { - if (hasNonNullUserAgentHeader(request)) { + if (request.firstMatchingHeader(HEADER_USER_AGENT).isPresent()) { return request; } String headerValue = finalizeUserAgent(context); diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java index d2758296f447..4a957982453d 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java @@ -182,7 +182,7 @@ public void when_userAgentHeaderPresentButEmpty_EmptyHeaderIsPreserved() throws List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); assertThat(userAgentHeaders).isNotNull().hasSize(1); - assertThat(userAgentHeaders.get(0)).doesNotContain("aws-sdk-java"); + assertThat(userAgentHeaders.get(0)).contains("aws-sdk-java"); } @Test diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java index 181d21b0fdab..0592cdeffd99 100644 --- a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java @@ -18,6 +18,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.stream.Stream; @@ -34,11 +36,14 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.restjsonendpointproviders.RestJsonEndpointProvidersClient; import software.amazon.awssdk.services.restjsonendpointproviders.RestJsonEndpointProvidersClientBuilder; +import software.amazon.awssdk.utils.StringUtils; /** - * Functional tests to verify that custom User-Agent headers provided via - * {@link software.amazon.awssdk.core.client.config.ClientOverrideConfiguration.Builder#putHeader(String, String)} - * are preserved and not overwritten by the SDK's default User-Agent generation logic. + * Functional tests verifying custom User-Agent header preservation. + * + *

    Tests ensure that User-Agent headers provided via + * {@link software.amazon.awssdk.core.client.config.ClientOverrideConfiguration.Builder#putHeader(String, String)} are preserved + * and not overwritten by SDK's default User-Agent generation logic. */ class CustomUserAgentHeaderTest { @@ -46,36 +51,48 @@ class CustomUserAgentHeaderTest { private static final String SDK_USER_AGENT_PREFIX = "aws-sdk-java"; private static final String TEST_API_NAME = "TestApiName"; private static final String TEST_API_VERSION = "1.0"; + private static final String INTERCEPTOR_STOP_MESSAGE = "stop"; private CapturingInterceptor interceptor; + private static Stream customUserAgentValues() { + return Stream.of( + Arguments.of("CustomUserAgentHeaderValue"), + Arguments.of("MyApplication/1.0.0"), + Arguments.of("CustomClient/2.0 (Linux; x86_64)") + ); + } + + // ========== Default Behavior Tests ========== + + private static Stream customUserAgentListValues() { + return Stream.of( + Arguments.of(Arrays.asList("Agent1")), + Arguments.of(Arrays.asList("Agent1", "Agent2")), + Arguments.of(Arrays.asList("CustomClient/1.0", "MyApp/2.0")) + ); + } + + // ========== Custom User-Agent Preservation Tests ========== + @BeforeEach void setUp() { interceptor = new CapturingInterceptor(); } @Test - void execute_withoutCustomUserAgent_shouldAddSdkDefaultUserAgent() { - + void executeRequest_withoutCustomUserAgent_shouldAddSdkDefaultUserAgent() { RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); executeRequestExpectingInterception(client); - String userAgent = getCapturedUserAgent(); - assertThat(userAgent).contains(SDK_USER_AGENT_PREFIX); - } - - @Test - void execute_withEmptyCustomUserAgent_shouldPreserveEmptyValue() { - RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(""); - executeRequestExpectingInterception(client); - String userAgent = getCapturedUserAgent(); - assertThat(userAgent).isEmpty(); + assertUserAgentContains(SDK_USER_AGENT_PREFIX); } - @ParameterizedTest(name = "{index}: userAgent={0}") - @MethodSource("customUserAgentValues") - void execute_withCustomUserAgent_shouldPreserveAndNotOverwrite(String customUserAgent) { + // ========== API Name Handling Tests ========== + @ParameterizedTest(name = "Custom User-Agent ''{0}'' should be preserved without SDK prefix") + @MethodSource("customUserAgentValues") + void executeRequest_withCustomUserAgent_shouldPreserveAndNotOverwrite(String customUserAgent) { RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(customUserAgent); executeRequestExpectingInterception(client); @@ -85,20 +102,24 @@ void execute_withCustomUserAgent_shouldPreserveAndNotOverwrite(String customUser .doesNotContain(SDK_USER_AGENT_PREFIX); } - private static Stream customUserAgentValues() { - return Stream.of( - Arguments.of("CustomUserAgentHeaderValue"), - Arguments.of("MyApplication/1.0.0"), - Arguments.of("CustomClient/2.0 (Linux; x86_64)") - ); + @ParameterizedTest(name = "Custom User-Agent list {0} should be preserved") + @MethodSource("customUserAgentListValues") + void executeRequest_withCustomUserAgentList_shouldPreserveAllValues(List customUserAgentList) { + RestJsonEndpointProvidersClient client = clientWithCustomUserAgentList(customUserAgentList); + executeRequestExpectingInterception(client); + + List userAgentList = getCapturedUserAgentList(); + assertThat(userAgentList).isEqualTo(customUserAgentList); } - @Test - void execute_withCustomUserAgentAndApiName_shouldNotAppendApiName() { + // ========== Edge Case Tests ========== + @Test + void executeRequest_withCustomUserAgentAndApiName_shouldNotAppendApiName() { String customUserAgent = "CustomUserAgentHeaderValue"; RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(customUserAgent); executeRequestWithApiName(client); + String userAgent = getCapturedUserAgent(); assertThat(userAgent) .isEqualTo(customUserAgent) @@ -106,12 +127,95 @@ void execute_withCustomUserAgentAndApiName_shouldNotAppendApiName() { } @Test - void execute_withoutCustomUserAgentAndWithApiName_shouldAppendApiName() { - + void executeRequest_withoutCustomUserAgentAndWithApiName_shouldAppendApiName() { RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); executeRequestWithApiName(client); - String userAgent = getCapturedUserAgent(); - assertThat(userAgent).contains(TEST_API_NAME + "/" + TEST_API_VERSION); + + assertUserAgentContains(TEST_API_NAME + "/" + TEST_API_VERSION); + } + + /** + * Verifies that null User-Agent list throws NullPointerException. + * + *

    This ensures the SDK fails fast with clear error rather than allowing + * invalid configuration. + */ + @Test + void buildClient_withNullListUserAgent_shouldThrowNullPointerException() { + assertThatThrownBy(() -> clientWithCustomUserAgentList(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("values must not be null"); + } + + /** + * Verifies that empty User-Agent list results in SDK default User-Agent. + * + *

    Behavioral Change: Previously as in when UserAgentApplyStage was done before MergeCustomHeaderStage, explicitly + * setting User-Agent Header to empty String or empty list would delete the SDK User-Agent. Current behavior ensures SDK + * User-Agent is always present when User-Agent Header is emptyString/EmptyList/Null. + */ + @Test + void executeRequest_withEmptyListUserAgent_shouldResultInSdkUserAgentHeader() { + RestJsonEndpointProvidersClient client = clientWithCustomUserAgentList(Collections.emptyList()); + executeRequestExpectingInterception(client); + + List userAgentList = getCapturedUserAgentList(); + assertThat(userAgentList) + .hasSize(1) + .anyMatch(ua -> ua.startsWith(SDK_USER_AGENT_PREFIX)); + } + + @Test + void executeRequest_withEmptyCustomUserAgent_shouldStoreSdkUserAgent() { + RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(""); + executeRequestExpectingInterception(client); + + assertUserAgentContains(SDK_USER_AGENT_PREFIX); + } + + @Test + void executeRequest_withNullStringUserAgent_shouldStoreAsSdkUserAgent() { + RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(null); + executeRequestExpectingInterception(client); + + List userAgentList = getCapturedUserAgentList(); + assertThat(userAgentList).hasSize(1); + assertThat(userAgentList) + .hasSize(1) + .allSatisfy(ua -> { + assertThat(ua).isNotNull(); + assertThat(ua).startsWith(SDK_USER_AGENT_PREFIX); + }); + + } + + private void assertUserAgentContains(String expected) { + assertThat(getCapturedUserAgent()).contains(expected); + } + + private void executeRequestExpectingInterception(RestJsonEndpointProvidersClient client) { + assertThatThrownBy(() -> client.allTypes(r -> { + })) + .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); + } + + private void executeRequestWithApiName(RestJsonEndpointProvidersClient client) { + assertThatThrownBy(() -> client.allTypes(r -> r + .overrideConfiguration(o -> o.addApiName(api -> api + .name(TEST_API_NAME) + .version(TEST_API_VERSION))))) + .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); + } + + private String getCapturedUserAgent() { + Map> headers = interceptor.context.httpRequest().headers(); + assertThat(headers).containsKey(USER_AGENT_HEADER); + return headers.get(USER_AGENT_HEADER).get(0); + } + + private List getCapturedUserAgentList() { + Map> headers = interceptor.context.httpRequest().headers(); + return headers.get(USER_AGENT_HEADER); } private RestJsonEndpointProvidersClientBuilder defaultClientBuilder() { @@ -133,23 +237,15 @@ private RestJsonEndpointProvidersClient clientWithCustomUserAgent(String customU .build(); } - private void executeRequestExpectingInterception(RestJsonEndpointProvidersClient client) { - assertThatThrownBy(() -> client.allTypes(r -> {})) - .hasMessageContaining("stop"); - } - - private void executeRequestWithApiName(RestJsonEndpointProvidersClient client) { - assertThatThrownBy(() -> client.allTypes(r -> r - .overrideConfiguration(o -> o.addApiName(api -> api - .name(TEST_API_NAME) - .version(TEST_API_VERSION))))) - .hasMessageContaining("stop"); - } - - private String getCapturedUserAgent() { - Map> headers = interceptor.context.httpRequest().headers(); - assertThat(headers).containsKey(USER_AGENT_HEADER); - return headers.get(USER_AGENT_HEADER).get(0); + private RestJsonEndpointProvidersClient clientWithCustomUserAgentList(List customUserAgentList) { + return RestJsonEndpointProvidersClient.builder() + .region(Region.US_WEST_2) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("akid", "skid"))) + .overrideConfiguration(c -> c + .addExecutionInterceptor(interceptor) + .putHeader(USER_AGENT_HEADER, customUserAgentList)) + .build(); } private static class CapturingInterceptor implements ExecutionInterceptor { @@ -158,7 +254,7 @@ private static class CapturingInterceptor implements ExecutionInterceptor { @Override public void beforeTransmission(Context.BeforeTransmission context, ExecutionAttributes executionAttributes) { this.context = context; - throw new RuntimeException("stop"); + throw new RuntimeException(INTERCEPTOR_STOP_MESSAGE); } } } From 229d4a9e544cd1d8f5bf456d9a0a1913361768f3 Mon Sep 17 00:00:00 2001 From: John Viegas Date: Fri, 5 Dec 2025 09:59:28 -0800 Subject: [PATCH 3/5] Skip User-Agent Stage only if Additional headers configured on client option --- .../pipeline/stages/ApplyUserAgentStage.java | 17 ++++--- .../stages/ApplyUserAgentStageTest.java | 49 ++++++++++++------- .../useragent/CustomUserAgentHeaderTest.java | 42 ++++++++++------ 3 files changed, 66 insertions(+), 42 deletions(-) diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java index 3909301732b5..681a777752c8 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java @@ -25,6 +25,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.core.ApiName; @@ -40,7 +41,6 @@ import software.amazon.awssdk.core.useragent.BusinessMetricCollection; import software.amazon.awssdk.http.SdkHttpFullRequest; import software.amazon.awssdk.identity.spi.Identity; -import software.amazon.awssdk.utils.CollectionUtils; import software.amazon.awssdk.utils.CompletableFutureUtils; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.Pair; @@ -68,7 +68,7 @@ public ApplyUserAgentStage(HttpClientDependencies dependencies) { public SdkHttpFullRequest.Builder execute(SdkHttpFullRequest.Builder request, RequestExecutionContext context) throws Exception { - if (request.firstMatchingHeader(HEADER_USER_AGENT).isPresent()) { + if (hasUserAgentInAdditionalHeaders()) { return request; } String headerValue = finalizeUserAgent(context); @@ -76,12 +76,15 @@ public SdkHttpFullRequest.Builder execute(SdkHttpFullRequest.Builder request, } /** - * Checks if User-Agent header exists with a non-null value (including empty string). - * This is done to maintain backward compatibility since MergeCustomHeadersStage merges non-null headers only. + * Checks if User-Agent header is present in ADDITIONAL_HTTP_HEADERS configuration. + * We skip adding user-agent in the ApplyUserAgentStage if user has set "User-Agent" header in additional header of client */ - private boolean hasNonNullUserAgentHeader(SdkHttpFullRequest.Builder request) { - List userAgentValues = request.matchingHeaders(HEADER_USER_AGENT); - return CollectionUtils.firstIfPresent(userAgentValues) != null; + private boolean hasUserAgentInAdditionalHeaders() { + Map> additionalHeaders = clientConfig.option(SdkClientOption.ADDITIONAL_HTTP_HEADERS); + if (additionalHeaders == null) { + return false; + } + return additionalHeaders.containsKey(HEADER_USER_AGENT); } /** diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java index 4a957982453d..cb7b1328322d 100644 --- a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStageTest.java @@ -25,7 +25,9 @@ import static software.amazon.awssdk.core.internal.useragent.UserAgentConstant.SPACE; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import org.junit.Test; import org.junit.runner.RunWith; @@ -153,7 +155,7 @@ public void when_identityContainsProvider_authSourceIsPresent() throws Exception } @Test - public void when_userAgentHeaderAlreadyPresent_doesNotOverwrite() throws Exception { + public void when_userAgentHeaderAlreadyPresent_AndSdkOptionAdditionalHeaderNotPresent_doesNotOverwrite() throws Exception { ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); String existingUserAgent = "CustomUserAgent/1.0"; @@ -165,13 +167,11 @@ public void when_userAgentHeaderAlreadyPresent_doesNotOverwrite() throws Excepti List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); assertThat(userAgentHeaders).isNotNull().hasSize(1); - assertThat(userAgentHeaders.get(0)).isEqualTo(existingUserAgent); - // Verify it does NOT contain SDK user agent values - assertThat(userAgentHeaders.get(0)).doesNotContain("aws-sdk-java"); + assertThat(userAgentHeaders.get(0)).startsWith("aws-sdk-java"); } @Test - public void when_userAgentHeaderPresentButEmpty_EmptyHeaderIsPreserved() throws Exception { + public void when_userAgentHeaderPresentButEmpty_sdkAddsUserAgent() throws Exception { ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); SdkHttpFullRequest.Builder requestWithEmptyHeader = SdkHttpFullRequest.builder() @@ -182,18 +182,18 @@ public void when_userAgentHeaderPresentButEmpty_EmptyHeaderIsPreserved() throws List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); assertThat(userAgentHeaders).isNotNull().hasSize(1); - assertThat(userAgentHeaders.get(0)).contains("aws-sdk-java"); + assertThat(userAgentHeaders.get(0)).startsWith("aws-sdk-java"); } @Test public void when_userAgentHeaderPresentButNull_sdkAddsHeader() throws Exception { ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); String headerValue = null; - SdkHttpFullRequest.Builder requestWithEmptyHeader = SdkHttpFullRequest.builder() - .putHeader(HEADER_USER_AGENT, headerValue); + SdkHttpFullRequest.Builder requestWithNullHeader = SdkHttpFullRequest.builder() + .putHeader(HEADER_USER_AGENT, headerValue); RequestExecutionContext ctx = requestExecutionContext(executionAttributes(IDENTITY_WITHOUT_SOURCE), noOpRequest()); - SdkHttpFullRequest.Builder result = stage.execute(requestWithEmptyHeader, ctx); + SdkHttpFullRequest.Builder result = stage.execute(requestWithNullHeader, ctx); List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); assertThat(userAgentHeaders).isNotNull().hasSize(1); @@ -201,7 +201,7 @@ public void when_userAgentHeaderPresentButNull_sdkAddsHeader() throws Exception } @Test - public void when_userAgentHeaderAbsent_addsHeader() throws Exception { + public void when_userAgentHeaderAbsent_sdkAddsHeader() throws Exception { ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); SdkHttpFullRequest.Builder requestWithoutHeader = SdkHttpFullRequest.builder(); @@ -215,22 +215,33 @@ public void when_userAgentHeaderAbsent_addsHeader() throws Exception { } @Test - public void when_multipleUserAgentHeadersPresent_doesNotOverwrite() throws Exception { - ApplyUserAgentStage stage = new ApplyUserAgentStage(dependencies(clientUserAgent())); + public void when_userAgentInAdditionalHeaders_doesNotOverwriteUserAgent() throws Exception { + Map> headerMap = new LinkedHashMap<>(); + headerMap.put(HEADER_USER_AGENT, Arrays.asList("CustomAgent/1.0", "AnotherAgent/2.0")); - SdkHttpFullRequest.Builder requestWithMultipleHeaders = - SdkHttpFullRequest.builder() - .putHeader(HEADER_USER_AGENT, "CustomAgent/1.0") - .appendHeader(HEADER_USER_AGENT, "AnotherAgent/2.0"); + SdkClientConfiguration clientConfiguration = + SdkClientConfiguration.builder() + .option(SdkClientOption.CLIENT_USER_AGENT, clientUserAgent()) + .option(SdkClientOption.ADDITIONAL_HTTP_HEADERS, headerMap) + .build(); + HttpClientDependencies httpClientDependencies = HttpClientDependencies.builder() + .clientConfiguration(clientConfiguration) + .build(); + + ApplyUserAgentStage stage = new ApplyUserAgentStage(httpClientDependencies); + + SdkHttpFullRequest.Builder request = SdkHttpFullRequest.builder(); RequestExecutionContext ctx = requestExecutionContext(executionAttributes(IDENTITY_WITHOUT_SOURCE), noOpRequest()); - SdkHttpFullRequest.Builder result = stage.execute(requestWithMultipleHeaders, ctx); + SdkHttpFullRequest.Builder result = stage.execute(request, ctx); + // ApplyUserAgentStage should skip adding User-Agent since it's in ADDITIONAL_HTTP_HEADERS + // The actual merging happens in MergeCustomHeadersStage List userAgentHeaders = result.headers().get(HEADER_USER_AGENT); - assertThat(userAgentHeaders).hasSize(2); - assertThat(userAgentHeaders).containsExactly("CustomAgent/1.0", "AnotherAgent/2.0"); + assertThat(userAgentHeaders).isNull(); } + private static HttpClientDependencies dependencies(String clientUserAgent) { return dependencies(clientUserAgent, null, null); } diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java index 0592cdeffd99..297a7d98fc8e 100644 --- a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java @@ -33,10 +33,10 @@ import software.amazon.awssdk.core.interceptor.Context; import software.amazon.awssdk.core.interceptor.ExecutionAttributes; import software.amazon.awssdk.core.interceptor.ExecutionInterceptor; +import software.amazon.awssdk.http.SdkHttpRequest; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.restjsonendpointproviders.RestJsonEndpointProvidersClient; import software.amazon.awssdk.services.restjsonendpointproviders.RestJsonEndpointProvidersClientBuilder; -import software.amazon.awssdk.utils.StringUtils; /** * Functional tests verifying custom User-Agent header preservation. @@ -112,6 +112,27 @@ void executeRequest_withCustomUserAgentList_shouldPreserveAllValues(List assertThat(userAgentList).isEqualTo(customUserAgentList); } + // ========== Header via Interceptors ========== + + @Test + void executeRequest_withInterceptorAddingUserAgent_shouldAddSdkDefaultUserAgent() { + RestJsonEndpointProvidersClient client = + defaultClientBuilder().overrideConfiguration(o -> o + .addExecutionInterceptor(interceptor) + .addExecutionInterceptor(new ExecutionInterceptor() { + @Override + public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context, + ExecutionAttributes executionAttributes) { + return context.httpRequest().toBuilder() + .putHeader(USER_AGENT_HEADER, "custom-agent") + .build(); + } + })).build(); + + executeRequestExpectingInterception(client); + assertUserAgentContains(SDK_USER_AGENT_PREFIX); + } + // ========== Edge Case Tests ========== @Test @@ -147,22 +168,13 @@ void buildClient_withNullListUserAgent_shouldThrowNullPointerException() { .hasMessageContaining("values must not be null"); } - /** - * Verifies that empty User-Agent list results in SDK default User-Agent. - * - *

    Behavioral Change: Previously as in when UserAgentApplyStage was done before MergeCustomHeaderStage, explicitly - * setting User-Agent Header to empty String or empty list would delete the SDK User-Agent. Current behavior ensures SDK - * User-Agent is always present when User-Agent Header is emptyString/EmptyList/Null. - */ @Test void executeRequest_withEmptyListUserAgent_shouldResultInSdkUserAgentHeader() { RestJsonEndpointProvidersClient client = clientWithCustomUserAgentList(Collections.emptyList()); executeRequestExpectingInterception(client); List userAgentList = getCapturedUserAgentList(); - assertThat(userAgentList) - .hasSize(1) - .anyMatch(ua -> ua.startsWith(SDK_USER_AGENT_PREFIX)); + assertThat(userAgentList).isNull(); } @Test @@ -170,7 +182,7 @@ void executeRequest_withEmptyCustomUserAgent_shouldStoreSdkUserAgent() { RestJsonEndpointProvidersClient client = clientWithCustomUserAgent(""); executeRequestExpectingInterception(client); - assertUserAgentContains(SDK_USER_AGENT_PREFIX); + assertUserAgentContains(""); } @Test @@ -179,14 +191,12 @@ void executeRequest_withNullStringUserAgent_shouldStoreAsSdkUserAgent() { executeRequestExpectingInterception(client); List userAgentList = getCapturedUserAgentList(); - assertThat(userAgentList).hasSize(1); assertThat(userAgentList) .hasSize(1) .allSatisfy(ua -> { - assertThat(ua).isNotNull(); - assertThat(ua).startsWith(SDK_USER_AGENT_PREFIX); - }); + assertThat(ua).isNull(); + }); } private void assertUserAgentContains(String expected) { From f0d9dfe56f79ae30b92052c082ecf8dc0fea371b Mon Sep 17 00:00:00 2001 From: John Viegas Date: Fri, 5 Dec 2025 14:16:04 -0800 Subject: [PATCH 4/5] Support for putHeaders on Request override config --- .../pipeline/stages/ApplyUserAgentStage.java | 14 ++- .../useragent/CustomUserAgentHeaderTest.java | 110 +++++++++++++++--- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java index 681a777752c8..78e4c3c66406 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java @@ -68,7 +68,7 @@ public ApplyUserAgentStage(HttpClientDependencies dependencies) { public SdkHttpFullRequest.Builder execute(SdkHttpFullRequest.Builder request, RequestExecutionContext context) throws Exception { - if (hasUserAgentInAdditionalHeaders()) { + if (hasUserAgentInAdditionalHeaders() || hasUserAgentInRequestHeaders(context)) { return request; } String headerValue = finalizeUserAgent(context); @@ -87,6 +87,18 @@ private boolean hasUserAgentInAdditionalHeaders() { return additionalHeaders.containsKey(HEADER_USER_AGENT); } + /** + * Checks if User-Agent header is present in request-level headers. + * We skip adding user-agent in the ApplyUserAgentStage if user has set "User-Agent" header at request level + */ + private boolean hasUserAgentInRequestHeaders(RequestExecutionContext context) { + Map> requestHeaders = context.requestConfig().headers(); + if (requestHeaders == null) { + return false; + } + return requestHeaders.containsKey(HEADER_USER_AGENT); + } + /** * The final value sent in the user agent header consists of *

      diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java index 297a7d98fc8e..4711b9906ce3 100644 --- a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/useragent/CustomUserAgentHeaderTest.java @@ -63,8 +63,6 @@ private static Stream customUserAgentValues() { ); } - // ========== Default Behavior Tests ========== - private static Stream customUserAgentListValues() { return Stream.of( Arguments.of(Arrays.asList("Agent1")), @@ -73,13 +71,13 @@ private static Stream customUserAgentListValues() { ); } - // ========== Custom User-Agent Preservation Tests ========== - @BeforeEach void setUp() { interceptor = new CapturingInterceptor(); } + // ========== Default Behavior Tests ========== + @Test void executeRequest_withoutCustomUserAgent_shouldAddSdkDefaultUserAgent() { RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); @@ -88,7 +86,7 @@ void executeRequest_withoutCustomUserAgent_shouldAddSdkDefaultUserAgent() { assertUserAgentContains(SDK_USER_AGENT_PREFIX); } - // ========== API Name Handling Tests ========== + // ========== Custom User-Agent Preservation Tests ========== @ParameterizedTest(name = "Custom User-Agent ''{0}'' should be preserved without SDK prefix") @MethodSource("customUserAgentValues") @@ -112,6 +110,69 @@ void executeRequest_withCustomUserAgentList_shouldPreserveAllValues(List assertThat(userAgentList).isEqualTo(customUserAgentList); } + // ========== Request-Level User-Agent Tests ========== + + @ParameterizedTest(name = "Request-level User-Agent ''{0}'' should be preserved") + @MethodSource("customUserAgentValues") + void executeRequest_withRequestLevelCustomUserAgent_shouldPreserveAndNotOverwrite(String customUserAgent) { + RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); + + assertThatThrownBy(() -> client.allTypes(r -> r + .overrideConfiguration(o -> o.putHeader(USER_AGENT_HEADER, customUserAgent)))) + .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); + + String userAgent = getCapturedUserAgent(); + assertThat(userAgent) + .isEqualTo(customUserAgent) + .doesNotContain(SDK_USER_AGENT_PREFIX); + } + + @ParameterizedTest(name = "Request-level User-Agent list {0} should be preserved") + @MethodSource("customUserAgentListValues") + void executeRequest_withRequestLevelCustomUserAgentList_shouldPreserveAllValues(List customUserAgentList) { + RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); + + assertThatThrownBy(() -> client.allTypes(r -> r + .overrideConfiguration(o -> o.putHeader(USER_AGENT_HEADER, customUserAgentList)))) + .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); + + List userAgentList = getCapturedUserAgentList(); + assertThat(userAgentList).isEqualTo(customUserAgentList); + } + + @Test + void executeRequest_withRequestLevelCustomUserAgentAndApiName_shouldNotAppendApiName() { + String customUserAgent = "CustomUserAgentHeaderValue"; + RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); + + assertThatThrownBy(() -> client.allTypes(r -> r + .overrideConfiguration(o -> o + .addApiName(api -> api.name(TEST_API_NAME).version(TEST_API_VERSION)) + .putHeader(USER_AGENT_HEADER, customUserAgent)))) + .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); + + String userAgent = getCapturedUserAgent(); + assertThat(userAgent) + .isEqualTo(customUserAgent) + .doesNotContain(TEST_API_NAME); + } + + @ParameterizedTest(name = "Request-level User-Agent list {0} with API name should not append API name") + @MethodSource("customUserAgentListValues") + void executeRequest_withRequestLevelCustomUserAgentListAndApiName_shouldNotAppendApiName(List customUserAgentList) { + RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); + + assertThatThrownBy(() -> client.allTypes(r -> r + .overrideConfiguration(o -> o + .addApiName(api -> api.name(TEST_API_NAME).version(TEST_API_VERSION)) + .putHeader(USER_AGENT_HEADER, customUserAgentList)))) + .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); + + List userAgentList = getCapturedUserAgentList(); + assertThat(userAgentList).isEqualTo(customUserAgentList); + assertThat(String.join(" ", userAgentList)).doesNotContain(TEST_API_NAME); + } + // ========== Header via Interceptors ========== @Test @@ -133,7 +194,7 @@ public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context, assertUserAgentContains(SDK_USER_AGENT_PREFIX); } - // ========== Edge Case Tests ========== + // ========== API Name Handling Tests ========== @Test void executeRequest_withCustomUserAgentAndApiName_shouldNotAppendApiName() { @@ -155,12 +216,8 @@ void executeRequest_withoutCustomUserAgentAndWithApiName_shouldAppendApiName() { assertUserAgentContains(TEST_API_NAME + "/" + TEST_API_VERSION); } - /** - * Verifies that null User-Agent list throws NullPointerException. - * - *

      This ensures the SDK fails fast with clear error rather than allowing - * invalid configuration. - */ + // ========== Edge Case Tests ========== + @Test void buildClient_withNullListUserAgent_shouldThrowNullPointerException() { assertThatThrownBy(() -> clientWithCustomUserAgentList(null)) @@ -195,17 +252,40 @@ void executeRequest_withNullStringUserAgent_shouldStoreAsSdkUserAgent() { .hasSize(1) .allSatisfy(ua -> { assertThat(ua).isNull(); - }); } + @Test + void executeRequest_withRequestLevelEmptyCustomUserAgent_shouldStoreEmptyUserAgent() { + RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); + + assertThatThrownBy(() -> client.allTypes(r -> r + .overrideConfiguration(o -> o.putHeader(USER_AGENT_HEADER, "")))) + .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); + + assertUserAgentContains(""); + } + + @Test + void executeRequest_withRequestLevelEmptyListUserAgent_shouldResultInNoUserAgent() { + RestJsonEndpointProvidersClient client = defaultClientBuilder().build(); + + assertThatThrownBy(() -> client.allTypes(r -> r + .overrideConfiguration(o -> o.putHeader(USER_AGENT_HEADER, Collections.emptyList())))) + .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); + + List userAgentList = getCapturedUserAgentList(); + assertThat(userAgentList).isNull(); + } + + // ========== Helper Methods ========== + private void assertUserAgentContains(String expected) { assertThat(getCapturedUserAgent()).contains(expected); } private void executeRequestExpectingInterception(RestJsonEndpointProvidersClient client) { - assertThatThrownBy(() -> client.allTypes(r -> { - })) + assertThatThrownBy(() -> client.allTypes(r -> {})) .hasMessageContaining(INTERCEPTOR_STOP_MESSAGE); } From 184932f508f9ff31984259ccae8f7fc7c4f0bf32 Mon Sep 17 00:00:00 2001 From: John Viegas Date: Fri, 5 Dec 2025 17:23:25 -0800 Subject: [PATCH 5/5] update review comments --- .changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json | 2 +- .../internal/http/pipeline/stages/ApplyUserAgentStage.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json b/.changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json index 51543ef5bc4f..eab06274e421 100644 --- a/.changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json +++ b/.changes/next-release/bugfix-AWSSDKforJavav2-f9f830e.json @@ -2,5 +2,5 @@ "type": "bugfix", "category": "AWS SDK for Java v2", "contributor": "", - "description": "Skip User-Agent header modification in ApplyUserAgentStage when custom User-Agent is already provided." + "description": "ApplyUserAgentStage will not overwrite the custom User-Agent" } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java index 78e4c3c66406..692973f528ec 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/ApplyUserAgentStage.java @@ -68,7 +68,7 @@ public ApplyUserAgentStage(HttpClientDependencies dependencies) { public SdkHttpFullRequest.Builder execute(SdkHttpFullRequest.Builder request, RequestExecutionContext context) throws Exception { - if (hasUserAgentInAdditionalHeaders() || hasUserAgentInRequestHeaders(context)) { + if (hasUserAgentInAdditionalHeaders() || hasUserAgentInRequestConfig(context)) { return request; } String headerValue = finalizeUserAgent(context); @@ -88,10 +88,10 @@ private boolean hasUserAgentInAdditionalHeaders() { } /** - * Checks if User-Agent header is present in request-level headers. + * Checks if User-Agent header is present in request override configs. * We skip adding user-agent in the ApplyUserAgentStage if user has set "User-Agent" header at request level */ - private boolean hasUserAgentInRequestHeaders(RequestExecutionContext context) { + private boolean hasUserAgentInRequestConfig(RequestExecutionContext context) { Map> requestHeaders = context.requestConfig().headers(); if (requestHeaders == null) { return false;