From e05b180dcd9476b99b90296d6341782bb0d18b40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 20:54:04 +0000 Subject: [PATCH 1/2] Fix LRO poller to treat HTTP 404 and 406 as transient during transaction-status polling Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/6789a40b-e0da-4d5e-9cce-6cd42b9b4bd1 Co-authored-by: PallabPaul <20746357+PallabPaul@users.noreply.github.com> --- .../confidentialledger/_operations/_patch.py | 24 +- .../aio/_operations/_patch.py | 23 +- .../tests/test_polling.py | 383 ++++++++++++++++++ 3 files changed, 409 insertions(+), 21 deletions(-) create mode 100644 sdk/confidentialledger/azure-confidentialledger/tests/test_polling.py diff --git a/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py b/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py index 77951c223179..93cb45b66f76 100644 --- a/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py +++ b/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py @@ -11,7 +11,7 @@ import time from typing import Any, Callable, IO, List, Optional, Union, cast -from azure.core.exceptions import ResourceNotFoundError +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError from azure.core.polling import PollingMethod, LROPoller, NoPolling from azure.confidentialledger._operations._operations import ( @@ -55,7 +55,6 @@ def __init__( self._latest_response: JSON = {} self._retry_not_found = retry_not_found - self._not_found_count = 0 def initialize(self, client, initial_response, deserialization_callback): # pylint: disable=unused-argument self._evaluate_response(initial_response) @@ -102,15 +101,18 @@ def run(self) -> None: try: response = self._operation() self._evaluate_response(response) - except ResourceNotFoundError as not_found_exception: - # We'll allow some instances of resource not found to account for replication - # delay if session stickiness is lost. - - self._not_found_count += 1 - - not_retryable = not self._retry_not_found or self._give_up_not_found_error(not_found_exception) - - if not_retryable or self._not_found_count >= 3: + except HttpResponseError as http_exception: + # 404: node doesn't know about the transaction yet (replication lag) + # 406: node knows the transaction but it hasn't committed yet + # Both are transient during transaction-status polling — treat as + # pending so the poller retries. + if http_exception.status_code in (404, 406) and self._retry_not_found: + if ( + isinstance(http_exception, ResourceNotFoundError) + and self._give_up_not_found_error(http_exception) + ): + raise + else: raise if not self.finished(): time.sleep(self._polling_interval_s) diff --git a/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/aio/_operations/_patch.py b/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/aio/_operations/_patch.py index 1c934dcf36ec..5ce84379776e 100644 --- a/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/aio/_operations/_patch.py +++ b/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/aio/_operations/_patch.py @@ -12,7 +12,7 @@ import asyncio # pylint: disable=do-not-import-asyncio from typing import Any, Callable, IO, Coroutine, List, Optional, Union, cast -from azure.core.exceptions import ResourceNotFoundError +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError from azure.core.polling import AsyncLROPoller, AsyncNoPolling, AsyncPollingMethod from azure.confidentialledger.aio._operations._operations import ( @@ -56,15 +56,18 @@ async def run(self) -> None: try: response = await self._operation() self._evaluate_response(response) - except ResourceNotFoundError as not_found_exception: - # We'll allow some instances of resource not found to account for replication - # delay if session stickiness is lost. - - self._not_found_count += 1 - - not_retryable = not self._retry_not_found or self._give_up_not_found_error(not_found_exception) - - if not_retryable or self._not_found_count >= 3: + except HttpResponseError as http_exception: + # 404: node doesn't know about the transaction yet (replication lag) + # 406: node knows the transaction but it hasn't committed yet + # Both are transient during transaction-status polling — treat as + # pending so the poller retries. + if http_exception.status_code in (404, 406) and self._retry_not_found: + if ( + isinstance(http_exception, ResourceNotFoundError) + and self._give_up_not_found_error(http_exception) + ): + raise + else: raise if not self.finished(): await asyncio.sleep(self._polling_interval_s) diff --git a/sdk/confidentialledger/azure-confidentialledger/tests/test_polling.py b/sdk/confidentialledger/azure-confidentialledger/tests/test_polling.py new file mode 100644 index 000000000000..2788daf9a106 --- /dev/null +++ b/sdk/confidentialledger/azure-confidentialledger/tests/test_polling.py @@ -0,0 +1,383 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------- +"""Tests for the custom StatePollingMethod and AsyncStatePollingMethod classes, +covering transient 404 and 406 handling during transaction-status polling. +""" + +import pytest + +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError, ODataV4Format + +from azure.confidentialledger._operations._patch import StatePollingMethod +from azure.confidentialledger.aio._operations._patch import AsyncStatePollingMethod + + +def _make_resource_not_found(error_code=None): + """Create a ResourceNotFoundError (HTTP 404) with an optional error code.""" + error = ResourceNotFoundError(message="Not found") + error.status_code = 404 + if error_code: + error.error = ODataV4Format({"error": {"code": error_code, "message": ""}}) + return error + + +def _make_http_response_error(status_code, message="Error"): + """Create an HttpResponseError with the given status code.""" + error = HttpResponseError(message=message) + error.status_code = status_code + return error + + +# --------------------------------------------------------------------------- +# Sync StatePollingMethod tests +# --------------------------------------------------------------------------- + + +class TestStatePollingMethodTransient404: + """Verify that 404 responses during polling are treated as transient when + retry_not_found is True. + """ + + def test_404_retried_then_committed(self): + """404 should be retried and eventually succeed when the transaction commits.""" + call_count = 0 + + def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 3: + raise _make_resource_not_found() + return {"state": "Committed"} + + poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + poller.run() + + assert poller.status() == "finished" + assert call_count == 4 + + def test_404_raises_when_retry_not_found_false(self): + """404 should raise immediately when retry_not_found is False.""" + + def operation(): + raise _make_resource_not_found() + + poller = StatePollingMethod(operation, "Ready", 0, retry_not_found=False) + poller._status = "polling" + + with pytest.raises(ResourceNotFoundError): + poller.run() + + assert poller.status() == "failed" + + def test_404_with_invalid_transaction_id_raises(self): + """404 with InvalidTransactionId error code should raise even when + retry_not_found is True (permanent error, not transient). + """ + + def operation(): + raise _make_resource_not_found(error_code="InvalidTransactionId") + + poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + + with pytest.raises(ResourceNotFoundError): + poller.run() + + assert poller.status() == "failed" + + def test_many_404s_retried_without_limit(self): + """More than 3 consecutive 404s should still be retried (no hard limit).""" + call_count = 0 + + def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 10: + raise _make_resource_not_found() + return {"state": "Committed"} + + poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + poller.run() + + assert poller.status() == "finished" + assert call_count == 11 + + +class TestStatePollingMethodTransient406: + """Verify that 406 responses during polling are treated as transient when + retry_not_found is True. + """ + + def test_406_retried_then_committed(self): + """406 should be retried and eventually succeed when the transaction commits.""" + call_count = 0 + + def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 3: + raise _make_http_response_error(406, "Not Acceptable") + return {"state": "Committed"} + + poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + poller.run() + + assert poller.status() == "finished" + assert call_count == 4 + + def test_406_raises_when_retry_not_found_false(self): + """406 should raise immediately when retry_not_found is False.""" + + def operation(): + raise _make_http_response_error(406, "Not Acceptable") + + poller = StatePollingMethod(operation, "Ready", 0, retry_not_found=False) + poller._status = "polling" + + with pytest.raises(HttpResponseError): + poller.run() + + assert poller.status() == "failed" + + def test_many_406s_retried_without_limit(self): + """More than 3 consecutive 406s should still be retried (no hard limit).""" + call_count = 0 + + def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 10: + raise _make_http_response_error(406, "Not Acceptable") + return {"state": "Committed"} + + poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + poller.run() + + assert poller.status() == "finished" + assert call_count == 11 + + +class TestStatePollingMethodMixed: + """Verify mixed 404/406 sequences and non-transient errors.""" + + def test_mixed_404_and_406_retried(self): + """Alternating 404 and 406 should both be retried.""" + call_count = 0 + + def operation(): + nonlocal call_count + call_count += 1 + if call_count % 2 == 1 and call_count <= 4: + raise _make_resource_not_found() + if call_count % 2 == 0 and call_count <= 4: + raise _make_http_response_error(406, "Not Acceptable") + return {"state": "Committed"} + + poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + poller.run() + + assert poller.status() == "finished" + assert call_count == 5 + + def test_other_http_errors_raise_immediately(self): + """Non-404/406 HTTP errors should raise immediately even when + retry_not_found is True. + """ + + def operation(): + raise _make_http_response_error(500, "Internal Server Error") + + poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + + with pytest.raises(HttpResponseError): + poller.run() + + assert poller.status() == "failed" + + def test_pending_state_keeps_polling(self): + """Responses with 'state': 'Pending' should keep polling (baseline behavior).""" + call_count = 0 + + def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 2: + return {"state": "Pending"} + return {"state": "Committed"} + + poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + poller.run() + + assert poller.status() == "finished" + assert call_count == 3 + + +# --------------------------------------------------------------------------- +# Async AsyncStatePollingMethod tests +# --------------------------------------------------------------------------- + + +class TestAsyncStatePollingMethodTransient404: + """Verify that 404 responses during async polling are treated as transient.""" + + @pytest.mark.asyncio + async def test_404_retried_then_committed(self): + call_count = 0 + + async def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 3: + raise _make_resource_not_found() + return {"state": "Committed"} + + poller = AsyncStatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + await poller.run() + + assert poller.status() == "finished" + assert call_count == 4 + + @pytest.mark.asyncio + async def test_404_raises_when_retry_not_found_false(self): + async def operation(): + raise _make_resource_not_found() + + poller = AsyncStatePollingMethod(operation, "Ready", 0, retry_not_found=False) + poller._status = "polling" + + with pytest.raises(ResourceNotFoundError): + await poller.run() + + assert poller.status() == "failed" + + @pytest.mark.asyncio + async def test_404_with_invalid_transaction_id_raises(self): + async def operation(): + raise _make_resource_not_found(error_code="InvalidTransactionId") + + poller = AsyncStatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + + with pytest.raises(ResourceNotFoundError): + await poller.run() + + assert poller.status() == "failed" + + @pytest.mark.asyncio + async def test_many_404s_retried_without_limit(self): + call_count = 0 + + async def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 10: + raise _make_resource_not_found() + return {"state": "Committed"} + + poller = AsyncStatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + await poller.run() + + assert poller.status() == "finished" + assert call_count == 11 + + +class TestAsyncStatePollingMethodTransient406: + """Verify that 406 responses during async polling are treated as transient.""" + + @pytest.mark.asyncio + async def test_406_retried_then_committed(self): + call_count = 0 + + async def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 3: + raise _make_http_response_error(406, "Not Acceptable") + return {"state": "Committed"} + + poller = AsyncStatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + await poller.run() + + assert poller.status() == "finished" + assert call_count == 4 + + @pytest.mark.asyncio + async def test_406_raises_when_retry_not_found_false(self): + async def operation(): + raise _make_http_response_error(406, "Not Acceptable") + + poller = AsyncStatePollingMethod(operation, "Ready", 0, retry_not_found=False) + poller._status = "polling" + + with pytest.raises(HttpResponseError): + await poller.run() + + assert poller.status() == "failed" + + @pytest.mark.asyncio + async def test_many_406s_retried_without_limit(self): + call_count = 0 + + async def operation(): + nonlocal call_count + call_count += 1 + if call_count <= 10: + raise _make_http_response_error(406, "Not Acceptable") + return {"state": "Committed"} + + poller = AsyncStatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + await poller.run() + + assert poller.status() == "finished" + assert call_count == 11 + + +class TestAsyncStatePollingMethodMixed: + """Verify mixed 404/406 sequences and non-transient errors in async polling.""" + + @pytest.mark.asyncio + async def test_mixed_404_and_406_retried(self): + call_count = 0 + + async def operation(): + nonlocal call_count + call_count += 1 + if call_count % 2 == 1 and call_count <= 4: + raise _make_resource_not_found() + if call_count % 2 == 0 and call_count <= 4: + raise _make_http_response_error(406, "Not Acceptable") + return {"state": "Committed"} + + poller = AsyncStatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + await poller.run() + + assert poller.status() == "finished" + assert call_count == 5 + + @pytest.mark.asyncio + async def test_other_http_errors_raise_immediately(self): + async def operation(): + raise _make_http_response_error(500, "Internal Server Error") + + poller = AsyncStatePollingMethod(operation, "Committed", 0, retry_not_found=True) + poller._status = "polling" + + with pytest.raises(HttpResponseError): + await poller.run() + + assert poller.status() == "failed" From 304414acfb416b9514323d3413a8334f02e10fcb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:47:39 +0000 Subject: [PATCH 2/2] Restore original 404 retry logic (with counter) and add separate 406 handling Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/dbd04bba-1b0d-4c00-92c0-5ee3d41d6653 Co-authored-by: PallabPaul <20746357+PallabPaul@users.noreply.github.com> --- .../confidentialledger/_operations/_patch.py | 24 ++++++---- .../aio/_operations/_patch.py | 23 +++++---- .../tests/test_polling.py | 47 ++++++++----------- 3 files changed, 49 insertions(+), 45 deletions(-) diff --git a/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py b/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py index 93cb45b66f76..0c12e4ab5f5f 100644 --- a/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py +++ b/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/_operations/_patch.py @@ -55,6 +55,7 @@ def __init__( self._latest_response: JSON = {} self._retry_not_found = retry_not_found + self._not_found_count = 0 def initialize(self, client, initial_response, deserialization_callback): # pylint: disable=unused-argument self._evaluate_response(initial_response) @@ -101,17 +102,22 @@ def run(self) -> None: try: response = self._operation() self._evaluate_response(response) + except ResourceNotFoundError as not_found_exception: + # We'll allow some instances of resource not found to account for replication + # delay if session stickiness is lost. + + self._not_found_count += 1 + + not_retryable = not self._retry_not_found or self._give_up_not_found_error(not_found_exception) + + if not_retryable or self._not_found_count >= 3: + raise except HttpResponseError as http_exception: - # 404: node doesn't know about the transaction yet (replication lag) - # 406: node knows the transaction but it hasn't committed yet - # Both are transient during transaction-status polling — treat as + # 406: node knows the transaction but it hasn't committed yet. + # This is transient during transaction-status polling — treat as # pending so the poller retries. - if http_exception.status_code in (404, 406) and self._retry_not_found: - if ( - isinstance(http_exception, ResourceNotFoundError) - and self._give_up_not_found_error(http_exception) - ): - raise + if http_exception.status_code == 406 and self._retry_not_found: + pass # stay in polling loop else: raise if not self.finished(): diff --git a/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/aio/_operations/_patch.py b/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/aio/_operations/_patch.py index 5ce84379776e..f3b9a2051c20 100644 --- a/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/aio/_operations/_patch.py +++ b/sdk/confidentialledger/azure-confidentialledger/azure/confidentialledger/aio/_operations/_patch.py @@ -56,17 +56,22 @@ async def run(self) -> None: try: response = await self._operation() self._evaluate_response(response) + except ResourceNotFoundError as not_found_exception: + # We'll allow some instances of resource not found to account for replication + # delay if session stickiness is lost. + + self._not_found_count += 1 + + not_retryable = not self._retry_not_found or self._give_up_not_found_error(not_found_exception) + + if not_retryable or self._not_found_count >= 3: + raise except HttpResponseError as http_exception: - # 404: node doesn't know about the transaction yet (replication lag) - # 406: node knows the transaction but it hasn't committed yet - # Both are transient during transaction-status polling — treat as + # 406: node knows the transaction but it hasn't committed yet. + # This is transient during transaction-status polling — treat as # pending so the poller retries. - if http_exception.status_code in (404, 406) and self._retry_not_found: - if ( - isinstance(http_exception, ResourceNotFoundError) - and self._give_up_not_found_error(http_exception) - ): - raise + if http_exception.status_code == 406 and self._retry_not_found: + pass # stay in polling loop else: raise if not self.finished(): diff --git a/sdk/confidentialledger/azure-confidentialledger/tests/test_polling.py b/sdk/confidentialledger/azure-confidentialledger/tests/test_polling.py index 2788daf9a106..7489c5a0332b 100644 --- a/sdk/confidentialledger/azure-confidentialledger/tests/test_polling.py +++ b/sdk/confidentialledger/azure-confidentialledger/tests/test_polling.py @@ -38,17 +38,17 @@ def _make_http_response_error(status_code, message="Error"): class TestStatePollingMethodTransient404: """Verify that 404 responses during polling are treated as transient when - retry_not_found is True. + retry_not_found is True, up to 3 retries. """ def test_404_retried_then_committed(self): - """404 should be retried and eventually succeed when the transaction commits.""" + """404 should be retried (up to 3 times) and eventually succeed.""" call_count = 0 def operation(): nonlocal call_count call_count += 1 - if call_count <= 3: + if call_count <= 2: raise _make_resource_not_found() return {"state": "Committed"} @@ -57,7 +57,7 @@ def operation(): poller.run() assert poller.status() == "finished" - assert call_count == 4 + assert call_count == 3 def test_404_raises_when_retry_not_found_false(self): """404 should raise immediately when retry_not_found is False.""" @@ -89,23 +89,19 @@ def operation(): assert poller.status() == "failed" - def test_many_404s_retried_without_limit(self): - """More than 3 consecutive 404s should still be retried (no hard limit).""" - call_count = 0 + def test_404_raises_after_3_retries(self): + """After 3 consecutive 404s the poller should give up and raise.""" def operation(): - nonlocal call_count - call_count += 1 - if call_count <= 10: - raise _make_resource_not_found() - return {"state": "Committed"} + raise _make_resource_not_found() poller = StatePollingMethod(operation, "Committed", 0, retry_not_found=True) poller._status = "polling" - poller.run() - assert poller.status() == "finished" - assert call_count == 11 + with pytest.raises(ResourceNotFoundError): + poller.run() + + assert poller.status() == "failed" class TestStatePollingMethodTransient406: @@ -237,7 +233,7 @@ async def test_404_retried_then_committed(self): async def operation(): nonlocal call_count call_count += 1 - if call_count <= 3: + if call_count <= 2: raise _make_resource_not_found() return {"state": "Committed"} @@ -246,7 +242,7 @@ async def operation(): await poller.run() assert poller.status() == "finished" - assert call_count == 4 + assert call_count == 3 @pytest.mark.asyncio async def test_404_raises_when_retry_not_found_false(self): @@ -275,22 +271,19 @@ async def operation(): assert poller.status() == "failed" @pytest.mark.asyncio - async def test_many_404s_retried_without_limit(self): - call_count = 0 + async def test_404_raises_after_3_retries(self): + """After 3 consecutive 404s the async poller should give up and raise.""" async def operation(): - nonlocal call_count - call_count += 1 - if call_count <= 10: - raise _make_resource_not_found() - return {"state": "Committed"} + raise _make_resource_not_found() poller = AsyncStatePollingMethod(operation, "Committed", 0, retry_not_found=True) poller._status = "polling" - await poller.run() - assert poller.status() == "finished" - assert call_count == 11 + with pytest.raises(ResourceNotFoundError): + await poller.run() + + assert poller.status() == "failed" class TestAsyncStatePollingMethodTransient406: