From 79f707b6a100a76576fb2716fe93e4d56025f073 Mon Sep 17 00:00:00 2001 From: Aditya Audi Date: Thu, 2 Apr 2026 12:37:24 -0400 Subject: [PATCH] feat: expand RetryPresets with fixed, linear, and slow strategies - Add BackoffStrategy enum (EXPONENTIAL, LINEAR, FIXED) to config.py - Add backoff_strategy field to RetryStrategyConfig (defaults to EXPONENTIAL) - Delegate delay calculation to BackoffStrategy.calculate_base_delay() - Add three new presets: fixed_wait, linear_backoff, slow - Add comprehensive tests for new backoff strategies and presets Resolves #328 --- .../config.py | 49 ++++ .../retries.py | 56 +++- tests/retries_test.py | 266 +++++++++++++++++- 3 files changed, 365 insertions(+), 6 deletions(-) diff --git a/src/aws_durable_execution_sdk_python/config.py b/src/aws_durable_execution_sdk_python/config.py index 548b6c1..fdece2c 100644 --- a/src/aws_durable_execution_sdk_python/config.py +++ b/src/aws_durable_execution_sdk_python/config.py @@ -453,6 +453,55 @@ def result(self, timeout_seconds: int | None = None) -> T: return self.future.result(timeout=timeout_seconds) +# region Backoff + + +class BackoffStrategy(StrEnum): + """ + Backoff strategies determine how retry delay grows between attempts. + + members: + :EXPONENTIAL: Delay grows exponentially: initial_delay * backoff_rate ^ (attempts - 1) + :LINEAR: Delay grows linearly: initial_delay * attempts_made + :FIXED: Delay stays constant: initial_delay + """ + + EXPONENTIAL = "EXPONENTIAL" + LINEAR = "LINEAR" + FIXED = "FIXED" + + def calculate_base_delay( + self, + initial_delay_seconds: int, + backoff_rate: Numeric, + attempts_made: int, + max_delay_seconds: int, + ) -> float: + """Calculate base delay before jitter for the given attempt. + + Args: + initial_delay_seconds: The initial delay in seconds. + backoff_rate: The rate at which delay grows (used by EXPONENTIAL). + attempts_made: Number of attempts already made (1-based). + max_delay_seconds: Maximum delay cap in seconds. + + Returns: + The base delay in seconds, capped at max_delay_seconds. + """ + match self: + case BackoffStrategy.FIXED: + base: float = float(initial_delay_seconds) + case BackoffStrategy.LINEAR: + base = float(initial_delay_seconds) * attempts_made + case _: # default is EXPONENTIAL + base = initial_delay_seconds * (backoff_rate ** (attempts_made - 1)) + + return min(base, max_delay_seconds) + + +# endregion Backoff + + # region Jitter diff --git a/src/aws_durable_execution_sdk_python/retries.py b/src/aws_durable_execution_sdk_python/retries.py index 5a09db2..bd78e78 100644 --- a/src/aws_durable_execution_sdk_python/retries.py +++ b/src/aws_durable_execution_sdk_python/retries.py @@ -7,7 +7,11 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING -from aws_durable_execution_sdk_python.config import Duration, JitterStrategy +from aws_durable_execution_sdk_python.config import ( + BackoffStrategy, + Duration, + JitterStrategy, +) if TYPE_CHECKING: from collections.abc import Callable @@ -49,6 +53,7 @@ class RetryStrategyConfig: default_factory=lambda: Duration.from_minutes(5) ) # 5 minutes backoff_rate: Numeric = 2.0 + backoff_strategy: BackoffStrategy = field(default=BackoffStrategy.EXPONENTIAL) jitter_strategy: JitterStrategy = field(default=JitterStrategy.FULL) retryable_errors: list[str | re.Pattern] | None = None retryable_error_types: list[type[Exception]] | None = None @@ -103,10 +108,12 @@ def retry_strategy(error: Exception, attempts_made: int) -> RetryDecision: if not is_retryable_error_message and not is_retryable_error_type: return RetryDecision.no_retry() - # Calculate delay with exponential backoff - base_delay: float = min( - config.initial_delay_seconds * (config.backoff_rate ** (attempts_made - 1)), - config.max_delay_seconds, + # Calculate delay using configured backoff strategy + base_delay: float = config.backoff_strategy.calculate_base_delay( + initial_delay_seconds=config.initial_delay_seconds, + backoff_rate=config.backoff_rate, + attempts_made=attempts_made, + max_delay_seconds=config.max_delay_seconds, ) # Apply jitter to get final delay delay_with_jitter: float = config.jitter_strategy.apply_jitter(base_delay) @@ -172,3 +179,42 @@ def critical(cls) -> Callable[[Exception, int], RetryDecision]: jitter_strategy=JitterStrategy.NONE, ) ) + + @classmethod + def fixed_wait(cls) -> Callable[[Exception, int], RetryDecision]: + """Constant delay between retries with no backoff.""" + return create_retry_strategy( + RetryStrategyConfig( + max_attempts=5, + initial_delay=Duration.from_seconds(5), + max_delay=Duration.from_minutes(5), + backoff_strategy=BackoffStrategy.FIXED, + jitter_strategy=JitterStrategy.NONE, + ) + ) + + @classmethod + def linear_backoff(cls) -> Callable[[Exception, int], RetryDecision]: + """Linearly increasing delay between retries.""" + return create_retry_strategy( + RetryStrategyConfig( + max_attempts=5, + initial_delay=Duration.from_seconds(5), + max_delay=Duration.from_minutes(5), + backoff_strategy=BackoffStrategy.LINEAR, + jitter_strategy=JitterStrategy.FULL, + ) + ) + + @classmethod + def slow(cls) -> Callable[[Exception, int], RetryDecision]: + """Long delays for operations that need extended recovery time.""" + return create_retry_strategy( + RetryStrategyConfig( + max_attempts=8, + initial_delay=Duration.from_seconds(30), + max_delay=Duration.from_minutes(10), + backoff_rate=2, + jitter_strategy=JitterStrategy.FULL, + ) + ) diff --git a/tests/retries_test.py b/tests/retries_test.py index 1b58134..0ea628b 100644 --- a/tests/retries_test.py +++ b/tests/retries_test.py @@ -5,7 +5,7 @@ import pytest -from aws_durable_execution_sdk_python.config import Duration +from aws_durable_execution_sdk_python.config import BackoffStrategy, Duration from aws_durable_execution_sdk_python.retries import ( JitterStrategy, RetryDecision, @@ -574,3 +574,267 @@ def test_mixed_error_types_and_patterns(): # endregion + + +# region Backoff Strategy Tests + + +def test_exponential_backoff_strategy(): + """Test EXPONENTIAL backoff strategy calculates delay correctly.""" + strategy: BackoffStrategy = BackoffStrategy.EXPONENTIAL + # initial=5, rate=2, attempt=3: 5 * 2^(3-1) = 5 * 4 = 20 + result: float = strategy.calculate_base_delay( + initial_delay_seconds=5, + backoff_rate=2.0, + attempts_made=3, + max_delay_seconds=300, + ) + assert result == 20.0 + + +def test_linear_backoff_strategy(): + """Test LINEAR backoff strategy calculates delay correctly.""" + strategy: BackoffStrategy = BackoffStrategy.LINEAR + # initial=5, attempt=1: 5 * 1 = 5 + result_1: float = strategy.calculate_base_delay( + initial_delay_seconds=5, + backoff_rate=2.0, + attempts_made=1, + max_delay_seconds=300, + ) + assert result_1 == 5.0 + + # initial=5, attempt=3: 5 * 3 = 15 + result_3: float = strategy.calculate_base_delay( + initial_delay_seconds=5, + backoff_rate=2.0, + attempts_made=3, + max_delay_seconds=300, + ) + assert result_3 == 15.0 + + +def test_fixed_backoff_strategy(): + """Test FIXED backoff strategy returns constant delay.""" + strategy: BackoffStrategy = BackoffStrategy.FIXED + # Always returns initial_delay regardless of attempt number + result_1: float = strategy.calculate_base_delay( + initial_delay_seconds=5, + backoff_rate=2.0, + attempts_made=1, + max_delay_seconds=300, + ) + result_5: float = strategy.calculate_base_delay( + initial_delay_seconds=5, + backoff_rate=2.0, + attempts_made=5, + max_delay_seconds=300, + ) + assert result_1 == 5.0 + assert result_5 == 5.0 + + +def test_backoff_strategy_respects_max_delay(): + """Test all backoff strategies cap delay at max_delay_seconds.""" + max_delay: int = 10 + + # EXPONENTIAL: 5 * 2^4 = 80, capped to 10 + exp_result: float = BackoffStrategy.EXPONENTIAL.calculate_base_delay( + initial_delay_seconds=5, + backoff_rate=2.0, + attempts_made=5, + max_delay_seconds=max_delay, + ) + assert exp_result == max_delay + + # LINEAR: 5 * 5 = 25, capped to 10 + lin_result: float = BackoffStrategy.LINEAR.calculate_base_delay( + initial_delay_seconds=5, + backoff_rate=2.0, + attempts_made=5, + max_delay_seconds=max_delay, + ) + assert lin_result == max_delay + + # FIXED: 5, not capped (already under) + fix_result: float = BackoffStrategy.FIXED.calculate_base_delay( + initial_delay_seconds=5, + backoff_rate=2.0, + attempts_made=5, + max_delay_seconds=max_delay, + ) + assert fix_result == 5.0 + + +def test_default_backoff_strategy_is_exponential(): + """Test RetryStrategyConfig defaults to EXPONENTIAL backoff.""" + config: RetryStrategyConfig = RetryStrategyConfig() + assert config.backoff_strategy == BackoffStrategy.EXPONENTIAL + + +# endregion + + +# region New Preset Tests + + +def test_fixed_wait_preset(): + """Test fixed_wait preset uses constant delay and allows retries within max attempts.""" + strategy = RetryPresets.fixed_wait() + error: Exception = Exception("test error") + + # Should retry within max attempts + decision = strategy(error, 1) + assert decision.should_retry is True + + # Should not retry after max attempts + decision = strategy(error, 5) + assert decision.should_retry is False + + +def test_fixed_wait_preset_constant_delay(): + """Test fixed_wait preset returns constant delay across attempts.""" + strategy = RetryPresets.fixed_wait() + error: Exception = Exception("test error") + + # FIXED strategy + NONE jitter = constant delay of 5s for all attempts + decision_1 = strategy(error, 1) + decision_2 = strategy(error, 2) + decision_3 = strategy(error, 3) + assert decision_1.delay_seconds == 5 + assert decision_2.delay_seconds == 5 + assert decision_3.delay_seconds == 5 + + +def test_linear_backoff_preset(): + """Test linear_backoff preset allows retries within max attempts.""" + strategy = RetryPresets.linear_backoff() + error: Exception = Exception("test error") + + # Should retry within max attempts + decision = strategy(error, 1) + assert decision.should_retry is True + + # Should not retry after max attempts + decision = strategy(error, 5) + assert decision.should_retry is False + + +@patch("random.random") +def test_linear_backoff_preset_increasing_delay(mock_random): + """Test linear_backoff preset has linearly increasing delay.""" + mock_random.return_value = 1.0 # Max jitter to get predictable values + strategy = RetryPresets.linear_backoff() + error: Exception = Exception("test error") + + # LINEAR: initial_delay * attempts_made, with FULL jitter (random=1.0 means full delay) + # attempt 1: 5 * 1 = 5 + decision_1 = strategy(error, 1) + assert decision_1.delay_seconds == 5 + + # attempt 2: 5 * 2 = 10 + decision_2 = strategy(error, 2) + assert decision_2.delay_seconds == 10 + + # attempt 3: 5 * 3 = 15 + decision_3 = strategy(error, 3) + assert decision_3.delay_seconds == 15 + + +def test_slow_preset(): + """Test slow preset allows retries within max attempts.""" + strategy = RetryPresets.slow() + error: Exception = Exception("test error") + + # Should retry within max attempts + decision = strategy(error, 1) + assert decision.should_retry is True + + # Should not retry after max attempts + decision = strategy(error, 8) + assert decision.should_retry is False + + +@patch("random.random") +def test_slow_preset_high_initial_delay(mock_random): + """Test slow preset starts with high initial delay.""" + mock_random.return_value = 1.0 # Max jitter + strategy = RetryPresets.slow() + error: Exception = Exception("test error") + + # Attempt 1: 30 * (2^0) = 30, full jitter with random=1.0 = 30 + decision = strategy(error, 1) + assert decision.delay_seconds == 30 + + +# endregion + + +# region Backoff Strategy Integration Tests + + +@patch("random.random") +def test_fixed_backoff_in_create_retry_strategy(mock_random): + """Test FIXED backoff works end-to-end through create_retry_strategy.""" + mock_random.return_value = 0.5 + config: RetryStrategyConfig = RetryStrategyConfig( + initial_delay=Duration.from_seconds(10), + backoff_strategy=BackoffStrategy.FIXED, + jitter_strategy=JitterStrategy.FULL, + ) + strategy = create_retry_strategy(config) + + error: Exception = Exception("test error") + # FIXED = 10, FULL jitter with 0.5 = 5 + decision_1 = strategy(error, 1) + assert decision_1.delay_seconds == 5 + + # Same delay at attempt 2 (FIXED ignores attempt number) + decision_2 = strategy(error, 2) + assert decision_2.delay_seconds == 5 + + +@patch("random.random") +def test_linear_backoff_in_create_retry_strategy(mock_random): + """Test LINEAR backoff works end-to-end through create_retry_strategy.""" + mock_random.return_value = 1.0 # Full jitter = full delay + config: RetryStrategyConfig = RetryStrategyConfig( + max_attempts=5, + initial_delay=Duration.from_seconds(5), + backoff_strategy=BackoffStrategy.LINEAR, + jitter_strategy=JitterStrategy.FULL, + ) + strategy = create_retry_strategy(config) + + error: Exception = Exception("test error") + # LINEAR: 5 * 1 = 5, jitter(1.0) = 5 + decision_1 = strategy(error, 1) + assert decision_1.delay_seconds == 5 + + # LINEAR: 5 * 2 = 10, jitter(1.0) = 10 + decision_2 = strategy(error, 2) + assert decision_2.delay_seconds == 10 + + # LINEAR: 5 * 3 = 15, jitter(1.0) = 15 + decision_3 = strategy(error, 3) + assert decision_3.delay_seconds == 15 + + +def test_linear_backoff_respects_max_delay(): + """Test LINEAR backoff caps delay at max_delay.""" + config: RetryStrategyConfig = RetryStrategyConfig( + max_attempts=5, + initial_delay=Duration.from_seconds(10), + max_delay=Duration.from_seconds(25), + backoff_strategy=BackoffStrategy.LINEAR, + jitter_strategy=JitterStrategy.NONE, + ) + strategy = create_retry_strategy(config) + + error: Exception = Exception("test error") + # LINEAR: 10 * 3 = 30, capped to 25 + decision = strategy(error, 3) + assert decision.delay_seconds == 25 + + +# endregion