Coverage for async-durable-execution/src/async_durable_execution/config.py: 98%
97 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
1"""Configuration types."""
3from __future__ import annotations
5import math
6import random
7import re
8from dataclasses import dataclass, field
9from datetime import timedelta
10from enum import Enum
11from typing import TypeAlias
13from .exceptions import ValidationError
15Duration: TypeAlias = int | timedelta
18def duration_to_seconds(duration: Duration, field_name: str = "duration") -> int:
19 """Convert a seconds integer or timedelta to whole seconds."""
20 if isinstance(duration, bool) or not isinstance(duration, int | timedelta):
21 msg = f"{field_name} must be an int number of seconds or a timedelta"
22 raise ValidationError(msg)
24 seconds = (
25 int(duration.total_seconds()) if isinstance(duration, timedelta) else duration
26 )
27 if seconds < 0:
28 msg = f"{field_name} must be non-negative"
29 raise ValidationError(msg)
30 return seconds
33class JitterStrategy(str, Enum):
34 """
35 Jitter strategies are used to introduce noise when attempting to retry
36 an invoke. We introduce noise to prevent a thundering-herd effect where
37 a group of accesses (e.g. invokes) happen at once.
39 Jitter is meant to be used to spread operations across time.
41 Based on AWS Architecture Blog: https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
43 members:
44 :NONE: No jitter; use the exact calculated delay
45 :FULL: Full jitter; random delay between 0 and calculated delay
46 :HALF: Equal jitter; random delay between 0.5x and 1.0x of the calculated delay
47 """
49 NONE = "NONE"
50 FULL = "FULL"
51 HALF = "HALF"
53 def apply_jitter(self, delay: float) -> float:
54 """Apply jitter to a delay value and return the final delay.
56 Args:
57 delay: The base delay value to apply jitter to
59 Returns:
60 The final delay after applying jitter strategy
61 """
62 match self:
63 case JitterStrategy.NONE:
64 return delay
65 case JitterStrategy.HALF:
66 # Equal jitter: delay/2 + random(0, delay/2)
67 return delay / 2 + random.random() * (delay / 2) # noqa: S311
68 case _: # default is FULL
69 # Full jitter: random(0, delay)
70 return random.random() * delay # noqa: S311
72 def finalize_delay(self, base_delay: float) -> int:
73 """Apply jitter, round up, and clamp to a minimum of 1 second."""
74 return max(1, math.ceil(self.apply_jitter(base_delay)))
77@dataclass
78class _DelayStrategy:
79 """Common delay configuration for retry and polling strategies."""
81 max_attempts: int = 6
82 initial_delay: Duration = 5
83 max_delay: Duration = 60
84 backoff_rate: int | float = 2
85 jitter_strategy: JitterStrategy = field(default=JitterStrategy.FULL)
86 increment: Duration | None = None
88 def __post_init__(self):
89 self.initial_delay = duration_to_seconds(self.initial_delay, "initial_delay")
90 self.max_delay = duration_to_seconds(self.max_delay, "max_delay")
91 if self.increment is not None:
92 self.increment = duration_to_seconds(self.increment, "increment")
94 @property
95 def initial_delay_seconds(self) -> int:
96 """Get initial delay in seconds."""
97 return duration_to_seconds(self.initial_delay, "initial_delay")
99 @property
100 def max_delay_seconds(self) -> int:
101 """Get max delay in seconds."""
102 return duration_to_seconds(self.max_delay, "max_delay")
104 @property
105 def increment_seconds(self) -> int | None:
106 """Get linear delay increment in seconds."""
107 if self.increment is None:
108 return None
109 return duration_to_seconds(self.increment, "increment")
111 def calculate_delay(self, attempts_made: int) -> int:
112 """Calculate a whole-second delay for exponential or linear strategies."""
113 increment_seconds = self.increment_seconds
114 if increment_seconds is None:
115 base_delay: float = self.initial_delay_seconds * (
116 self.backoff_rate ** (attempts_made - 1)
117 )
118 else:
119 base_delay = self.initial_delay_seconds + increment_seconds * (
120 attempts_made - 1
121 )
123 return self.jitter_strategy.finalize_delay(
124 min(base_delay, self.max_delay_seconds)
125 )
128@dataclass
129class RetryStrategy(_DelayStrategy):
130 """Exponential-backoff retry strategy for durable operations."""
132 retryable_errors: list[str | re.Pattern] | None = None
133 retryable_error_types: list[type[Exception]] | None = None
135 def __call__(self, error: Exception, attempts_made: int) -> Duration | None:
136 """Return retry delay, or None if the error should not be retried."""
137 default_retryable_error_pattern = re.compile(r".*")
138 should_use_default_errors: bool = (
139 self.retryable_errors is None and self.retryable_error_types is None
140 )
142 retryable_errors: list[str | re.Pattern] = (
143 self.retryable_errors
144 if self.retryable_errors is not None
145 else (
146 [default_retryable_error_pattern] if should_use_default_errors else []
147 )
148 )
149 retryable_error_types: list[type[Exception]] = self.retryable_error_types or []
151 if attempts_made >= self.max_attempts:
152 return None
154 is_retryable_error_message: bool = any(
155 pattern.search(str(error))
156 if isinstance(pattern, re.Pattern)
157 else pattern in str(error)
158 for pattern in retryable_errors
159 )
160 is_retryable_error_type: bool = any(
161 isinstance(error, error_type) for error_type in retryable_error_types
162 )
164 if not is_retryable_error_message and not is_retryable_error_type:
165 return None
167 return self.calculate_delay(attempts_made)
169 @classmethod
170 def none(cls) -> RetryStrategy:
171 """No retries."""
172 return cls(max_attempts=1, max_delay=300, backoff_rate=2.0)
174 @classmethod
175 def default(cls) -> RetryStrategy:
176 """Default retries, used automatically when no retry strategy is provided."""
177 return cls()
179 @classmethod
180 def transient(cls) -> RetryStrategy:
181 """Quick retries for transient errors."""
182 return cls(
183 max_attempts=3,
184 max_delay=300,
185 backoff_rate=2,
186 jitter_strategy=JitterStrategy.HALF,
187 )
189 @classmethod
190 def resource_availability(cls) -> RetryStrategy:
191 """Longer retries for resource availability."""
192 return cls(
193 max_attempts=5,
194 initial_delay=5,
195 max_delay=300,
196 backoff_rate=2,
197 )
199 @classmethod
200 def critical(cls) -> RetryStrategy:
201 """Aggressive retries for critical operations."""
202 return cls(
203 max_attempts=10,
204 initial_delay=1,
205 max_delay=60,
206 backoff_rate=1.5,
207 jitter_strategy=JitterStrategy.NONE,
208 )
210 @classmethod
211 def linear(cls) -> RetryStrategy:
212 """Linearly increasing delay between retries: 1s, 2s, 3s, 4s, 5s."""
213 return cls(
214 max_attempts=6,
215 initial_delay=1,
216 increment=1,
217 max_delay=300,
218 backoff_rate=2,
219 jitter_strategy=JitterStrategy.NONE,
220 )