From ad9074d4110e222c3a97578257e0d609c7a98761 Mon Sep 17 00:00:00 2001 From: SamRemis Date: Tue, 18 Nov 2025 22:17:11 -0500 Subject: [PATCH 1/4] Add retry mode resolver functionality - Add CachingRetryStrategyResolver for per-client retry strategy caching - Add retry strategy configuration options - Update client generation to support retry mode resolution - Add comprehensive tests for retry functionality --- .../python/codegen/ClientGenerator.java | 26 +++++++- .../codegen/generators/ConfigGenerator.java | 25 ++++---- .../src/smithy_core/interfaces/retries.py | 1 + .../smithy-core/src/smithy_core/retries.py | 43 +++++++++++++ .../smithy-core/tests/unit/test_retries.py | 61 +++++++++++++++++++ 5 files changed, 144 insertions(+), 12 deletions(-) diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java index 1cac8962..95c6e0d5 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java @@ -83,6 +83,8 @@ private void generateService(PythonWriter writer) { } } + writer.addDependency(SmithyPythonDependency.SMITHY_CORE); + writer.addImport("smithy_core.retries", "RetryStrategyResolver"); writer.write(""" def __init__(self, config: $1T | None = None, plugins: list[$2T] | None = None): self._config = config or $1T() @@ -95,6 +97,8 @@ def __init__(self, config: $1T | None = None, plugins: list[$2T] | None = None): for plugin in client_plugins: plugin(self._config) + + self._retry_strategy_resolver = RetryStrategyResolver() """, configSymbol, pluginSymbol, writer.consumer(w -> writeDefaultPlugins(w, defaultPlugins))); var topDownIndex = TopDownIndex.of(model); @@ -187,6 +191,8 @@ private void writeSharedOperationInit(PythonWriter writer, OperationShape operat writer.addImport("smithy_core.types", "TypedProperties"); writer.addImport("smithy_core.aio.client", "RequestPipeline"); writer.addImport("smithy_core.exceptions", "ExpectationNotMetError"); + writer.addImport("smithy_core.retries", "RetryStrategyOptions"); + writer.addImport("smithy_core.interfaces.retries", "RetryStrategy"); writer.addStdlibImport("copy", "deepcopy"); writer.write(""" @@ -200,6 +206,24 @@ private void writeSharedOperationInit(PythonWriter writer, OperationShape operat plugin(config) if config.protocol is None or config.transport is None: raise ExpectationNotMetError("protocol and transport MUST be set on the config to make calls.") + + # Resolve retry strategy from config + if isinstance(config.retry_strategy, RetryStrategy): + retry_strategy = config.retry_strategy + elif isinstance(config.retry_strategy, RetryStrategyOptions): + retry_strategy = await self._retry_strategy_resolver.resolve_retry_strategy( + options=config.retry_strategy + ) + elif config.retry_strategy is None: + retry_strategy = await self._retry_strategy_resolver.resolve_retry_strategy( + options=RetryStrategyOptions() + ) + else: + raise TypeError( + f"retry_strategy must be RetryStrategy, RetryStrategyOptions, or None, " + f"got {type(config.retry_strategy).__name__}" + ) + pipeline = RequestPipeline( protocol=config.protocol, transport=config.transport @@ -212,7 +236,7 @@ raise ExpectationNotMetError("protocol and transport MUST be set on the config t auth_scheme_resolver=config.auth_scheme_resolver, supported_auth_schemes=config.auth_schemes, endpoint_resolver=config.endpoint_resolver, - retry_strategy=config.retry_strategy, + retry_strategy=retry_strategy, ) """, writer.consumer(w -> writeDefaultPlugins(w, defaultPlugins))); diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java index 45b6324d..b17847ba 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java @@ -55,17 +55,20 @@ public final class ConfigGenerator implements Runnable { ConfigProperty.builder() .name("retry_strategy") .type(Symbol.builder() - .name("RetryStrategy") - .namespace("smithy_core.interfaces.retries", ".") - .addDependency(SmithyPythonDependency.SMITHY_CORE) + .name("RetryStrategy | RetryStrategyOptions") + .addReference(Symbol.builder() + .name("RetryStrategy") + .namespace("smithy_core.interfaces.retries", ".") + .addDependency(SmithyPythonDependency.SMITHY_CORE) + .build()) + .addReference(Symbol.builder() + .name("RetryStrategyOptions") + .namespace("smithy_core.retries", ".") + .addDependency(SmithyPythonDependency.SMITHY_CORE) + .build()) .build()) - .documentation("The retry strategy for issuing retry tokens and computing retry delays.") - .nullable(false) - .initialize(writer -> { - writer.addDependency(SmithyPythonDependency.SMITHY_CORE); - writer.addImport("smithy_core.retries", "SimpleRetryStrategy"); - writer.write("self.retry_strategy = retry_strategy or SimpleRetryStrategy()"); - }) + .documentation( + "The retry strategy or options for configuring retry behavior. Can be either a configured RetryStrategy or RetryStrategyOptions to create one.") .build(), ConfigProperty.builder() .name("endpoint_uri") @@ -379,7 +382,7 @@ private void writeInitParams(PythonWriter writer, Collection pro } private void documentProperties(PythonWriter writer, Collection properties) { - writer.writeDocs(() ->{ + writer.writeDocs(() -> { var iter = properties.iterator(); writer.write("\nConstructor.\n"); while (iter.hasNext()) { diff --git a/packages/smithy-core/src/smithy_core/interfaces/retries.py b/packages/smithy-core/src/smithy_core/interfaces/retries.py index f100dca3..16d93543 100644 --- a/packages/smithy-core/src/smithy_core/interfaces/retries.py +++ b/packages/smithy-core/src/smithy_core/interfaces/retries.py @@ -52,6 +52,7 @@ class RetryToken(Protocol): """Delay in seconds to wait before the retry attempt.""" +@runtime_checkable class RetryStrategy(Protocol): """Issuer of :py:class:`RetryToken`s.""" diff --git a/packages/smithy-core/src/smithy_core/retries.py b/packages/smithy-core/src/smithy_core/retries.py index 0f017f6e..d91eb985 100644 --- a/packages/smithy-core/src/smithy_core/retries.py +++ b/packages/smithy-core/src/smithy_core/retries.py @@ -5,9 +5,52 @@ from collections.abc import Callable from dataclasses import dataclass from enum import Enum +from functools import lru_cache +from typing import Literal from .exceptions import RetryError from .interfaces import retries as retries_interface +from .interfaces.retries import RetryStrategy + +RetryStrategyType = Literal["simple"] + + +@dataclass(kw_only=True, frozen=True) +class RetryStrategyOptions: + """Options for configuring retry behavior.""" + + retry_mode: RetryStrategyType = "simple" + """The retry mode to use.""" + + max_attempts: int = 3 + """Maximum number of attempts (initial attempt plus retries).""" + + +class RetryStrategyResolver: + """Retry strategy resolver that caches retry strategies based on configuration options. + + This resolver caches retry strategy instances based on their configuration to reuse existing + instances of RetryStrategy with the same settings. Uses LRU cache for thread-safe caching. + """ + + async def resolve_retry_strategy( + self, *, options: RetryStrategyOptions + ) -> RetryStrategy: + """Resolve a retry strategy from the provided options, using cache when possible. + + :param options: The retry strategy options to use for creating the strategy. + """ + return self._create_retry_strategy(options.retry_mode, options.max_attempts) + + @lru_cache + def _create_retry_strategy( + self, retry_mode: RetryStrategyType, max_attempts: int + ) -> RetryStrategy: + match retry_mode: + case "simple": + return SimpleRetryStrategy(max_attempts=max_attempts) + case _: + raise ValueError(f"Unknown retry mode: {retry_mode}") class ExponentialBackoffJitterType(Enum): diff --git a/packages/smithy-core/tests/unit/test_retries.py b/packages/smithy-core/tests/unit/test_retries.py index f8abea72..858a0210 100644 --- a/packages/smithy-core/tests/unit/test_retries.py +++ b/packages/smithy-core/tests/unit/test_retries.py @@ -1,15 +1,19 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +import asyncio import pytest +from smithy_core.aio.client import CLIENT_ID from smithy_core.exceptions import CallError, RetryError from smithy_core.retries import ExponentialBackoffJitterType as EBJT from smithy_core.retries import ( + CachingRetryStrategyResolver, ExponentialRetryBackoffStrategy, SimpleRetryStrategy, StandardRetryQuota, StandardRetryStrategy, ) +from smithy_core.types import TypedProperties @pytest.mark.parametrize( @@ -208,3 +212,60 @@ def test_retry_quota_release_caps_at_max( # Release more than we acquired. Should cap at initial capacity. retry_quota.release(release_amount=50) assert retry_quota.available_capacity == 10 + + +@pytest.mark.asyncio +async def test_caching_retry_strategy_default_resolution() -> None: + resolver = CachingRetryStrategyResolver() + properties = TypedProperties({CLIENT_ID.key: "test-client-1"}) + + strategy = await resolver.resolve_retry_strategy(properties=properties) + + assert isinstance(strategy, SimpleRetryStrategy) + + +@pytest.mark.asyncio +async def test_caching_retry_strategy_resolver_caches_per_client() -> None: + resolver = CachingRetryStrategyResolver() + properties1 = TypedProperties({CLIENT_ID.key: "test-caller-1"}) + properties2 = TypedProperties({CLIENT_ID.key: "test-caller-2"}) + + strategy1a = await resolver.resolve_retry_strategy(properties=properties1) + strategy1b = await resolver.resolve_retry_strategy(properties=properties1) + strategy2 = await resolver.resolve_retry_strategy(properties=properties2) + + assert strategy1a is strategy1b + assert strategy1a is not strategy2 + + +@pytest.mark.asyncio +async def test_caching_retry_strategy_resolver_concurrent_access() -> None: + resolver = CachingRetryStrategyResolver() + properties = TypedProperties({CLIENT_ID.key: "test-caller-concurrent"}) + + strategies = await asyncio.gather( + resolver.resolve_retry_strategy(properties=properties), + resolver.resolve_retry_strategy(properties=properties), + resolver.resolve_retry_strategy(properties=properties), + ) + + assert strategies[0] is strategies[1] + assert strategies[1] is strategies[2] + + +@pytest.mark.asyncio +async def test_caching_retry_strategy_resolver_requires_client_id() -> None: + resolver = CachingRetryStrategyResolver() + properties = TypedProperties({}) + + with pytest.raises(ValueError, match=CLIENT_ID.key): + await resolver.resolve_retry_strategy(properties=properties) + + +def test_caching_retry_strategy_resolver_survives_deepcopy() -> None: + from copy import deepcopy + + resolver = CachingRetryStrategyResolver() + resolver_copy = deepcopy(resolver) + + assert resolver is resolver_copy From c7ae74c4bb39f0e3a5234170a1f405fc22b6ecd9 Mon Sep 17 00:00:00 2001 From: SamRemis Date: Tue, 18 Nov 2025 22:29:54 -0500 Subject: [PATCH 2/4] Fix test issue from bad merge --- .../smithy-core/tests/unit/test_retries.py | 58 +++++++------------ 1 file changed, 21 insertions(+), 37 deletions(-) diff --git a/packages/smithy-core/tests/unit/test_retries.py b/packages/smithy-core/tests/unit/test_retries.py index 858a0210..825b3764 100644 --- a/packages/smithy-core/tests/unit/test_retries.py +++ b/packages/smithy-core/tests/unit/test_retries.py @@ -3,17 +3,16 @@ import asyncio import pytest -from smithy_core.aio.client import CLIENT_ID from smithy_core.exceptions import CallError, RetryError from smithy_core.retries import ExponentialBackoffJitterType as EBJT from smithy_core.retries import ( - CachingRetryStrategyResolver, ExponentialRetryBackoffStrategy, + RetryStrategyOptions, + RetryStrategyResolver, SimpleRetryStrategy, StandardRetryQuota, StandardRetryStrategy, ) -from smithy_core.types import TypedProperties @pytest.mark.parametrize( @@ -214,58 +213,43 @@ def test_retry_quota_release_caps_at_max( assert retry_quota.available_capacity == 10 -@pytest.mark.asyncio async def test_caching_retry_strategy_default_resolution() -> None: - resolver = CachingRetryStrategyResolver() - properties = TypedProperties({CLIENT_ID.key: "test-client-1"}) + resolver = RetryStrategyResolver() + options = RetryStrategyOptions() - strategy = await resolver.resolve_retry_strategy(properties=properties) + strategy = await resolver.resolve_retry_strategy(options=options) assert isinstance(strategy, SimpleRetryStrategy) + assert strategy.max_attempts == 3 -@pytest.mark.asyncio -async def test_caching_retry_strategy_resolver_caches_per_client() -> None: - resolver = CachingRetryStrategyResolver() - properties1 = TypedProperties({CLIENT_ID.key: "test-caller-1"}) - properties2 = TypedProperties({CLIENT_ID.key: "test-caller-2"}) +async def test_caching_retry_strategy_resolver_creates_strategies_by_options() -> None: + resolver = RetryStrategyResolver() - strategy1a = await resolver.resolve_retry_strategy(properties=properties1) - strategy1b = await resolver.resolve_retry_strategy(properties=properties1) - strategy2 = await resolver.resolve_retry_strategy(properties=properties2) + options1 = RetryStrategyOptions(max_attempts=3) + options2 = RetryStrategyOptions(max_attempts=5) - assert strategy1a is strategy1b - assert strategy1a is not strategy2 + strategy1 = await resolver.resolve_retry_strategy(options=options1) + strategy2 = await resolver.resolve_retry_strategy(options=options2) + assert strategy1.max_attempts == 3 + assert strategy2.max_attempts == 5 -@pytest.mark.asyncio -async def test_caching_retry_strategy_resolver_concurrent_access() -> None: - resolver = CachingRetryStrategyResolver() - properties = TypedProperties({CLIENT_ID.key: "test-caller-concurrent"}) - - strategies = await asyncio.gather( - resolver.resolve_retry_strategy(properties=properties), - resolver.resolve_retry_strategy(properties=properties), - resolver.resolve_retry_strategy(properties=properties), - ) - - assert strategies[0] is strategies[1] - assert strategies[1] is strategies[2] +async def test_caching_retry_strategy_resolver_caches_strategies() -> None: + resolver = RetryStrategyResolver() -@pytest.mark.asyncio -async def test_caching_retry_strategy_resolver_requires_client_id() -> None: - resolver = CachingRetryStrategyResolver() - properties = TypedProperties({}) + options = RetryStrategyOptions(max_attempts=5) + strategy1 = await resolver.resolve_retry_strategy(options=options) + strategy2 = await resolver.resolve_retry_strategy(options=options) - with pytest.raises(ValueError, match=CLIENT_ID.key): - await resolver.resolve_retry_strategy(properties=properties) + assert strategy1 is strategy2 def test_caching_retry_strategy_resolver_survives_deepcopy() -> None: from copy import deepcopy - resolver = CachingRetryStrategyResolver() + resolver = RetryStrategyResolver() resolver_copy = deepcopy(resolver) assert resolver is resolver_copy From d643ab3fba1d50404ed6b6c6cbb9f9f5ff244417 Mon Sep 17 00:00:00 2001 From: SamRemis Date: Tue, 18 Nov 2025 22:31:34 -0500 Subject: [PATCH 3/4] Update test_retries.py --- packages/smithy-core/tests/unit/test_retries.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/smithy-core/tests/unit/test_retries.py b/packages/smithy-core/tests/unit/test_retries.py index 825b3764..b0b2fc49 100644 --- a/packages/smithy-core/tests/unit/test_retries.py +++ b/packages/smithy-core/tests/unit/test_retries.py @@ -244,12 +244,3 @@ async def test_caching_retry_strategy_resolver_caches_strategies() -> None: strategy2 = await resolver.resolve_retry_strategy(options=options) assert strategy1 is strategy2 - - -def test_caching_retry_strategy_resolver_survives_deepcopy() -> None: - from copy import deepcopy - - resolver = RetryStrategyResolver() - resolver_copy = deepcopy(resolver) - - assert resolver is resolver_copy From 92058132d24fa2affbe931262b262431ec4ca84d Mon Sep 17 00:00:00 2001 From: SamRemis Date: Tue, 18 Nov 2025 22:36:49 -0500 Subject: [PATCH 4/4] Update test_retries.py --- packages/smithy-core/tests/unit/test_retries.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/smithy-core/tests/unit/test_retries.py b/packages/smithy-core/tests/unit/test_retries.py index b0b2fc49..7571f412 100644 --- a/packages/smithy-core/tests/unit/test_retries.py +++ b/packages/smithy-core/tests/unit/test_retries.py @@ -1,7 +1,5 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -import asyncio - import pytest from smithy_core.exceptions import CallError, RetryError from smithy_core.retries import ExponentialBackoffJitterType as EBJT