From 4193da003f3fddc1ad608bbea16aad9f8e4b70b9 Mon Sep 17 00:00:00 2001 From: Alessandra Romero Date: Tue, 11 Nov 2025 16:42:51 -0500 Subject: [PATCH 1/5] Add functional tests for StandardRetryStrategy and StandardRetryQuota --- .../smithy-core/tests/functional/__init__.py | 2 + .../tests/functional/test_retries.py | 184 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 packages/smithy-core/tests/functional/__init__.py create mode 100644 packages/smithy-core/tests/functional/test_retries.py diff --git a/packages/smithy-core/tests/functional/__init__.py b/packages/smithy-core/tests/functional/__init__.py new file mode 100644 index 000000000..04f8b7b76 --- /dev/null +++ b/packages/smithy-core/tests/functional/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-core/tests/functional/test_retries.py b/packages/smithy-core/tests/functional/test_retries.py new file mode 100644 index 000000000..b69e66396 --- /dev/null +++ b/packages/smithy-core/tests/functional/test_retries.py @@ -0,0 +1,184 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from smithy_core.exceptions import CallError, RetryError +from smithy_core.retries import StandardRetryQuota, StandardRetryStrategy + + +def get_retry_quota(strategy: StandardRetryStrategy) -> int: + return strategy._retry_quota.available_capacity # pyright: ignore[reportPrivateUsage] + + +async def test_standard_retry_eventually_succeeds() -> None: + strategy = StandardRetryStrategy(max_attempts=3) + error = CallError(is_retry_safe=True) + + token = await strategy.acquire_initial_retry_token() + assert token.retry_count == 0 + assert get_retry_quota(strategy) == 500 + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert token.retry_count == 1 + assert get_retry_quota(strategy) == 495 + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert token.retry_count == 2 + assert get_retry_quota(strategy) == 490 + + await strategy.record_success(token=token) + assert get_retry_quota(strategy) == 495 + + +async def test_standard_retry_fails_due_to_max_attempts() -> None: + strategy = StandardRetryStrategy(max_attempts=3) + error = CallError(is_retry_safe=True) + + token = await strategy.acquire_initial_retry_token() + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert token.retry_count == 1 + assert get_retry_quota(strategy) == 495 + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert token.retry_count == 2 + assert get_retry_quota(strategy) == 490 + + with pytest.raises(RetryError, match="maximum number of allowed attempts"): + await strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert get_retry_quota(strategy) == 490 + + +async def test_retry_quota_exhausted_after_single_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 5, raising=False) + strategy = StandardRetryStrategy(max_attempts=3) + error = CallError(is_retry_safe=True) + + token = await strategy.acquire_initial_retry_token() + assert get_retry_quota(strategy) == 5 + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert token.retry_count == 1 + assert get_retry_quota(strategy) == 0 + + with pytest.raises(RetryError, match="Retry quota exceeded"): + await strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert get_retry_quota(strategy) == 0 + + +async def test_retry_quota_prevents_retries_when_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 0, raising=False) + strategy = StandardRetryStrategy(max_attempts=3) + error = CallError(is_retry_safe=True) + + token = await strategy.acquire_initial_retry_token() + assert get_retry_quota(strategy) == 0 + + with pytest.raises(RetryError, match="Retry quota exceeded"): + await strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert get_retry_quota(strategy) == 0 + + +async def test_retry_quota_stops_retries_when_exhauste( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 10, raising=False) + strategy = StandardRetryStrategy(max_attempts=5) + error = CallError(is_retry_safe=True) + + token = await strategy.acquire_initial_retry_token() + assert get_retry_quota(strategy) == 10 + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert get_retry_quota(strategy) == 5 + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert get_retry_quota(strategy) == 0 + + with pytest.raises(RetryError, match="Retry quota exceeded"): + await strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert get_retry_quota(strategy) == 0 + + +async def test_retry_quota_recovers_after_successful_responses( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 15, raising=False) + strategy = StandardRetryStrategy(max_attempts=5) + error = CallError(is_retry_safe=True) + + # First operation: 2 retries then success + token = await strategy.acquire_initial_retry_token() + assert get_retry_quota(strategy) == 15 + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert get_retry_quota(strategy) == 10 + + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert get_retry_quota(strategy) == 5 + + await strategy.record_success(token=token) + assert get_retry_quota(strategy) == 10 + + # Second operation: 1 retry then success + token = await strategy.acquire_initial_retry_token() + token = await strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + assert get_retry_quota(strategy) == 5 + await strategy.record_success(token=token) + assert get_retry_quota(strategy) == 10 + + +async def test_retry_quota_shared_correctly_across_multiple_operations() -> None: + strategy = StandardRetryStrategy(max_attempts=5) + error = CallError(is_retry_safe=True) + + # Operation 1 + op1_token = await strategy.acquire_initial_retry_token() + assert get_retry_quota(strategy) == 500 + + op1_token = await strategy.refresh_retry_token_for_retry( + token_to_renew=op1_token, error=error + ) + assert get_retry_quota(strategy) == 495 + + op1_token = await strategy.refresh_retry_token_for_retry( + token_to_renew=op1_token, error=error + ) + assert get_retry_quota(strategy) == 490 + + # Operation 2 (while operation 1 is in progress) + op2_token = await strategy.acquire_initial_retry_token() + op2_token = await strategy.refresh_retry_token_for_retry( + token_to_renew=op2_token, error=error + ) + assert get_retry_quota(strategy) == 485 + + await strategy.record_success(token=op2_token) + assert get_retry_quota(strategy) == 490 + + await strategy.record_success(token=op1_token) + assert get_retry_quota(strategy) == 495 From 5fb7e152bb24a9080a1653c943af9180dfc1366f Mon Sep 17 00:00:00 2001 From: Alessandra Romero Date: Thu, 13 Nov 2025 17:02:52 -0500 Subject: [PATCH 2/5] Remove access to private attributes --- .../tests/functional/test_retries.py | 187 ++++++++---------- 1 file changed, 79 insertions(+), 108 deletions(-) diff --git a/packages/smithy-core/tests/functional/test_retries.py b/packages/smithy-core/tests/functional/test_retries.py index b69e66396..2b407e89f 100644 --- a/packages/smithy-core/tests/functional/test_retries.py +++ b/packages/smithy-core/tests/functional/test_retries.py @@ -6,179 +6,150 @@ from smithy_core.retries import StandardRetryQuota, StandardRetryStrategy -def get_retry_quota(strategy: StandardRetryStrategy) -> int: - return strategy._retry_quota.available_capacity # pyright: ignore[reportPrivateUsage] - - -async def test_standard_retry_eventually_succeeds() -> None: - strategy = StandardRetryStrategy(max_attempts=3) +def test_standard_retry_eventually_succeeds() -> None: + retry_quota = StandardRetryQuota() + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) error = CallError(is_retry_safe=True) - token = await strategy.acquire_initial_retry_token() + token = strategy.acquire_initial_retry_token() assert token.retry_count == 0 - assert get_retry_quota(strategy) == 500 + assert retry_quota.available_capacity == 500 - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) assert token.retry_count == 1 - assert get_retry_quota(strategy) == 495 + assert retry_quota.available_capacity == 495 - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) assert token.retry_count == 2 - assert get_retry_quota(strategy) == 490 + assert retry_quota.available_capacity == 490 - await strategy.record_success(token=token) - assert get_retry_quota(strategy) == 495 + strategy.record_success(token=token) + assert retry_quota.available_capacity == 495 -async def test_standard_retry_fails_due_to_max_attempts() -> None: - strategy = StandardRetryStrategy(max_attempts=3) +def test_standard_retry_fails_due_to_max_attempts() -> None: + retry_quota = StandardRetryQuota() + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) error = CallError(is_retry_safe=True) - token = await strategy.acquire_initial_retry_token() + token = strategy.acquire_initial_retry_token() - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) assert token.retry_count == 1 - assert get_retry_quota(strategy) == 495 + assert retry_quota.available_capacity == 495 - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) assert token.retry_count == 2 - assert get_retry_quota(strategy) == 490 + assert retry_quota.available_capacity == 490 with pytest.raises(RetryError, match="maximum number of allowed attempts"): - await strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert get_retry_quota(strategy) == 490 + strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 490 -async def test_retry_quota_exhausted_after_single_retry( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 5, raising=False) - strategy = StandardRetryStrategy(max_attempts=3) +def test_retry_quota_exhausted_after_single_retry() -> None: + retry_quota = StandardRetryQuota(initial_capacity=5) + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) error = CallError(is_retry_safe=True) - token = await strategy.acquire_initial_retry_token() - assert get_retry_quota(strategy) == 5 + token = strategy.acquire_initial_retry_token() + assert retry_quota.available_capacity == 5 - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) assert token.retry_count == 1 - assert get_retry_quota(strategy) == 0 + assert retry_quota.available_capacity == 0 with pytest.raises(RetryError, match="Retry quota exceeded"): - await strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert get_retry_quota(strategy) == 0 + strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 0 -async def test_retry_quota_prevents_retries_when_zero( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 0, raising=False) - strategy = StandardRetryStrategy(max_attempts=3) +def test_retry_quota_prevents_retries_when_zero() -> None: + retry_quota = StandardRetryQuota(initial_capacity=0) + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) error = CallError(is_retry_safe=True) - token = await strategy.acquire_initial_retry_token() - assert get_retry_quota(strategy) == 0 + token = strategy.acquire_initial_retry_token() + assert retry_quota.available_capacity == 0 with pytest.raises(RetryError, match="Retry quota exceeded"): - await strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert get_retry_quota(strategy) == 0 + strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 0 -async def test_retry_quota_stops_retries_when_exhauste( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 10, raising=False) - strategy = StandardRetryStrategy(max_attempts=5) +def test_retry_quota_stops_retries_when_exhausted() -> None: + retry_quota = StandardRetryQuota(initial_capacity=10) + strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) error = CallError(is_retry_safe=True) - token = await strategy.acquire_initial_retry_token() - assert get_retry_quota(strategy) == 10 + token = strategy.acquire_initial_retry_token() + assert retry_quota.available_capacity == 10 - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) - assert get_retry_quota(strategy) == 5 + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 5 - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) - assert get_retry_quota(strategy) == 0 + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 0 with pytest.raises(RetryError, match="Retry quota exceeded"): - await strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert get_retry_quota(strategy) == 0 + strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 0 -async def test_retry_quota_recovers_after_successful_responses( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 15, raising=False) - strategy = StandardRetryStrategy(max_attempts=5) +def test_retry_quota_recovers_after_successful_responses() -> None: + retry_quota = StandardRetryQuota(initial_capacity=15) + strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) error = CallError(is_retry_safe=True) # First operation: 2 retries then success - token = await strategy.acquire_initial_retry_token() - assert get_retry_quota(strategy) == 15 + token = strategy.acquire_initial_retry_token() + assert retry_quota.available_capacity == 15 - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) - assert get_retry_quota(strategy) == 10 + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 10 - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) - assert get_retry_quota(strategy) == 5 + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 5 - await strategy.record_success(token=token) - assert get_retry_quota(strategy) == 10 + strategy.record_success(token=token) + assert retry_quota.available_capacity == 10 # Second operation: 1 retry then success - token = await strategy.acquire_initial_retry_token() - token = await strategy.refresh_retry_token_for_retry( - token_to_renew=token, error=error - ) - assert get_retry_quota(strategy) == 5 - await strategy.record_success(token=token) - assert get_retry_quota(strategy) == 10 + token = strategy.acquire_initial_retry_token() + token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) + assert retry_quota.available_capacity == 5 + strategy.record_success(token=token) + assert retry_quota.available_capacity == 10 async def test_retry_quota_shared_correctly_across_multiple_operations() -> None: - strategy = StandardRetryStrategy(max_attempts=5) + retry_quota = StandardRetryQuota() + strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) error = CallError(is_retry_safe=True) # Operation 1 - op1_token = await strategy.acquire_initial_retry_token() - assert get_retry_quota(strategy) == 500 + op1_token = strategy.acquire_initial_retry_token() + assert retry_quota.available_capacity == 500 - op1_token = await strategy.refresh_retry_token_for_retry( + op1_token = strategy.refresh_retry_token_for_retry( token_to_renew=op1_token, error=error ) - assert get_retry_quota(strategy) == 495 + assert retry_quota.available_capacity == 495 - op1_token = await strategy.refresh_retry_token_for_retry( + op1_token = strategy.refresh_retry_token_for_retry( token_to_renew=op1_token, error=error ) - assert get_retry_quota(strategy) == 490 + assert retry_quota.available_capacity == 490 # Operation 2 (while operation 1 is in progress) - op2_token = await strategy.acquire_initial_retry_token() - op2_token = await strategy.refresh_retry_token_for_retry( + op2_token = strategy.acquire_initial_retry_token() + op2_token = strategy.refresh_retry_token_for_retry( token_to_renew=op2_token, error=error ) - assert get_retry_quota(strategy) == 485 + assert retry_quota.available_capacity == 485 - await strategy.record_success(token=op2_token) - assert get_retry_quota(strategy) == 490 + strategy.record_success(token=op2_token) + assert retry_quota.available_capacity == 490 - await strategy.record_success(token=op1_token) - assert get_retry_quota(strategy) == 495 + strategy.record_success(token=op1_token) + assert retry_quota.available_capacity == 495 From c0858d23fe3ffac195d5bf3f1300a6440b26d8d7 Mon Sep 17 00:00:00 2001 From: Alessandra Romero Date: Wed, 19 Nov 2025 13:43:57 -0500 Subject: [PATCH 3/5] Remove async from test --- packages/smithy-core/tests/functional/test_retries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smithy-core/tests/functional/test_retries.py b/packages/smithy-core/tests/functional/test_retries.py index 2b407e89f..4b8b5e2fc 100644 --- a/packages/smithy-core/tests/functional/test_retries.py +++ b/packages/smithy-core/tests/functional/test_retries.py @@ -122,7 +122,7 @@ def test_retry_quota_recovers_after_successful_responses() -> None: assert retry_quota.available_capacity == 10 -async def test_retry_quota_shared_correctly_across_multiple_operations() -> None: +def test_retry_quota_shared_correctly_across_multiple_operations() -> None: retry_quota = StandardRetryQuota() strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) error = CallError(is_retry_safe=True) From 063ed288821c21b05c23368a1b4f75dabc091f35 Mon Sep 17 00:00:00 2001 From: Alessandra Romero Date: Wed, 19 Nov 2025 16:39:39 -0500 Subject: [PATCH 4/5] Refactor functional retry tests to match production retry flow --- .../tests/functional/test_retries.py | 202 ++++++++---------- 1 file changed, 90 insertions(+), 112 deletions(-) diff --git a/packages/smithy-core/tests/functional/test_retries.py b/packages/smithy-core/tests/functional/test_retries.py index 4b8b5e2fc..c07d1641d 100644 --- a/packages/smithy-core/tests/functional/test_retries.py +++ b/packages/smithy-core/tests/functional/test_retries.py @@ -1,155 +1,133 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +from asyncio import gather, sleep + import pytest from smithy_core.exceptions import CallError, RetryError -from smithy_core.retries import StandardRetryQuota, StandardRetryStrategy - - -def test_standard_retry_eventually_succeeds() -> None: - retry_quota = StandardRetryQuota() - strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) - error = CallError(is_retry_safe=True) - +from smithy_core.interfaces import retries as retries_interface +from smithy_core.retries import ( + ExponentialBackoffJitterType, + ExponentialRetryBackoffStrategy, + StandardRetryQuota, + StandardRetryStrategy, +) + + +async def retry_operation( + strategy: retries_interface.RetryStrategy, + status_codes: list[int], +) -> tuple[str, int]: token = strategy.acquire_initial_retry_token() - assert token.retry_count == 0 - assert retry_quota.available_capacity == 500 + responses = iter(status_codes) - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert token.retry_count == 1 - assert retry_quota.available_capacity == 495 + while True: + if token.retry_delay: + await sleep(token.retry_delay) - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert token.retry_count == 2 - assert retry_quota.available_capacity == 490 + status_code = next(responses) + attempt = token.retry_count + 1 - strategy.record_success(token=token) - assert retry_quota.available_capacity == 495 + if status_code == 200: + strategy.record_success(token=token) + return "success", attempt + error = CallError( + fault="server" if status_code >= 500 else "client", + message=f"HTTP {status_code}", + is_retry_safe=status_code >= 500, + ) -def test_standard_retry_fails_due_to_max_attempts() -> None: - retry_quota = StandardRetryQuota() - strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) - error = CallError(is_retry_safe=True) + try: + token = strategy.refresh_retry_token_for_retry( + token_to_renew=token, error=error + ) + except RetryError: + raise error - token = strategy.acquire_initial_retry_token() - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert token.retry_count == 1 - assert retry_quota.available_capacity == 495 +async def test_standard_retry_eventually_succeeds(): + quota = StandardRetryQuota(initial_capacity=500) + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert token.retry_count == 2 - assert retry_quota.available_capacity == 490 + result, attempts = await retry_operation(strategy, [500, 500, 200]) - with pytest.raises(RetryError, match="maximum number of allowed attempts"): - strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 490 + assert result == "success" + assert attempts == 3 + assert quota.available_capacity == 495 -def test_retry_quota_exhausted_after_single_retry() -> None: - retry_quota = StandardRetryQuota(initial_capacity=5) - strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) - error = CallError(is_retry_safe=True) +async def test_standard_retry_fails_due_to_max_attempts(): + quota = StandardRetryQuota(initial_capacity=500) + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) - token = strategy.acquire_initial_retry_token() - assert retry_quota.available_capacity == 5 - - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert token.retry_count == 1 - assert retry_quota.available_capacity == 0 + with pytest.raises(CallError, match="502"): + await retry_operation(strategy, [502, 502, 502]) - with pytest.raises(RetryError, match="Retry quota exceeded"): - strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 0 + assert quota.available_capacity == 490 -def test_retry_quota_prevents_retries_when_zero() -> None: - retry_quota = StandardRetryQuota(initial_capacity=0) - strategy = StandardRetryStrategy(max_attempts=3, retry_quota=retry_quota) - error = CallError(is_retry_safe=True) - - token = strategy.acquire_initial_retry_token() - assert retry_quota.available_capacity == 0 +async def test_retry_quota_exhausted_after_single_retry(): + quota = StandardRetryQuota(initial_capacity=5) + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) - with pytest.raises(RetryError, match="Retry quota exceeded"): - strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 0 + with pytest.raises(CallError, match="502"): + await retry_operation(strategy, [500, 502]) + assert quota.available_capacity == 0 -def test_retry_quota_stops_retries_when_exhausted() -> None: - retry_quota = StandardRetryQuota(initial_capacity=10) - strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) - error = CallError(is_retry_safe=True) - token = strategy.acquire_initial_retry_token() - assert retry_quota.available_capacity == 10 +async def test_retry_quota_prevents_retries_when_quota_zero(): + quota = StandardRetryQuota(initial_capacity=0) + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 5 + with pytest.raises(CallError, match="500"): + await retry_operation(strategy, [500]) - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 0 + assert quota.available_capacity == 0 - with pytest.raises(RetryError, match="Retry quota exceeded"): - strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 0 +async def test_retry_quota_stops_retries_when_exhausted(): + quota = StandardRetryQuota(initial_capacity=10) + strategy = StandardRetryStrategy(max_attempts=5, retry_quota=quota) -def test_retry_quota_recovers_after_successful_responses() -> None: - retry_quota = StandardRetryQuota(initial_capacity=15) - strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) - error = CallError(is_retry_safe=True) + with pytest.raises(CallError, match="503"): + await retry_operation(strategy, [500, 502, 503]) - # First operation: 2 retries then success - token = strategy.acquire_initial_retry_token() - assert retry_quota.available_capacity == 15 + assert quota.available_capacity == 0 - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 10 - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 5 +async def test_retry_quota_recovers_after_successful_responses(): + quota = StandardRetryQuota(initial_capacity=15) + strategy = StandardRetryStrategy(max_attempts=5, retry_quota=quota) - strategy.record_success(token=token) - assert retry_quota.available_capacity == 10 + # First operation: 2 retries then success + await retry_operation(strategy, [500, 502, 200]) + assert quota.available_capacity == 10 # Second operation: 1 retry then success - token = strategy.acquire_initial_retry_token() - token = strategy.refresh_retry_token_for_retry(token_to_renew=token, error=error) - assert retry_quota.available_capacity == 5 - strategy.record_success(token=token) - assert retry_quota.available_capacity == 10 + await retry_operation(strategy, [500, 200]) + assert quota.available_capacity == 10 -def test_retry_quota_shared_correctly_across_multiple_operations() -> None: - retry_quota = StandardRetryQuota() - strategy = StandardRetryStrategy(max_attempts=5, retry_quota=retry_quota) - error = CallError(is_retry_safe=True) - - # Operation 1 - op1_token = strategy.acquire_initial_retry_token() - assert retry_quota.available_capacity == 500 - - op1_token = strategy.refresh_retry_token_for_retry( - token_to_renew=op1_token, error=error +async def test_retry_quota_shared_across_concurrent_operations(): + quota = StandardRetryQuota(initial_capacity=500) + backoff = ExponentialRetryBackoffStrategy( + backoff_scale_value=1, + max_backoff=10, + jitter_type=ExponentialBackoffJitterType.FULL, ) - assert retry_quota.available_capacity == 495 - - op1_token = strategy.refresh_retry_token_for_retry( - token_to_renew=op1_token, error=error + strategy = StandardRetryStrategy( + max_attempts=5, + retry_quota=quota, + backoff_strategy=backoff, ) - assert retry_quota.available_capacity == 490 - # Operation 2 (while operation 1 is in progress) - op2_token = strategy.acquire_initial_retry_token() - op2_token = strategy.refresh_retry_token_for_retry( - token_to_renew=op2_token, error=error + result1, result2 = await gather( + retry_operation(strategy, [500, 500, 200]), + retry_operation(strategy, [500, 200]), ) - assert retry_quota.available_capacity == 485 - - strategy.record_success(token=op2_token) - assert retry_quota.available_capacity == 490 - strategy.record_success(token=op1_token) - assert retry_quota.available_capacity == 495 + assert result1 == ("success", 3) + assert result2 == ("success", 2) + assert quota.available_capacity == 495 From 7afac12c821d6d5d8c857a3cc4c556e99b30597b Mon Sep 17 00:00:00 2001 From: Alessandra Romero Date: Tue, 25 Nov 2025 16:26:03 -0500 Subject: [PATCH 5/5] Add TODO and timeout errors test --- .../tests/functional/test_retries.py | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/smithy-core/tests/functional/test_retries.py b/packages/smithy-core/tests/functional/test_retries.py index c07d1641d..9a72b491b 100644 --- a/packages/smithy-core/tests/functional/test_retries.py +++ b/packages/smithy-core/tests/functional/test_retries.py @@ -4,7 +4,7 @@ from asyncio import gather, sleep import pytest -from smithy_core.exceptions import CallError, RetryError +from smithy_core.exceptions import CallError, ClientTimeoutError, RetryError from smithy_core.interfaces import retries as retries_interface from smithy_core.retries import ( ExponentialBackoffJitterType, @@ -14,29 +14,35 @@ ) +# TODO: Refactor this to use a smithy-testing generated client async def retry_operation( strategy: retries_interface.RetryStrategy, - status_codes: list[int], + responses: list[int | Exception], ) -> tuple[str, int]: token = strategy.acquire_initial_retry_token() - responses = iter(status_codes) + response_iter = iter(responses) while True: if token.retry_delay: await sleep(token.retry_delay) - status_code = next(responses) + response = next(response_iter) attempt = token.retry_count + 1 - if status_code == 200: + # Success case + if response == 200: strategy.record_success(token=token) return "success", attempt - error = CallError( - fault="server" if status_code >= 500 else "client", - message=f"HTTP {status_code}", - is_retry_safe=status_code >= 500, - ) + # Error case - either status code or exception + if isinstance(response, Exception): + error = response + else: + error = CallError( + fault="server" if response >= 500 else "client", + message=f"HTTP {response}", + is_retry_safe=response >= 500, + ) try: token = strategy.refresh_retry_token_for_retry( @@ -131,3 +137,17 @@ async def test_retry_quota_shared_across_concurrent_operations(): assert result1 == ("success", 3) assert result2 == ("success", 2) assert quota.available_capacity == 495 + + +async def test_retry_quota_handles_timeout_errors(): + quota = StandardRetryQuota(initial_capacity=500) + strategy = StandardRetryStrategy(max_attempts=3, retry_quota=quota) + + timeout1 = ClientTimeoutError() + timeout2 = ClientTimeoutError() + + result, attempts = await retry_operation(strategy, [timeout1, timeout2, 200]) + + assert result == "success" + assert attempts == 3 + assert quota.available_capacity == 490