Skip to content

Commit 6a3f6cd

Browse files
tvaron3simorenoh
andauthored
[Bugfix] UserAgent Refactoring and Remove Duplicate Custom Prefix (#44189)
* change workloads based on feedback * add staging yml file * add staging yml file * refactor useragent changes for enabled features and fix bug on init * update samples * update changelog * refactor tests, add suffix tests, react to copilot * pylint unused imports * Update sdk/cosmos/azure-cosmos/azure/cosmos/user_agent_policy.py --------- Co-authored-by: Simon Moreno <[email protected]>
1 parent 6817570 commit 6a3f6cd

12 files changed

+248
-172
lines changed

sdk/cosmos/azure-cosmos/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#### Bugs Fixed
1212
* Fixed bug where client timeout/read_timeout values were not properly enforced[PR 42652](https://github.com/Azure/azure-sdk-for-python/pull/42652).
1313
* Fixed bug when passing in None for some option in `query_items` would cause unexpected errors. See [PR 44098](https://github.com/Azure/azure-sdk-for-python/pull/44098)
14+
* Fixed bug where first metadata requests have duplicated custom `user_agent` in headers. See [PR 44189](https://github.com/Azure/azure-sdk-for-python/pull/44189)
1415

1516
#### Other Changes
1617
* Added cross-regional retries for 503 (Service Unavailable) errors. See [PR 41588](https://github.com/Azure/azure-sdk-for-python/pull/41588).

sdk/cosmos/azure-cosmos/azure/cosmos/_cosmos_client_connection.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
HTTPPolicy,
3939
ContentDecodePolicy,
4040
HeadersPolicy,
41-
UserAgentPolicy,
4241
NetworkTraceLoggingPolicy,
4342
CustomHookPolicy,
4443
DistributedTracingPolicy,
@@ -49,6 +48,7 @@
4948
from azure.core.utils import CaseInsensitiveDict
5049

5150
from . import _base as base
51+
from .user_agent_policy import CosmosUserAgentPolicy
5252
from ._global_partition_endpoint_manager_per_partition_automatic_failover import _GlobalPartitionEndpointManagerForPerPartitionAutomaticFailover # pylint: disable=line-too-long
5353
from . import _query_iterable as query_iterable
5454
from . import _runtime_constants as runtime_constants
@@ -231,7 +231,7 @@ def __init__( # pylint: disable=too-many-statements
231231
policies = [
232232
HeadersPolicy(**kwargs),
233233
ProxyPolicy(proxies=proxies),
234-
UserAgentPolicy(base_user_agent=self._user_agent, **kwargs),
234+
CosmosUserAgentPolicy(base_user_agent=self._user_agent, **kwargs),
235235
ContentDecodePolicy(),
236236
retry_policy,
237237
credentials_policy,
@@ -245,6 +245,8 @@ def __init__( # pylint: disable=too-many-statements
245245
**kwargs
246246
),
247247
]
248+
# after passing in the user_agent into the user agent policy the user_agent is no longer needed
249+
kwargs.pop("user_agent", None)
248250

249251
transport = kwargs.pop("transport", None)
250252
self.pipeline_client: PipelineClient[HttpRequest, HttpResponse] = PipelineClient(

sdk/cosmos/azure-cosmos/azure/cosmos/_synchronized_request.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@
3434
from ._availability_strategy_handler import execute_with_hedging
3535
from ._constants import _Constants
3636
from ._request_object import RequestObject
37-
from ._utils import get_user_agent_features
3837
from .documents import _OperationType
39-
from .http_constants import ResourceType
4038

4139
# cspell:ignore ppaf
4240
def _is_readable_stream(obj):
@@ -133,15 +131,6 @@ def _Request(global_endpoint_manager, request_params, connection_policy, pipelin
133131

134132
parse_result = urlparse(request.url)
135133

136-
# Add relevant enabled features to user agent for debugging
137-
if request.headers[http_constants.HttpHeaders.ThinClientProxyResourceType] == http_constants.ResourceType.Document:
138-
user_agent_features = get_user_agent_features(global_endpoint_manager)
139-
if len(user_agent_features) > 0:
140-
user_agent = kwargs.pop("user_agent", global_endpoint_manager.client._user_agent)
141-
user_agent = "{} {}".format(user_agent, user_agent_features)
142-
kwargs.update({"user_agent": user_agent})
143-
kwargs.update({"user_agent_overwrite": True})
144-
145134
# The requests library now expects header values to be strings only starting 2.11,
146135
# and will raise an error on validation if they are not, so casting all header values to strings.
147136
request.headers.update({header: str(value) for header, value in request.headers.items()})
@@ -220,7 +209,7 @@ def _is_availability_strategy_applicable(request_params: RequestObject) -> bool:
220209
"""
221210
return (request_params.availability_strategy_config is not None and
222211
not request_params.is_hedging_request and
223-
request_params.resource_type == ResourceType.Document and
212+
request_params.resource_type == http_constants.ResourceType.Document and
224213
(not _OperationType.IsWriteOperation(request_params.operation_type) or
225214
request_params.retry_write > 0))
226215

sdk/cosmos/azure-cosmos/azure/cosmos/aio/_asynchronous_request.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@
3636
from .._constants import _Constants
3737
from .._request_object import RequestObject
3838
from .._synchronized_request import _request_body_from_data, _replace_url_prefix
39-
from .._utils import get_user_agent_features
4039
from ..documents import _OperationType
41-
from ..http_constants import ResourceType
4240

4341
# cspell:ignore ppaf
4442
async def _Request(global_endpoint_manager, request_params, connection_policy, pipeline_client, request, **kwargs): # pylint: disable=too-many-statements
@@ -97,15 +95,6 @@ async def _Request(global_endpoint_manager, request_params, connection_policy, p
9795

9896
parse_result = urlparse(request.url)
9997

100-
# Add relevant enabled features to user agent for debugging
101-
if request.headers[http_constants.HttpHeaders.ThinClientProxyResourceType] == http_constants.ResourceType.Document:
102-
user_agent_features = get_user_agent_features(global_endpoint_manager)
103-
if len(user_agent_features) > 0:
104-
user_agent = kwargs.pop("user_agent", global_endpoint_manager.client._user_agent)
105-
user_agent = "{} {}".format(user_agent, user_agent_features)
106-
kwargs.update({"user_agent": user_agent})
107-
kwargs.update({"user_agent_overwrite": True})
108-
10998
# The requests library now expects header values to be strings only starting 2.11,
11099
# and will raise an error on validation if they are not, so casting all header values to strings.
111100
request.headers.update({header: str(value) for header, value in request.headers.items()})
@@ -190,7 +179,7 @@ def _is_availability_strategy_applicable(request_params: RequestObject) -> bool:
190179
"""
191180
return (request_params.availability_strategy_config is not None and
192181
not request_params.is_hedging_request and
193-
request_params.resource_type == ResourceType.Document and
182+
request_params.resource_type == http_constants.ResourceType.Document and
194183
(not _OperationType.IsWriteOperation(request_params.operation_type) or
195184
request_params.retry_write > 0))
196185

sdk/cosmos/azure-cosmos/azure/cosmos/aio/_cosmos_client_connection_async.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
AsyncHTTPPolicy,
3939
ContentDecodePolicy,
4040
HeadersPolicy,
41-
UserAgentPolicy,
4241
NetworkTraceLoggingPolicy,
4342
CustomHookPolicy,
4443
DistributedTracingPolicy,
@@ -79,8 +78,7 @@
7978
from .._cosmos_http_logging_policy import CosmosHttpLoggingPolicy
8079
from .._range_partition_resolver import RangePartitionResolver
8180
from ._read_items_helper_async import ReadItemsHelperAsync
82-
83-
81+
from ..user_agent_policy import CosmosUserAgentPolicy
8482

8583

8684
class CredentialDict(TypedDict, total=False):
@@ -230,7 +228,7 @@ def __init__( # pylint: disable=too-many-statements
230228
policies = [
231229
HeadersPolicy(**kwargs),
232230
ProxyPolicy(proxies=proxies),
233-
UserAgentPolicy(base_user_agent=self._user_agent, **kwargs),
231+
CosmosUserAgentPolicy(base_user_agent=self._user_agent, **kwargs),
234232
ContentDecodePolicy(),
235233
retry_policy,
236234
credentials_policy,
@@ -244,6 +242,8 @@ def __init__( # pylint: disable=too-many-statements
244242
**kwargs
245243
),
246244
]
245+
# after passing in the user_agent into the user agent policy the user_agent is no longer needed
246+
kwargs.pop("user_agent", None)
247247

248248
transport = kwargs.pop("transport", None)
249249
self.pipeline_client: AsyncPipelineClient[HttpRequest, AsyncHttpResponse] = AsyncPipelineClient(
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See LICENSE.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
7+
from azure.core.pipeline import PipelineRequest
8+
from azure.core.pipeline.policies import UserAgentPolicy
9+
from azure.core.pipeline.policies._universal import HTTPRequestType
10+
11+
from azure.cosmos._utils import get_user_agent_features
12+
13+
14+
class CosmosUserAgentPolicy(UserAgentPolicy):
15+
"""Custom user agent policy for Cosmos DB that appends feature flags to the user agent string.
16+
17+
This policy extends the standard UserAgentPolicy to include Cosmos-specific feature flags
18+
(e.g., circuit breaker, per-partition automatic failover) in the user agent header for
19+
debugging and telemetry purposes.
20+
"""
21+
22+
def on_request(self, request: PipelineRequest[HTTPRequestType]) -> None:
23+
"""Modifies the User-Agent header before the request is sent.
24+
25+
:param request: The PipelineRequest object
26+
:type request: ~azure.core.pipeline.PipelineRequest
27+
"""
28+
options_dict = request.context.options
29+
# Add relevant enabled features to user agent for debugging
30+
if "global_endpoint_manager" in options_dict:
31+
global_endpoint_manager = options_dict["global_endpoint_manager"]
32+
user_agent_features = get_user_agent_features(global_endpoint_manager)
33+
if len(user_agent_features) > 0:
34+
user_agent = "{} {}".format(self._user_agent, user_agent_features)
35+
options_dict["user_agent"] = user_agent
36+
options_dict["user_agent_overwrite"] = True
37+
super().on_request(request)

sdk/cosmos/azure-cosmos/samples/user_agent_management.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def create_client_with_custom_user_agent():
7474
client = cosmos_client.CosmosClient(
7575
HOST,
7676
{'masterKey': MASTER_KEY},
77-
user_agent=custom_user_agent
77+
user_agent_suffix=custom_user_agent
7878
)
7979

8080
print(f'Client created with custom user agent: {custom_user_agent}')
@@ -106,7 +106,7 @@ def create_client_with_detailed_user_agent():
106106
client = cosmos_client.CosmosClient(
107107
HOST,
108108
{'masterKey': MASTER_KEY},
109-
user_agent=detailed_user_agent
109+
user_agent_suffix=detailed_user_agent
110110
)
111111

112112
print(f'Client created with detailed user agent: {detailed_user_agent}')
@@ -128,23 +128,23 @@ def create_multiple_clients_with_different_user_agents():
128128
ingestion_client = cosmos_client.CosmosClient(
129129
HOST,
130130
{'masterKey': MASTER_KEY},
131-
user_agent="DataIngestionService/1.0.0"
131+
user_agent_suffix="DataIngestionService/1.0.0"
132132
)
133133
print('Created client for data ingestion: DataIngestionService/1.0.0')
134134

135135
# Client for query service
136136
query_client = cosmos_client.CosmosClient(
137137
HOST,
138138
{'masterKey': MASTER_KEY},
139-
user_agent="QueryService/1.0.0"
139+
user_agent_suffix="QueryService/1.0.0"
140140
)
141141
print('Created client for queries: QueryService/1.0.0')
142142

143143
# Client for analytics service
144144
analytics_client = cosmos_client.CosmosClient(
145145
HOST,
146146
{'masterKey': MASTER_KEY},
147-
user_agent="AnalyticsService/1.0.0"
147+
user_agent_suffix="AnalyticsService/1.0.0"
148148
)
149149
print('Created client for analytics: AnalyticsService/1.0.0')
150150

@@ -215,7 +215,7 @@ def operation_with_user_agent(user_agent_suffix, operation_name):
215215
client = cosmos_client.CosmosClient(
216216
HOST,
217217
{'masterKey': MASTER_KEY},
218-
user_agent=user_agent_suffix
218+
user_agent_suffix=user_agent_suffix
219219
)
220220
db = client.create_database_if_not_exists(id=DATABASE_ID)
221221
print(f'{operation_name} completed with user agent: {user_agent_suffix}')

sdk/cosmos/azure-cosmos/samples/user_agent_management_async.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ async def create_client_with_custom_user_agent():
7373
async with CosmosClient(
7474
HOST,
7575
{'masterKey': MASTER_KEY},
76-
user_agent=custom_user_agent
76+
user_agent_suffix=custom_user_agent
7777
) as client:
7878
print(f'Async client created with custom user agent: {custom_user_agent}')
7979
print('This will appear as: [default-user-agent] {}'.format(custom_user_agent))
@@ -103,7 +103,7 @@ async def create_client_with_detailed_user_agent():
103103
async with CosmosClient(
104104
HOST,
105105
{'masterKey': MASTER_KEY},
106-
user_agent=detailed_user_agent
106+
user_agent_suffix=detailed_user_agent
107107
) as client:
108108
print(f'Async client created with detailed user agent: {detailed_user_agent}')
109109
print('This helps identify specific instances in diagnostics and logs')
@@ -146,7 +146,7 @@ async def demonstrate_user_agent_in_operations():
146146
async with CosmosClient(
147147
HOST,
148148
{'masterKey': MASTER_KEY},
149-
user_agent=custom_suffix
149+
user_agent_suffix=custom_suffix
150150
) as client:
151151
try:
152152
# Create database
@@ -199,7 +199,7 @@ async def operation_with_user_agent(user_agent_suffix, operation_name):
199199
async with CosmosClient(
200200
HOST,
201201
{'masterKey': MASTER_KEY},
202-
user_agent=user_agent_suffix
202+
user_agent_suffix=user_agent_suffix
203203
) as client:
204204
db = await client.create_database_if_not_exists(id=DATABASE_ID)
205205
print(f'{operation_name} completed with user agent: {user_agent_suffix}')
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# The MIT License (MIT)
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
import os
4+
import unittest
5+
import uuid
6+
import re
7+
from typing import Optional, Any
8+
9+
import test_config
10+
import pytest
11+
from azure.cosmos import CosmosClient
12+
from test_cosmos_http_logging_policy import create_logger
13+
14+
15+
@pytest.mark.cosmosEmulator
16+
class TestUserAgent(unittest.TestCase):
17+
"""Python User Agent Prefix/Suffix Tests (sync)."""
18+
19+
client: Optional[CosmosClient] = None
20+
host = test_config.TestConfig.host
21+
masterKey = test_config.TestConfig.masterKey
22+
connectionPolicy = test_config.TestConfig.connectionPolicy
23+
24+
@classmethod
25+
def setUpClass(cls):
26+
if (cls.masterKey == '[YOUR_KEY_HERE]' or
27+
cls.host == '[YOUR_ENDPOINT_HERE]'):
28+
raise Exception(
29+
"You must specify your Azure Cosmos account values for "
30+
"'masterKey' and 'host' at the top of this class to run the "
31+
"tests.")
32+
33+
def _check(self, user_agent_kw: dict[str, Any]) -> None:
34+
self.client = CosmosClient(self.host, self.masterKey, **user_agent_kw)
35+
created_database = self.client.get_database_client(test_config.TestConfig.TEST_DATABASE_ID)
36+
read_result = created_database.read()
37+
assert read_result['id'] == created_database.id
38+
39+
def test_user_agent_special_characters(self):
40+
# Values cover normal, accented, apostrophe, unicode, and spaces / symbols
41+
user_agents = ["TestUserAgent", "TéstUserAgent's", "UserAgent with space$%_^()*&"] # cspell:disable-line
42+
for ua in user_agents:
43+
# Prefix usage
44+
self._check({'user_agent': ua})
45+
# Suffix usage
46+
self._check({'user_agent_suffix': ua})
47+
48+
# Explicit negative test for disallowed unicode (same logic as previous unicode test)
49+
bad = "UnicodeChar鱀InUserAgent" # expecting potential encode issues when used as prefix
50+
try:
51+
# Prefix usage
52+
self._check({'user_agent': bad})
53+
# Suffix usage
54+
self._check({'user_agent_suffix': bad})
55+
pytest.fail("Unicode characters should not be allowed.")
56+
except UnicodeEncodeError as e:
57+
assert "ordinal not in range(256)" in e.reason
58+
59+
def test_user_agent_with_features(self):
60+
def _run_case(use_suffix: bool) -> None:
61+
os.environ["AZURE_COSMOS_ENABLE_CIRCUIT_BREAKER"] = "True"
62+
user_agent_value = "customString"
63+
mock_handler = test_config.MockHandler()
64+
logger = create_logger("testloggerdefault_sync", mock_handler)
65+
try:
66+
kwargs: dict[str, Any] = {"logger": logger}
67+
if use_suffix:
68+
kwargs["user_agent_suffix"] = user_agent_value
69+
else:
70+
kwargs["user_agent"] = user_agent_value
71+
self.client = CosmosClient(self.host, self.masterKey, **kwargs)
72+
created_database = self.client.get_database_client(test_config.TestConfig.TEST_DATABASE_ID)
73+
container = created_database.get_container_client(test_config.TestConfig.TEST_SINGLE_PARTITION_CONTAINER_ID)
74+
container.create_item(body={"id": "testItem" + str(uuid.uuid4()), "content": "testContent"})
75+
container.create_item(body={"id": "testItem" + str(uuid.uuid4()), "content": "testContent"})
76+
finally:
77+
os.environ["AZURE_COSMOS_ENABLE_CIRCUIT_BREAKER"] = "False"
78+
79+
# Examine log messages for User-Agent header
80+
for log_message in mock_handler.messages:
81+
msg = log_message.message
82+
if msg.startswith("Request URL:"):
83+
m = re.search(r"'User-Agent':\s*'([^']+)'", msg, re.MULTILINE)
84+
if m:
85+
ua = m.group(1)
86+
if use_suffix:
87+
# Suffix case: SDK core first then suffix at end
88+
assert ua.startswith("azsdk-python-cosmos")
89+
assert ua.endswith("customString | F2")
90+
else:
91+
# Prefix case: custom part first
92+
assert ua.startswith("customString azsdk-python-cosmos")
93+
assert ua.endswith("| F2")
94+
95+
_run_case(False) # prefix scenario
96+
_run_case(True) # suffix scenario
97+
98+
99+
if __name__ == '__main__':
100+
unittest.main()

0 commit comments

Comments
 (0)