From 72cc43c3826bd1c96f34866f165e8c735b29ac5f Mon Sep 17 00:00:00 2001 From: Aditya Ganti Date: Fri, 12 Jun 2026 21:29:04 -0400 Subject: [PATCH] feat: add linear retry strategy and fixed/linear presets --- .../retries.py | 53 ++++++++++++ .../tests/retries_test.py | 86 +++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/retries.py b/packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/retries.py index 8c60e54e..8a8b3db6 100644 --- a/packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/retries.py +++ b/packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/retries.py @@ -125,6 +125,34 @@ def retry_strategy(error: Exception, attempts_made: int) -> RetryDecision: return retry_strategy +def create_linear_retry_strategy( + max_attempts: int = 6, + initial_delay: Duration | None = None, + increment: Duration | None = None, +) -> Callable[[Exception, int], RetryDecision]: + """Linearly increasing delay between retries: initial + increment * (attempts_made - 1). + + Mirrors the JS SDK's ``createLinearRetryStrategy``. With the defaults this + yields delays of 1s, 2s, 3s, 4s, 5s. No jitter is applied and there is no + upper cap on the delay; callers who need either can build their own + strategy via ``create_retry_strategy``. + """ + initial: Duration = ( + initial_delay if initial_delay is not None else Duration.from_seconds(1) + ) + step: Duration = increment if increment is not None else Duration.from_seconds(1) + + def linear_retry_strategy(_error: Exception, attempts_made: int) -> RetryDecision: + if attempts_made >= max_attempts: + return RetryDecision.no_retry() + delay_seconds: int = initial.to_seconds() + step.to_seconds() * ( + attempts_made - 1 + ) + return RetryDecision.retry(Duration(seconds=delay_seconds)) + + return linear_retry_strategy + + class RetryPresets: """Default retry presets.""" @@ -180,6 +208,31 @@ def critical(cls) -> Callable[[Exception, int], RetryDecision]: ) ) + @classmethod + def linear(cls) -> Callable[[Exception, int], RetryDecision]: + """Linearly increasing delay between retries: 1s, 2s, 3s, 4s, 5s.""" + return create_linear_retry_strategy( + max_attempts=6, + initial_delay=Duration.from_seconds(1), + increment=Duration.from_seconds(1), + ) + + @classmethod + def fixed( + cls, interval: Duration | None = None + ) -> Callable[[Exception, int], RetryDecision]: + """Constant delay between retries (5s by default, no jitter).""" + delay: Duration = interval if interval is not None else Duration.from_seconds(5) + return create_retry_strategy( + RetryStrategyConfig( + max_attempts=5, + initial_delay=delay, + max_delay=delay, + backoff_rate=1, + jitter_strategy=JitterStrategy.NONE, + ) + ) + @dataclass(frozen=True) class WithRetryConfig(Generic[T]): diff --git a/packages/aws-durable-execution-sdk-python/tests/retries_test.py b/packages/aws-durable-execution-sdk-python/tests/retries_test.py index 1b581347..30b3115e 100644 --- a/packages/aws-durable-execution-sdk-python/tests/retries_test.py +++ b/packages/aws-durable-execution-sdk-python/tests/retries_test.py @@ -11,6 +11,7 @@ RetryDecision, RetryPresets, RetryStrategyConfig, + create_linear_retry_strategy, create_retry_strategy, ) @@ -574,3 +575,88 @@ def test_mixed_error_types_and_patterns(): # endregion + + +# region create_linear_retry_strategy + + +def test_linear_retry_strategy_uses_additive_formula(): + """Default config yields delays of 1s, 2s, 3s, 4s, 5s with no jitter.""" + strategy = create_linear_retry_strategy() + + delays = [ + strategy(Exception("e"), attempt).delay_seconds for attempt in range(1, 6) + ] + + assert delays == [1, 2, 3, 4, 5] + + +def test_linear_retry_strategy_stops_at_max_attempts(): + """No retry once attempts_made reaches max_attempts.""" + strategy = create_linear_retry_strategy(max_attempts=3) + + assert strategy(Exception("e"), 1).should_retry is True + assert strategy(Exception("e"), 2).should_retry is True + assert strategy(Exception("e"), 3).should_retry is False + + +def test_linear_retry_strategy_respects_custom_initial_and_increment(): + """Custom initial_delay and increment shift the additive sequence.""" + strategy = create_linear_retry_strategy( + max_attempts=10, + initial_delay=Duration.from_seconds(2), + increment=Duration.from_seconds(3), + ) + + delays = [ + strategy(Exception("e"), attempt).delay_seconds for attempt in range(1, 5) + ] + + # 2 + 3*0, 2 + 3*1, 2 + 3*2, 2 + 3*3 + assert delays == [2, 5, 8, 11] + + +# endregion + + +# region RetryPresets.linear / RetryPresets.fixed + + +def test_retry_presets_linear_matches_js_defaults(): + """RetryPresets.linear() yields 1s, 2s, 3s, 4s, 5s and stops after 6 attempts.""" + strategy = RetryPresets.linear() + + delays = [ + strategy(Exception("e"), attempt).delay_seconds for attempt in range(1, 6) + ] + assert delays == [1, 2, 3, 4, 5] + assert strategy(Exception("e"), 6).should_retry is False + + +def test_retry_presets_fixed_uses_default_interval(): + """RetryPresets.fixed() defaults to a 5s constant delay with no jitter.""" + strategy = RetryPresets.fixed() + + for attempt in range(1, 5): + decision = strategy(Exception("e"), attempt) + assert decision.should_retry is True + assert decision.delay_seconds == 5 + + +def test_retry_presets_fixed_respects_custom_interval(): + """A caller-supplied interval is used as the constant delay.""" + strategy = RetryPresets.fixed(interval=Duration.from_seconds(12)) + + assert strategy(Exception("e"), 1).delay_seconds == 12 + assert strategy(Exception("e"), 3).delay_seconds == 12 + + +def test_retry_presets_fixed_stops_at_max_attempts(): + """RetryPresets.fixed() stops retrying after 5 attempts.""" + strategy = RetryPresets.fixed() + + assert strategy(Exception("e"), 4).should_retry is True + assert strategy(Exception("e"), 5).should_retry is False + + +# endregion