From 32b88c12256f80c436527e782f58ecf0dcfbd0ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:14:02 +0000 Subject: [PATCH 1/6] Initial plan From 792e84ddc744e9dc4ebf4b436763b90fa8202824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:22:36 +0000 Subject: [PATCH 2/6] Fix 403 AAD token refresh in CosmosBearerTokenCredentialPolicy (sync and async) When Cosmos DB returns HTTP 403 with sub-status 5300 (AAD_REQUEST_NOT_AUTHORIZED), the cached bearer token is now cleared and the request is retried with a fresh token. This mirrors how the base class handles HTTP 401, and resolves the issue where long-running services using managed identity would permanently fail after token expiry. - Added send() override to CosmosBearerTokenCredentialPolicy (_auth_policy.py) - Added send() override to AsyncCosmosBearerTokenCredentialPolicy (_auth_policy_async.py) - Added unit tests for both sync and async policies Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/a5381531-6292-4e5e-be43-586d3267d980 Co-authored-by: bambriz <8497145+bambriz@users.noreply.github.com> --- .../azure-cosmos/azure/cosmos/_auth_policy.py | 29 +++- .../azure/cosmos/aio/_auth_policy_async.py | 29 +++- .../tests/test_auth_policy_unit.py | 157 ++++++++++++++++++ .../tests/test_auth_policy_unit_async.py | 157 ++++++++++++++++++ 4 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py create mode 100644 sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py diff --git a/sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py b/sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py index 83418e1f375d..41667d3bcec0 100644 --- a/sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py +++ b/sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py @@ -12,7 +12,7 @@ from azure.core.credentials import AccessToken from azure.core.exceptions import HttpResponseError -from .http_constants import HttpHeaders +from .http_constants import HttpHeaders, SubStatusCodes from ._constants import _Constants as Constants HTTPRequestType = TypeVar("HTTPRequestType", HttpRequest, LegacyHttpRequest) @@ -67,6 +67,33 @@ def on_request(self, request: PipelineRequest[HTTPRequestType]) -> None: continue raise + def send(self, request: PipelineRequest[HTTPRequestType]): # type: ignore[override] + """Authorize request with a bearer token and send it to the next policy. + + If Cosmos DB returns HTTP 403 with sub-status AAD_REQUEST_NOT_AUTHORIZED (5300), the cached + token is cleared and a single retry is performed with a fresh token. This handles the case + where an AAD token has expired and Cosmos DB returns 403 instead of 401. + + :param request: The pipeline request object + :type request: ~azure.core.pipeline.PipelineRequest + :return: The pipeline response object + :rtype: ~azure.core.pipeline.PipelineResponse + """ + response = super().send(request) + if ( + response.http_response.status_code == 403 + and int(response.http_response.headers.get(HttpHeaders.SubStatus, 0)) + == SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED + ): + self._token = None # cached token is invalid + self.on_request(request) + try: + response = self.next.send(request) + except Exception: + self.on_exception(request) + raise + return response + def authorize_request(self, request: PipelineRequest[HTTPRequestType], *scopes: str, **kwargs: Any) -> None: """Acquire a token from the credential and authorize the request with it. diff --git a/sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py b/sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py index ea1a86b120a1..baeac7fd139e 100644 --- a/sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py +++ b/sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py @@ -13,7 +13,7 @@ from azure.core.credentials import AccessToken from azure.core.exceptions import HttpResponseError -from ..http_constants import HttpHeaders +from ..http_constants import HttpHeaders, SubStatusCodes from .._constants import _Constants as Constants HTTPRequestType = TypeVar("HTTPRequestType", HttpRequest, LegacyHttpRequest) @@ -68,6 +68,33 @@ async def on_request(self, request: PipelineRequest[HTTPRequestType]) -> None: continue raise + async def send(self, request: PipelineRequest[HTTPRequestType]): # type: ignore[override] + """Authorize request with a bearer token and send it to the next policy. + + If Cosmos DB returns HTTP 403 with sub-status AAD_REQUEST_NOT_AUTHORIZED (5300), the cached + token is cleared and a single retry is performed with a fresh token. This handles the case + where an AAD token has expired and Cosmos DB returns 403 instead of 401. + + :param request: The pipeline request object + :type request: ~azure.core.pipeline.PipelineRequest + :return: The pipeline response object + :rtype: ~azure.core.pipeline.PipelineResponse + """ + response = await super().send(request) + if ( + response.http_response.status_code == 403 + and int(response.http_response.headers.get(HttpHeaders.SubStatus, 0)) + == SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED + ): + self._token = None # cached token is invalid + await self.on_request(request) + try: + response = await self.next.send(request) + except Exception: + self.on_exception(request) + raise + return response + async def authorize_request(self, request: PipelineRequest[HTTPRequestType], *scopes: str, **kwargs: Any) -> None: """Acquire a token from the credential and authorize the request with it. diff --git a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py new file mode 100644 index 000000000000..4fe1d848c0f1 --- /dev/null +++ b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py @@ -0,0 +1,157 @@ +# The MIT License (MIT) +# Copyright (c) Microsoft Corporation. All rights reserved. + +"""Unit tests for CosmosBearerTokenCredentialPolicy 403/AAD token refresh behavior.""" + +import time +import unittest +from unittest.mock import MagicMock + +from azure.core.credentials import AccessToken +from azure.core.pipeline import PipelineRequest, PipelineResponse, PipelineContext +from azure.core.rest import HttpRequest + +from azure.cosmos._auth_policy import CosmosBearerTokenCredentialPolicy +from azure.cosmos.http_constants import HttpHeaders, SubStatusCodes + + +def _make_request(): + http_request = HttpRequest("GET", "https://example.cosmos.azure.com/dbs") + context = PipelineContext(None) + return PipelineRequest(http_request, context) + + +def _make_response(request, status_code, sub_status=None): + http_response = MagicMock() + http_response.status_code = status_code + headers = {} + if sub_status is not None: + headers[HttpHeaders.SubStatus] = str(sub_status) + http_response.headers = headers + return PipelineResponse(request.http_request, http_response, request.context) + + +def _make_credential(token_str="fake-token"): + credential = MagicMock() + credential.get_token.return_value = AccessToken(token_str, int(time.time()) + 3600) + return credential + + +class TestCosmosBearerTokenPolicySend(unittest.TestCase): + + def _build_policy_with_mock_next(self, credential, first_response, second_response=None): + """Create a policy with a mock `next` that returns given responses sequentially.""" + policy = CosmosBearerTokenCredentialPolicy(credential, "https://cosmos.azure.com/.default") + + call_count = [0] + + def mock_send(req): + call_count[0] += 1 + if call_count[0] == 1: + return first_response + return second_response + + mock_next = MagicMock() + mock_next.send.side_effect = mock_send + policy.next = mock_next + policy._call_count = call_count + return policy + + def test_200_response_passes_through(self): + """A 200 response is returned without any retry.""" + credential = _make_credential() + request = _make_request() + response_200 = _make_response(request, 200) + + policy = self._build_policy_with_mock_next(credential, response_200) + result = policy.send(request) + + assert result.http_response.status_code == 200 + assert policy.next.send.call_count == 1 + + def test_403_without_substatus_no_retry(self): + """A 403 with no sub-status does not trigger a retry (not AAD expiry).""" + credential = _make_credential() + request = _make_request() + response_403 = _make_response(request, 403) + + policy = self._build_policy_with_mock_next(credential, response_403) + result = policy.send(request) + + assert result.http_response.status_code == 403 + assert policy.next.send.call_count == 1 + + def test_403_with_other_substatus_no_retry(self): + """A 403 with a non-AAD sub-status does not trigger a retry.""" + credential = _make_credential() + request = _make_request() + response_403 = _make_response(request, 403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) + + policy = self._build_policy_with_mock_next(credential, response_403) + result = policy.send(request) + + assert result.http_response.status_code == 403 + assert policy.next.send.call_count == 1 + + def test_403_aad_not_authorized_clears_token_and_retries(self): + """A 403 with sub-status AAD_REQUEST_NOT_AUTHORIZED clears the token and retries.""" + credential = _make_credential("token-v1") + request = _make_request() + response_403 = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) + response_200 = _make_response(request, 200) + + policy = self._build_policy_with_mock_next(credential, response_403, response_200) + # Pre-populate the token so we can confirm it gets cleared + policy._token = AccessToken("old-expired-token", int(time.time()) - 100) + + result = policy.send(request) + + assert result.http_response.status_code == 200 + # next.send should have been called twice: initial + retry + assert policy.next.send.call_count == 2 + + def test_403_aad_not_authorized_token_cleared_before_retry(self): + """After 403/5300, the cached token is refreshed so a new token is used on retry.""" + credential = _make_credential("brand-new-token") + + token_states = [] + request = _make_request() + response_403 = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) + response_200 = _make_response(request, 200) + + policy = self._build_policy_with_mock_next(credential, response_403, response_200) + # Set an expired token initially + policy._token = AccessToken("expired-token", int(time.time()) - 10) + + # Capture token state on each call to next.send + original_send = policy.next.send.side_effect + + def capturing_send(req): + token_states.append(policy._token) + return original_send(req) + + policy.next.send.side_effect = capturing_send + + policy.send(request) + + # On the retry (second call), token should have been refreshed (not the expired one) + assert len(token_states) == 2 + assert token_states[1] is not None + assert token_states[1].token != "expired-token" + + def test_403_aad_retry_still_fails_returns_response(self): + """If the retry also fails with non-AAD 403, the second response is returned unchanged.""" + credential = _make_credential() + request = _make_request() + response_403_aad = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) + response_403_other = _make_response(request, 403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) + + policy = self._build_policy_with_mock_next(credential, response_403_aad, response_403_other) + result = policy.send(request) + + assert result.http_response.status_code == 403 + assert policy.next.send.call_count == 2 + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py new file mode 100644 index 000000000000..136aadc935be --- /dev/null +++ b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py @@ -0,0 +1,157 @@ +# The MIT License (MIT) +# Copyright (c) Microsoft Corporation. All rights reserved. + +"""Async unit tests for AsyncCosmosBearerTokenCredentialPolicy 403/AAD token refresh behavior.""" + +import time +import unittest +from unittest.mock import MagicMock, AsyncMock + +from azure.core.credentials import AccessToken +from azure.core.pipeline import PipelineRequest, PipelineResponse, PipelineContext +from azure.core.rest import HttpRequest + +from azure.cosmos.aio._auth_policy_async import AsyncCosmosBearerTokenCredentialPolicy +from azure.cosmos.http_constants import HttpHeaders, SubStatusCodes + + +def _make_request(): + http_request = HttpRequest("GET", "https://example.cosmos.azure.com/dbs") + context = PipelineContext(None) + return PipelineRequest(http_request, context) + + +def _make_response(request, status_code, sub_status=None): + http_response = MagicMock() + http_response.status_code = status_code + headers = {} + if sub_status is not None: + headers[HttpHeaders.SubStatus] = str(sub_status) + http_response.headers = headers + return PipelineResponse(request.http_request, http_response, request.context) + + +def _make_async_credential(token_str="fake-token"): + credential = MagicMock() + credential.get_token = AsyncMock(return_value=AccessToken(token_str, int(time.time()) + 3600)) + return credential + + +class TestAsyncCosmosBearerTokenPolicySend(unittest.IsolatedAsyncioTestCase): + + def _build_policy_with_mock_next(self, credential, first_response, second_response=None): + """Create a policy with a mock `next` that returns given responses sequentially.""" + policy = AsyncCosmosBearerTokenCredentialPolicy(credential, "https://cosmos.azure.com/.default") + + call_count = [0] + + async def mock_send(req): + call_count[0] += 1 + if call_count[0] == 1: + return first_response + return second_response + + mock_next = MagicMock() + mock_next.send = AsyncMock(side_effect=mock_send) + policy.next = mock_next + policy._call_count = call_count + return policy + + async def test_200_response_passes_through(self): + """A 200 response is returned without any retry.""" + credential = _make_async_credential() + request = _make_request() + response_200 = _make_response(request, 200) + + policy = self._build_policy_with_mock_next(credential, response_200) + result = await policy.send(request) + + assert result.http_response.status_code == 200 + assert policy.next.send.call_count == 1 + + async def test_403_without_substatus_no_retry(self): + """A 403 with no sub-status does not trigger a retry (not AAD expiry).""" + credential = _make_async_credential() + request = _make_request() + response_403 = _make_response(request, 403) + + policy = self._build_policy_with_mock_next(credential, response_403) + result = await policy.send(request) + + assert result.http_response.status_code == 403 + assert policy.next.send.call_count == 1 + + async def test_403_with_other_substatus_no_retry(self): + """A 403 with a non-AAD sub-status does not trigger a retry.""" + credential = _make_async_credential() + request = _make_request() + response_403 = _make_response(request, 403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) + + policy = self._build_policy_with_mock_next(credential, response_403) + result = await policy.send(request) + + assert result.http_response.status_code == 403 + assert policy.next.send.call_count == 1 + + async def test_403_aad_not_authorized_clears_token_and_retries(self): + """A 403 with sub-status AAD_REQUEST_NOT_AUTHORIZED clears the token and retries.""" + credential = _make_async_credential("token-v1") + request = _make_request() + response_403 = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) + response_200 = _make_response(request, 200) + + policy = self._build_policy_with_mock_next(credential, response_403, response_200) + # Pre-populate the token so we can confirm it gets cleared + policy._token = AccessToken("old-expired-token", int(time.time()) - 100) + + result = await policy.send(request) + + assert result.http_response.status_code == 200 + # next.send should have been called twice: initial + retry + assert policy.next.send.call_count == 2 + + async def test_403_aad_not_authorized_token_cleared_before_retry(self): + """After 403/5300, the cached token is refreshed so a new token is used on retry.""" + credential = _make_async_credential("brand-new-token") + + token_states = [] + request = _make_request() + response_403 = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) + response_200 = _make_response(request, 200) + + policy = self._build_policy_with_mock_next(credential, response_403, response_200) + # Set an expired token initially + policy._token = AccessToken("expired-token", int(time.time()) - 10) + + # Capture token state on each call to next.send + original_send = policy.next.send.side_effect + + async def capturing_send(req): + token_states.append(policy._token) + return await original_send(req) + + policy.next.send.side_effect = capturing_send + + await policy.send(request) + + # On the retry (second call), token should have been refreshed (not the expired one) + assert len(token_states) == 2 + assert token_states[1] is not None + assert token_states[1].token != "expired-token" + + async def test_403_aad_retry_still_fails_returns_response(self): + """If the retry also fails with non-AAD 403, the second response is returned unchanged.""" + credential = _make_async_credential() + request = _make_request() + response_403_aad = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) + response_403_other = _make_response(request, 403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) + + policy = self._build_policy_with_mock_next(credential, response_403_aad, response_403_other) + result = await policy.send(request) + + assert result.http_response.status_code == 403 + assert policy.next.send.call_count == 2 + + +if __name__ == "__main__": + unittest.main() From de530080d03ee3359ecd65163c512dd4914b11e5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:23:42 +0000 Subject: [PATCH 3/6] Fix condition indentation in send() overrides Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/a5381531-6292-4e5e-be43-586d3267d980 Co-authored-by: bambriz <8497145+bambriz@users.noreply.github.com> --- sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py | 2 +- sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py b/sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py index 41667d3bcec0..f622720a69bc 100644 --- a/sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py +++ b/sdk/cosmos/azure-cosmos/azure/cosmos/_auth_policy.py @@ -83,7 +83,7 @@ def send(self, request: PipelineRequest[HTTPRequestType]): # type: ignore[overr if ( response.http_response.status_code == 403 and int(response.http_response.headers.get(HttpHeaders.SubStatus, 0)) - == SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED + == SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED ): self._token = None # cached token is invalid self.on_request(request) diff --git a/sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py b/sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py index baeac7fd139e..b0d6d55e0b18 100644 --- a/sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py +++ b/sdk/cosmos/azure-cosmos/azure/cosmos/aio/_auth_policy_async.py @@ -84,7 +84,7 @@ async def send(self, request: PipelineRequest[HTTPRequestType]): # type: ignore if ( response.http_response.status_code == 403 and int(response.http_response.headers.get(HttpHeaders.SubStatus, 0)) - == SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED + == SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED ): self._token = None # cached token is invalid await self.on_request(request) From cb77da60388a1a92bd57f08d69f503f8628f4104 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:09:14 +0000 Subject: [PATCH 4/6] Rewrite auth policy tests using realistic Pipeline with MockTransport Replace MagicMock-based policy isolation tests with full azure-core Pipeline/AsyncPipeline tests that use a real MockTransport (extending HttpTransport/AsyncHttpTransport). This approach: - Returns proper requests.Response objects with real header dicts, including x-ms-substatus for the 403/5300 trigger - Records all outgoing requests so tests can assert on the exact Authorization header value (type=aad&ver=1.0&sig=) - Verifies both the Cosmos AAD header format and token freshness end-to-end through the full policy pipeline, matching the pattern used in azure-core's own authentication tests Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/a67aa68c-a3b6-42ef-824f-aa45415226d6 Co-authored-by: bambriz <8497145+bambriz@users.noreply.github.com> --- .../tests/test_auth_policy_unit.py | 262 +++++++++++------- .../tests/test_auth_policy_unit_async.py | 262 +++++++++++------- 2 files changed, 324 insertions(+), 200 deletions(-) diff --git a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py index 4fe1d848c0f1..933589d8c746 100644 --- a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py +++ b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py @@ -1,157 +1,219 @@ # The MIT License (MIT) # Copyright (c) Microsoft Corporation. All rights reserved. -"""Unit tests for CosmosBearerTokenCredentialPolicy 403/AAD token refresh behavior.""" +"""Unit tests for CosmosBearerTokenCredentialPolicy 403/AAD token refresh behavior. + +Uses a realistic azure-core Pipeline with a mock transport that returns proper +requests.Response objects (including the x-ms-substatus header), and verifies +that the Authorization header is correctly set in the requests that reach the transport. +""" import time import unittest -from unittest.mock import MagicMock +from unittest.mock import Mock + +from requests import Response from azure.core.credentials import AccessToken -from azure.core.pipeline import PipelineRequest, PipelineResponse, PipelineContext -from azure.core.rest import HttpRequest +from azure.core.pipeline import Pipeline +from azure.core.pipeline.transport import HttpTransport, HttpRequest from azure.cosmos._auth_policy import CosmosBearerTokenCredentialPolicy from azure.cosmos.http_constants import HttpHeaders, SubStatusCodes +COSMOS_ACCOUNT_URL = "https://example.cosmos.azure.com" +ACCOUNT_SCOPE = "https://cosmos.azure.com/.default" +AAD_AUTH_PREFIX = "type=aad&ver=1.0&sig=" -def _make_request(): - http_request = HttpRequest("GET", "https://example.cosmos.azure.com/dbs") - context = PipelineContext(None) - return PipelineRequest(http_request, context) - -def _make_response(request, status_code, sub_status=None): - http_response = MagicMock() - http_response.status_code = status_code - headers = {} +def _make_response(status_code, sub_status=None): + """Create a requests.Response with optional x-ms-substatus header.""" + response = Response() + response.status_code = status_code if sub_status is not None: - headers[HttpHeaders.SubStatus] = str(sub_status) - http_response.headers = headers - return PipelineResponse(request.http_request, http_response, request.context) + response.headers[HttpHeaders.SubStatus] = str(sub_status) + return response def _make_credential(token_str="fake-token"): - credential = MagicMock() + """Create a sync credential mock that returns an AccessToken via get_token.""" + credential = Mock(spec_set=["get_token"]) credential.get_token.return_value = AccessToken(token_str, int(time.time()) + 3600) return credential -class TestCosmosBearerTokenPolicySend(unittest.TestCase): +class MockTransport(HttpTransport): + """Minimal sync HTTP transport that replays a sequence of canned responses and + records each outgoing request so tests can inspect its headers.""" - def _build_policy_with_mock_next(self, credential, first_response, second_response=None): - """Create a policy with a mock `next` that returns given responses sequentially.""" - policy = CosmosBearerTokenCredentialPolicy(credential, "https://cosmos.azure.com/.default") + def __init__(self, *responses): + self._responses = list(responses) + self.requests = [] - call_count = [0] + def open(self): + pass - def mock_send(req): - call_count[0] += 1 - if call_count[0] == 1: - return first_response - return second_response + def close(self): + pass + + def __exit__(self, *args): + pass + + def __enter__(self): + return self + + def send(self, request, **kwargs): + self.requests.append(request) + return self._responses.pop(0) + + +class TestCosmosBearerTokenPolicySend(unittest.TestCase): + + def _run(self, credential, *responses): + """Build a Pipeline with the Cosmos bearer policy and run a GET against it. - mock_next = MagicMock() - mock_next.send.side_effect = mock_send - policy.next = mock_next - policy._call_count = call_count - return policy + Returns (pipeline_response, transport) so callers can inspect both the + final response and the recorded outgoing requests. + """ + transport = MockTransport(*responses) + policy = CosmosBearerTokenCredentialPolicy(credential, ACCOUNT_SCOPE) + pipeline = Pipeline(transport=transport, policies=[policy]) + http_response = pipeline.run(HttpRequest("GET", f"{COSMOS_ACCOUNT_URL}/dbs")) + return http_response, transport + + # ------------------------------------------------------------------ + # Pass-through cases — no retry expected + # ------------------------------------------------------------------ def test_200_response_passes_through(self): - """A 200 response is returned without any retry.""" + """A 200 response is forwarded to the caller with no retry.""" credential = _make_credential() - request = _make_request() - response_200 = _make_response(request, 200) - - policy = self._build_policy_with_mock_next(credential, response_200) - result = policy.send(request) + _, transport = self._run(credential, _make_response(200)) - assert result.http_response.status_code == 200 - assert policy.next.send.call_count == 1 + assert transport.requests[0].headers["Authorization"].startswith(AAD_AUTH_PREFIX) + assert len(transport.requests) == 1 def test_403_without_substatus_no_retry(self): - """A 403 with no sub-status does not trigger a retry (not AAD expiry).""" + """A 403 with no sub-status is not an AAD expiry — no retry should occur.""" credential = _make_credential() - request = _make_request() - response_403 = _make_response(request, 403) - - policy = self._build_policy_with_mock_next(credential, response_403) - result = policy.send(request) + result, transport = self._run(credential, _make_response(403)) assert result.http_response.status_code == 403 - assert policy.next.send.call_count == 1 + assert len(transport.requests) == 1 - def test_403_with_other_substatus_no_retry(self): - """A 403 with a non-AAD sub-status does not trigger a retry.""" + def test_403_write_forbidden_no_retry(self): + """403/WRITE_FORBIDDEN is a different error — no AAD-triggered retry.""" credential = _make_credential() - request = _make_request() - response_403 = _make_response(request, 403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) - - policy = self._build_policy_with_mock_next(credential, response_403) - result = policy.send(request) + result, transport = self._run( + credential, _make_response(403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) + ) assert result.http_response.status_code == 403 - assert policy.next.send.call_count == 1 + assert len(transport.requests) == 1 - def test_403_aad_not_authorized_clears_token_and_retries(self): - """A 403 with sub-status AAD_REQUEST_NOT_AUTHORIZED clears the token and retries.""" - credential = _make_credential("token-v1") - request = _make_request() - response_403 = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) - response_200 = _make_response(request, 200) + # ------------------------------------------------------------------ + # 403 / AAD_REQUEST_NOT_AUTHORIZED — retry expected + # ------------------------------------------------------------------ - policy = self._build_policy_with_mock_next(credential, response_403, response_200) - # Pre-populate the token so we can confirm it gets cleared - policy._token = AccessToken("old-expired-token", int(time.time()) - 100) + def test_403_aad_expired_retries_and_succeeds(self): + """403/AAD_REQUEST_NOT_AUTHORIZED triggers a token refresh and one retry. - result = policy.send(request) + The retry must succeed with the fresh token, and both the initial request + and the retry must carry a properly-formatted Cosmos AAD Authorization header. + """ + credential = _make_credential("fresh-token") + result, transport = self._run( + credential, + _make_response(403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED), + _make_response(200), + ) assert result.http_response.status_code == 200 - # next.send should have been called twice: initial + retry - assert policy.next.send.call_count == 2 - - def test_403_aad_not_authorized_token_cleared_before_retry(self): - """After 403/5300, the cached token is refreshed so a new token is used on retry.""" - credential = _make_credential("brand-new-token") + assert len(transport.requests) == 2 - token_states = [] - request = _make_request() - response_403 = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) - response_200 = _make_response(request, 200) + # Both requests must carry the Cosmos-specific AAD header format + for req in transport.requests: + assert req.headers["Authorization"].startswith(AAD_AUTH_PREFIX), ( + f"Expected Cosmos AAD header format, got: {req.headers.get('Authorization')}" + ) - policy = self._build_policy_with_mock_next(credential, response_403, response_200) - # Set an expired token initially - policy._token = AccessToken("expired-token", int(time.time()) - 10) + def test_403_aad_expired_sends_fresh_token_on_retry(self): + """The retry request must use a freshly-acquired token, not the expired one. - # Capture token state on each call to next.send - original_send = policy.next.send.side_effect + We give the credential two different tokens: the first simulates an expired + cached token; the second is the fresh one returned after the cache is cleared. + """ + fresh_token = "brand-new-token" + expired_token = "old-expired-token" - def capturing_send(req): - token_states.append(policy._token) - return original_send(req) - - policy.next.send.side_effect = capturing_send - - policy.send(request) + call_count = [0] + tokens = [expired_token, fresh_token] - # On the retry (second call), token should have been refreshed (not the expired one) - assert len(token_states) == 2 - assert token_states[1] is not None - assert token_states[1].token != "expired-token" + credential = Mock(spec_set=["get_token"]) - def test_403_aad_retry_still_fails_returns_response(self): - """If the retry also fails with non-AAD 403, the second response is returned unchanged.""" + def rotating_get_token(*scopes, **kwargs): + token = tokens[min(call_count[0], len(tokens) - 1)] + call_count[0] += 1 + return AccessToken(token, int(time.time()) + 3600) + + credential.get_token.side_effect = rotating_get_token + + transport = MockTransport( + _make_response(403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED), + _make_response(200), + ) + policy = CosmosBearerTokenCredentialPolicy(credential, ACCOUNT_SCOPE) + pipeline = Pipeline(transport=transport, policies=[policy]) + pipeline.run(HttpRequest("GET", f"{COSMOS_ACCOUNT_URL}/dbs")) + + assert len(transport.requests) == 2 + retry_auth = transport.requests[1].headers["Authorization"] + assert fresh_token in retry_auth, ( + f"Expected fresh token '{fresh_token}' in retry Authorization header, got: {retry_auth}" + ) + + def test_403_aad_expired_auth_header_cleared_before_retry(self): + """After 403/5300 the policy clears its cached token so the retry gets a new one. + + We force the token cache to contain an expired-looking token and verify + that the Authorization header on the retry differs from the initial request. + """ + credential = _make_credential("fresh-token-after-expiry") + transport = MockTransport( + _make_response(403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED), + _make_response(200), + ) + policy = CosmosBearerTokenCredentialPolicy(credential, ACCOUNT_SCOPE) + # Inject a "stale" token into the policy cache to simulate an expired token + policy._token = AccessToken("stale-token", int(time.time()) - 60) + + pipeline = Pipeline(transport=transport, policies=[policy]) + pipeline.run(HttpRequest("GET", f"{COSMOS_ACCOUNT_URL}/dbs")) + + assert len(transport.requests) == 2 + initial_auth = transport.requests[0].headers["Authorization"] + retry_auth = transport.requests[1].headers["Authorization"] + # The stale token must not appear in the retry request + assert "stale-token" not in retry_auth, ( + "Stale token should have been replaced before retry" + ) + # Both headers must still use the Cosmos-specific format + assert initial_auth.startswith(AAD_AUTH_PREFIX) + assert retry_auth.startswith(AAD_AUTH_PREFIX) + + def test_403_aad_retry_still_fails_returns_second_response(self): + """If the retry also returns a non-retriable 403, that response is returned unchanged.""" credential = _make_credential() - request = _make_request() - response_403_aad = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) - response_403_other = _make_response(request, 403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) - - policy = self._build_policy_with_mock_next(credential, response_403_aad, response_403_other) - result = policy.send(request) + result, transport = self._run( + credential, + _make_response(403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED), + _make_response(403, sub_status=SubStatusCodes.WRITE_FORBIDDEN), + ) assert result.http_response.status_code == 403 - assert policy.next.send.call_count == 2 + assert len(transport.requests) == 2 if __name__ == "__main__": unittest.main() + diff --git a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py index 136aadc935be..1916d04493c1 100644 --- a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py +++ b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py @@ -1,157 +1,219 @@ # The MIT License (MIT) # Copyright (c) Microsoft Corporation. All rights reserved. -"""Async unit tests for AsyncCosmosBearerTokenCredentialPolicy 403/AAD token refresh behavior.""" +"""Async unit tests for AsyncCosmosBearerTokenCredentialPolicy 403/AAD token refresh behavior. + +Uses a realistic azure-core AsyncPipeline with an async mock transport that returns proper +requests.Response objects (including the x-ms-substatus header), and verifies that the +Authorization header is correctly set in the requests that reach the transport. +""" import time import unittest -from unittest.mock import MagicMock, AsyncMock +from unittest.mock import Mock, AsyncMock + +from requests import Response from azure.core.credentials import AccessToken -from azure.core.pipeline import PipelineRequest, PipelineResponse, PipelineContext -from azure.core.rest import HttpRequest +from azure.core.pipeline import AsyncPipeline +from azure.core.pipeline.transport import AsyncHttpTransport, HttpRequest from azure.cosmos.aio._auth_policy_async import AsyncCosmosBearerTokenCredentialPolicy from azure.cosmos.http_constants import HttpHeaders, SubStatusCodes +COSMOS_ACCOUNT_URL = "https://example.cosmos.azure.com" +ACCOUNT_SCOPE = "https://cosmos.azure.com/.default" +AAD_AUTH_PREFIX = "type=aad&ver=1.0&sig=" -def _make_request(): - http_request = HttpRequest("GET", "https://example.cosmos.azure.com/dbs") - context = PipelineContext(None) - return PipelineRequest(http_request, context) - -def _make_response(request, status_code, sub_status=None): - http_response = MagicMock() - http_response.status_code = status_code - headers = {} +def _make_response(status_code, sub_status=None): + """Create a requests.Response with optional x-ms-substatus header.""" + response = Response() + response.status_code = status_code if sub_status is not None: - headers[HttpHeaders.SubStatus] = str(sub_status) - http_response.headers = headers - return PipelineResponse(request.http_request, http_response, request.context) + response.headers[HttpHeaders.SubStatus] = str(sub_status) + return response def _make_async_credential(token_str="fake-token"): - credential = MagicMock() + """Create an async credential mock that returns an AccessToken via get_token.""" + credential = Mock(spec_set=["get_token"]) credential.get_token = AsyncMock(return_value=AccessToken(token_str, int(time.time()) + 3600)) return credential -class TestAsyncCosmosBearerTokenPolicySend(unittest.IsolatedAsyncioTestCase): +class MockAsyncTransport(AsyncHttpTransport): + """Minimal async HTTP transport that replays a sequence of canned responses and + records each outgoing request so tests can inspect its headers.""" - def _build_policy_with_mock_next(self, credential, first_response, second_response=None): - """Create a policy with a mock `next` that returns given responses sequentially.""" - policy = AsyncCosmosBearerTokenCredentialPolicy(credential, "https://cosmos.azure.com/.default") + def __init__(self, *responses): + self._responses = list(responses) + self.requests = [] - call_count = [0] + async def open(self): + pass - async def mock_send(req): - call_count[0] += 1 - if call_count[0] == 1: - return first_response - return second_response + async def close(self): + pass + + async def __aexit__(self, *args): + pass + + async def __aenter__(self): + return self + + async def send(self, request, **kwargs): + self.requests.append(request) + return self._responses.pop(0) + + +class TestAsyncCosmosBearerTokenPolicySend(unittest.IsolatedAsyncioTestCase): + + async def _run(self, credential, *responses): + """Build an AsyncPipeline with the Cosmos bearer policy and run a GET against it. - mock_next = MagicMock() - mock_next.send = AsyncMock(side_effect=mock_send) - policy.next = mock_next - policy._call_count = call_count - return policy + Returns (pipeline_response, transport) so callers can inspect both the + final response and the recorded outgoing requests. + """ + transport = MockAsyncTransport(*responses) + policy = AsyncCosmosBearerTokenCredentialPolicy(credential, ACCOUNT_SCOPE) + pipeline = AsyncPipeline(transport=transport, policies=[policy]) + http_response = await pipeline.run(HttpRequest("GET", f"{COSMOS_ACCOUNT_URL}/dbs")) + return http_response, transport + + # ------------------------------------------------------------------ + # Pass-through cases — no retry expected + # ------------------------------------------------------------------ async def test_200_response_passes_through(self): - """A 200 response is returned without any retry.""" + """A 200 response is forwarded to the caller with no retry.""" credential = _make_async_credential() - request = _make_request() - response_200 = _make_response(request, 200) - - policy = self._build_policy_with_mock_next(credential, response_200) - result = await policy.send(request) + _, transport = await self._run(credential, _make_response(200)) - assert result.http_response.status_code == 200 - assert policy.next.send.call_count == 1 + assert transport.requests[0].headers["Authorization"].startswith(AAD_AUTH_PREFIX) + assert len(transport.requests) == 1 async def test_403_without_substatus_no_retry(self): - """A 403 with no sub-status does not trigger a retry (not AAD expiry).""" + """A 403 with no sub-status is not an AAD expiry — no retry should occur.""" credential = _make_async_credential() - request = _make_request() - response_403 = _make_response(request, 403) - - policy = self._build_policy_with_mock_next(credential, response_403) - result = await policy.send(request) + result, transport = await self._run(credential, _make_response(403)) assert result.http_response.status_code == 403 - assert policy.next.send.call_count == 1 + assert len(transport.requests) == 1 - async def test_403_with_other_substatus_no_retry(self): - """A 403 with a non-AAD sub-status does not trigger a retry.""" + async def test_403_write_forbidden_no_retry(self): + """403/WRITE_FORBIDDEN is a different error — no AAD-triggered retry.""" credential = _make_async_credential() - request = _make_request() - response_403 = _make_response(request, 403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) - - policy = self._build_policy_with_mock_next(credential, response_403) - result = await policy.send(request) + result, transport = await self._run( + credential, _make_response(403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) + ) assert result.http_response.status_code == 403 - assert policy.next.send.call_count == 1 + assert len(transport.requests) == 1 - async def test_403_aad_not_authorized_clears_token_and_retries(self): - """A 403 with sub-status AAD_REQUEST_NOT_AUTHORIZED clears the token and retries.""" - credential = _make_async_credential("token-v1") - request = _make_request() - response_403 = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) - response_200 = _make_response(request, 200) + # ------------------------------------------------------------------ + # 403 / AAD_REQUEST_NOT_AUTHORIZED — retry expected + # ------------------------------------------------------------------ - policy = self._build_policy_with_mock_next(credential, response_403, response_200) - # Pre-populate the token so we can confirm it gets cleared - policy._token = AccessToken("old-expired-token", int(time.time()) - 100) + async def test_403_aad_expired_retries_and_succeeds(self): + """403/AAD_REQUEST_NOT_AUTHORIZED triggers a token refresh and one retry. - result = await policy.send(request) + The retry must succeed with the fresh token, and both the initial request + and the retry must carry a properly-formatted Cosmos AAD Authorization header. + """ + credential = _make_async_credential("fresh-token") + result, transport = await self._run( + credential, + _make_response(403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED), + _make_response(200), + ) assert result.http_response.status_code == 200 - # next.send should have been called twice: initial + retry - assert policy.next.send.call_count == 2 - - async def test_403_aad_not_authorized_token_cleared_before_retry(self): - """After 403/5300, the cached token is refreshed so a new token is used on retry.""" - credential = _make_async_credential("brand-new-token") + assert len(transport.requests) == 2 - token_states = [] - request = _make_request() - response_403 = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) - response_200 = _make_response(request, 200) + # Both requests must carry the Cosmos-specific AAD header format + for req in transport.requests: + assert req.headers["Authorization"].startswith(AAD_AUTH_PREFIX), ( + f"Expected Cosmos AAD header format, got: {req.headers.get('Authorization')}" + ) - policy = self._build_policy_with_mock_next(credential, response_403, response_200) - # Set an expired token initially - policy._token = AccessToken("expired-token", int(time.time()) - 10) + async def test_403_aad_expired_sends_fresh_token_on_retry(self): + """The retry request must use a freshly-acquired token, not the expired one. - # Capture token state on each call to next.send - original_send = policy.next.send.side_effect + We give the credential two different tokens: the first simulates an expired + cached token; the second is the fresh one returned after the cache is cleared. + """ + fresh_token = "brand-new-token" + expired_token = "old-expired-token" - async def capturing_send(req): - token_states.append(policy._token) - return await original_send(req) - - policy.next.send.side_effect = capturing_send - - await policy.send(request) + call_count = [0] + tokens = [expired_token, fresh_token] - # On the retry (second call), token should have been refreshed (not the expired one) - assert len(token_states) == 2 - assert token_states[1] is not None - assert token_states[1].token != "expired-token" + credential = Mock(spec_set=["get_token"]) - async def test_403_aad_retry_still_fails_returns_response(self): - """If the retry also fails with non-AAD 403, the second response is returned unchanged.""" + async def rotating_get_token(*scopes, **kwargs): + token = tokens[min(call_count[0], len(tokens) - 1)] + call_count[0] += 1 + return AccessToken(token, int(time.time()) + 3600) + + credential.get_token = rotating_get_token + + transport = MockAsyncTransport( + _make_response(403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED), + _make_response(200), + ) + policy = AsyncCosmosBearerTokenCredentialPolicy(credential, ACCOUNT_SCOPE) + pipeline = AsyncPipeline(transport=transport, policies=[policy]) + await pipeline.run(HttpRequest("GET", f"{COSMOS_ACCOUNT_URL}/dbs")) + + assert len(transport.requests) == 2 + retry_auth = transport.requests[1].headers["Authorization"] + assert fresh_token in retry_auth, ( + f"Expected fresh token '{fresh_token}' in retry Authorization header, got: {retry_auth}" + ) + + async def test_403_aad_expired_auth_header_cleared_before_retry(self): + """After 403/5300 the policy clears its cached token so the retry gets a new one. + + We force the token cache to contain an expired-looking token and verify + that the Authorization header on the retry differs from the initial request. + """ + credential = _make_async_credential("fresh-token-after-expiry") + transport = MockAsyncTransport( + _make_response(403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED), + _make_response(200), + ) + policy = AsyncCosmosBearerTokenCredentialPolicy(credential, ACCOUNT_SCOPE) + # Inject a "stale" token into the policy cache to simulate an expired token + policy._token = AccessToken("stale-token", int(time.time()) - 60) + + pipeline = AsyncPipeline(transport=transport, policies=[policy]) + await pipeline.run(HttpRequest("GET", f"{COSMOS_ACCOUNT_URL}/dbs")) + + assert len(transport.requests) == 2 + initial_auth = transport.requests[0].headers["Authorization"] + retry_auth = transport.requests[1].headers["Authorization"] + # The stale token must not appear in the retry request + assert "stale-token" not in retry_auth, ( + "Stale token should have been replaced before retry" + ) + # Both headers must still use the Cosmos-specific format + assert initial_auth.startswith(AAD_AUTH_PREFIX) + assert retry_auth.startswith(AAD_AUTH_PREFIX) + + async def test_403_aad_retry_still_fails_returns_second_response(self): + """If the retry also returns a non-retriable 403, that response is returned unchanged.""" credential = _make_async_credential() - request = _make_request() - response_403_aad = _make_response(request, 403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED) - response_403_other = _make_response(request, 403, sub_status=SubStatusCodes.WRITE_FORBIDDEN) - - policy = self._build_policy_with_mock_next(credential, response_403_aad, response_403_other) - result = await policy.send(request) + result, transport = await self._run( + credential, + _make_response(403, sub_status=SubStatusCodes.AAD_REQUEST_NOT_AUTHORIZED), + _make_response(403, sub_status=SubStatusCodes.WRITE_FORBIDDEN), + ) assert result.http_response.status_code == 403 - assert policy.next.send.call_count == 2 + assert len(transport.requests) == 2 if __name__ == "__main__": unittest.main() + From a095d2c65f4d30131fe3682b1e4a3eae19c39415 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:42:38 +0000 Subject: [PATCH 5/6] Fix spelling: retriable -> retryable in test docstrings Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/6a43abe4-336d-48bb-9181-24523f9dc925 Co-authored-by: bambriz <8497145+bambriz@users.noreply.github.com> --- sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py | 2 +- sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py index 933589d8c746..288a778975b1 100644 --- a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py +++ b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit.py @@ -202,7 +202,7 @@ def test_403_aad_expired_auth_header_cleared_before_retry(self): assert retry_auth.startswith(AAD_AUTH_PREFIX) def test_403_aad_retry_still_fails_returns_second_response(self): - """If the retry also returns a non-retriable 403, that response is returned unchanged.""" + """If the retry also returns a non-retryable 403, that response is returned unchanged.""" credential = _make_credential() result, transport = self._run( credential, diff --git a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py index 1916d04493c1..ba6e594c63c4 100644 --- a/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py +++ b/sdk/cosmos/azure-cosmos/tests/test_auth_policy_unit_async.py @@ -202,7 +202,7 @@ async def test_403_aad_expired_auth_header_cleared_before_retry(self): assert retry_auth.startswith(AAD_AUTH_PREFIX) async def test_403_aad_retry_still_fails_returns_second_response(self): - """If the retry also returns a non-retriable 403, that response is returned unchanged.""" + """If the retry also returns a non-retryable 403, that response is returned unchanged.""" credential = _make_async_credential() result, transport = await self._run( credential, From f2daec1ef7d1a4c22f5ee079e6589cd4f850fd36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:23:38 +0000 Subject: [PATCH 6/6] Update CHANGELOG with bug fix for HTTP 403/5300 AAD token refresh Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/50ccc6e6-b671-434d-97cc-9469276b13da Co-authored-by: bambriz <8497145+bambriz@users.noreply.github.com> --- sdk/cosmos/azure-cosmos/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/cosmos/azure-cosmos/CHANGELOG.md b/sdk/cosmos/azure-cosmos/CHANGELOG.md index f08e9b526bbc..c62a5e98a241 100644 --- a/sdk/cosmos/azure-cosmos/CHANGELOG.md +++ b/sdk/cosmos/azure-cosmos/CHANGELOG.md @@ -7,6 +7,7 @@ #### Breaking Changes #### Bugs Fixed +* Fixed bug where HTTP 403 responses with sub-status 5300 (AAD_REQUEST_NOT_AUTHORIZED) did not trigger a token refresh and retry, causing AAD-authenticated requests to fail permanently after token expiry instead of recovering transparently. See [PR 46167](https://github.com/Azure/azure-sdk-for-python/pull/46167) #### Other Changes