Skip to content

Commit 9f9cfea

Browse files
author
Astraea Quinn S
authored
feat: Duration type rather than seconds
* Replace all duration parameters with Duration type - Add Duration class with days, hours, minutes, seconds fields - Add fluent builder methods: from_seconds(), from_minutes(), from_hours(), from_days() - Update all customer-facing APIs to accept Duration instead of int seconds: - context.wait(duration) - InvokeConfig.timeout - CallbackConfig.timeout and heartbeat_timeout - RetryStrategyConfig.initial_delay and max_delay - WaitStrategyConfig.initial_delay, max_delay, and timeout - RetryDecision.delay - WaitDecision.delay - WaitForConditionDecision.delay - Add *_seconds properties for internal backward compatibility - Replace common values with fluent builders (e.g., Duration.from_minutes(5))
1 parent 9f55997 commit 9f9cfea

File tree

14 files changed

+239
-126
lines changed

14 files changed

+239
-126
lines changed

src/aws_durable_execution_sdk_python/config.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from enum import Enum, StrEnum
88
from typing import TYPE_CHECKING, Generic, TypeVar
99

10+
from aws_durable_execution_sdk_python.exceptions import ValidationError
11+
1012
P = TypeVar("P") # Payload type
1113
R = TypeVar("R") # Result type
1214
T = TypeVar("T")
@@ -25,6 +27,42 @@
2527
Numeric = int | float # deliberately leaving off complex
2628

2729

30+
@dataclass(frozen=True)
31+
class Duration:
32+
"""Represents a duration stored as total seconds."""
33+
34+
seconds: int = 0
35+
36+
def __post_init__(self):
37+
if self.seconds < 0:
38+
msg = "Duration seconds must be positive"
39+
raise ValidationError(msg)
40+
41+
def to_seconds(self) -> int:
42+
"""Convert the duration to total seconds."""
43+
return self.seconds
44+
45+
@classmethod
46+
def from_seconds(cls, value: float) -> Duration:
47+
"""Create a Duration from total seconds."""
48+
return cls(seconds=int(value))
49+
50+
@classmethod
51+
def from_minutes(cls, value: float) -> Duration:
52+
"""Create a Duration from minutes."""
53+
return cls(seconds=int(value * 60))
54+
55+
@classmethod
56+
def from_hours(cls, value: float) -> Duration:
57+
"""Create a Duration from hours."""
58+
return cls(seconds=int(value * 3600))
59+
60+
@classmethod
61+
def from_days(cls, value: float) -> Duration:
62+
"""Create a Duration from days."""
63+
return cls(seconds=int(value * 86400))
64+
65+
2866
@dataclass(frozen=True)
2967
class BatchedInput(Generic[T, U]):
3068
batch_input: T
@@ -343,19 +381,34 @@ class MapConfig:
343381
@dataclass
344382
class InvokeConfig(Generic[P, R]):
345383
# retry_strategy: Callable[[Exception, int], RetryDecision] | None = None
346-
timeout_seconds: int = 0
384+
timeout: Duration = field(default_factory=Duration)
347385
serdes_payload: SerDes[P] | None = None
348386
serdes_result: SerDes[R] | None = None
349387

388+
@property
389+
def timeout_seconds(self) -> int:
390+
"""Get timeout in seconds."""
391+
return self.timeout.to_seconds()
392+
350393

351394
@dataclass(frozen=True)
352395
class CallbackConfig:
353396
"""Configuration for callbacks."""
354397

355-
timeout_seconds: int = 0
356-
heartbeat_timeout_seconds: int = 0
398+
timeout: Duration = field(default_factory=Duration)
399+
heartbeat_timeout: Duration = field(default_factory=Duration)
357400
serdes: SerDes | None = None
358401

402+
@property
403+
def timeout_seconds(self) -> int:
404+
"""Get timeout in seconds."""
405+
return self.timeout.to_seconds()
406+
407+
@property
408+
def heartbeat_timeout_seconds(self) -> int:
409+
"""Get heartbeat timeout in seconds."""
410+
return self.heartbeat_timeout.to_seconds()
411+
359412

360413
@dataclass(frozen=True)
361414
class WaitForCallbackConfig(CallbackConfig):

src/aws_durable_execution_sdk_python/context.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
BatchedInput,
99
CallbackConfig,
1010
ChildConfig,
11+
Duration,
1112
InvokeConfig,
1213
MapConfig,
1314
ParallelConfig,
@@ -447,15 +448,16 @@ def step(
447448
context_logger=self.logger,
448449
)
449450

450-
def wait(self, seconds: int, name: str | None = None) -> None:
451+
def wait(self, duration: Duration, name: str | None = None) -> None:
451452
"""Wait for a specified amount of time.
452453
453454
Args:
454-
seconds: Time to wait in seconds
455+
duration: Duration to wait
455456
name: Optional name for the wait step
456457
"""
458+
seconds = duration.to_seconds()
457459
if seconds < 1:
458-
msg = "seconds must be an integer greater than 0"
460+
msg = "duration must be at least 1 second"
459461
raise ValidationError(msg)
460462
wait_handler(
461463
seconds=seconds,

src/aws_durable_execution_sdk_python/retries.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import dataclass, field
88
from typing import TYPE_CHECKING
99

10-
from aws_durable_execution_sdk_python.config import JitterStrategy
10+
from aws_durable_execution_sdk_python.config import Duration, JitterStrategy
1111

1212
if TYPE_CHECKING:
1313
from collections.abc import Callable
@@ -20,31 +20,48 @@ class RetryDecision:
2020
"""Decision about whether to retry a step and with what delay."""
2121

2222
should_retry: bool
23-
delay_seconds: int
23+
delay: Duration
24+
25+
@property
26+
def delay_seconds(self) -> int:
27+
"""Get delay in seconds."""
28+
return self.delay.to_seconds()
2429

2530
@classmethod
26-
def retry(cls, delay_seconds: int) -> RetryDecision:
31+
def retry(cls, delay: Duration) -> RetryDecision:
2732
"""Create a retry decision."""
28-
return cls(should_retry=True, delay_seconds=delay_seconds)
33+
return cls(should_retry=True, delay=delay)
2934

3035
@classmethod
3136
def no_retry(cls) -> RetryDecision:
3237
"""Create a no-retry decision."""
33-
return cls(should_retry=False, delay_seconds=0)
38+
return cls(should_retry=False, delay=Duration())
3439

3540

3641
@dataclass
3742
class RetryStrategyConfig:
3843
max_attempts: int = 3
39-
initial_delay_seconds: int = 5
40-
max_delay_seconds: int = 300 # 5 minutes
44+
initial_delay: Duration = field(default_factory=lambda: Duration.from_seconds(5))
45+
max_delay: Duration = field(
46+
default_factory=lambda: Duration.from_minutes(5)
47+
) # 5 minutes
4148
backoff_rate: Numeric = 2.0
4249
jitter_strategy: JitterStrategy = field(default=JitterStrategy.FULL)
4350
retryable_errors: list[str | re.Pattern] = field(
4451
default_factory=lambda: [re.compile(r".*")]
4552
)
4653
retryable_error_types: list[type[Exception]] = field(default_factory=list)
4754

55+
@property
56+
def initial_delay_seconds(self) -> int:
57+
"""Get initial delay in seconds."""
58+
return self.initial_delay.to_seconds()
59+
60+
@property
61+
def max_delay_seconds(self) -> int:
62+
"""Get max delay in seconds."""
63+
return self.max_delay.to_seconds()
64+
4865

4966
def create_retry_strategy(
5067
config: RetryStrategyConfig,
@@ -82,7 +99,7 @@ def retry_strategy(error: Exception, attempts_made: int) -> RetryDecision:
8299
delay_with_jitter = math.ceil(delay_with_jitter)
83100
final_delay = max(1, delay_with_jitter)
84101

85-
return RetryDecision.retry(round(final_delay))
102+
return RetryDecision.retry(Duration(seconds=round(final_delay)))
86103

87104
return retry_strategy
88105

@@ -101,8 +118,8 @@ def default(cls) -> Callable[[Exception, int], RetryDecision]:
101118
return create_retry_strategy(
102119
RetryStrategyConfig(
103120
max_attempts=6,
104-
initial_delay_seconds=5,
105-
max_delay_seconds=60,
121+
initial_delay=Duration.from_seconds(5),
122+
max_delay=Duration.from_minutes(1),
106123
backoff_rate=2,
107124
jitter_strategy=JitterStrategy.FULL,
108125
)
@@ -123,8 +140,8 @@ def resource_availability(cls) -> Callable[[Exception, int], RetryDecision]:
123140
return create_retry_strategy(
124141
RetryStrategyConfig(
125142
max_attempts=5,
126-
initial_delay_seconds=5,
127-
max_delay_seconds=300,
143+
initial_delay=Duration.from_seconds(5),
144+
max_delay=Duration.from_minutes(5),
128145
backoff_rate=2,
129146
)
130147
)
@@ -135,8 +152,8 @@ def critical(cls) -> Callable[[Exception, int], RetryDecision]:
135152
return create_retry_strategy(
136153
RetryStrategyConfig(
137154
max_attempts=10,
138-
initial_delay_seconds=1,
139-
max_delay_seconds=60,
155+
initial_delay=Duration.from_seconds(1),
156+
max_delay=Duration.from_minutes(1),
140157
backoff_rate=1.5,
141158
jitter_strategy=JitterStrategy.NONE,
142159
)

src/aws_durable_execution_sdk_python/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
BatchedInput,
1414
CallbackConfig,
1515
ChildConfig,
16+
Duration,
1617
MapConfig,
1718
ParallelConfig,
1819
StepConfig,
@@ -126,7 +127,7 @@ def parallel(
126127
... # pragma: no cover
127128

128129
@abstractmethod
129-
def wait(self, seconds: int, name: str | None = None) -> None:
130+
def wait(self, duration: Duration, name: str | None = None) -> None:
130131
"""Wait for a specified amount of time."""
131132
... # pragma: no cover
132133

src/aws_durable_execution_sdk_python/waits.py

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dataclasses import dataclass, field
66
from typing import TYPE_CHECKING, Generic
77

8-
from aws_durable_execution_sdk_python.config import JitterStrategy, T
8+
from aws_durable_execution_sdk_python.config import Duration, JitterStrategy, T
99

1010
if TYPE_CHECKING:
1111
from collections.abc import Callable
@@ -20,28 +20,52 @@ class WaitDecision:
2020
"""Decision about whether to wait a step and with what delay."""
2121

2222
should_wait: bool
23-
delay_seconds: int
23+
delay: Duration
24+
25+
@property
26+
def delay_seconds(self) -> int:
27+
"""Get delay in seconds."""
28+
return self.delay.to_seconds()
2429

2530
@classmethod
26-
def wait(cls, delay_seconds: int) -> WaitDecision:
31+
def wait(cls, delay: Duration) -> WaitDecision:
2732
"""Create a wait decision."""
28-
return cls(should_wait=True, delay_seconds=delay_seconds)
33+
return cls(should_wait=True, delay=delay)
2934

3035
@classmethod
3136
def no_wait(cls) -> WaitDecision:
3237
"""Create a no-wait decision."""
33-
return cls(should_wait=False, delay_seconds=0)
38+
return cls(should_wait=False, delay=Duration())
3439

3540

3641
@dataclass
3742
class WaitStrategyConfig(Generic[T]):
3843
should_continue_polling: Callable[[T], bool]
3944
max_attempts: int = 60
40-
initial_delay_seconds: int = 5
41-
max_delay_seconds: int = 300 # 5 minutes
45+
initial_delay: Duration = field(default_factory=lambda: Duration.from_seconds(5))
46+
max_delay: Duration = field(
47+
default_factory=lambda: Duration.from_minutes(5)
48+
) # 5 minutes
4249
backoff_rate: Numeric = 1.5
4350
jitter_strategy: JitterStrategy = field(default=JitterStrategy.FULL)
44-
timeout_seconds: int | None = None # Not implemented yet
51+
timeout: Duration | None = None # Not implemented yet
52+
53+
@property
54+
def initial_delay_seconds(self) -> int:
55+
"""Get initial delay in seconds."""
56+
return self.initial_delay.to_seconds()
57+
58+
@property
59+
def max_delay_seconds(self) -> int:
60+
"""Get max delay in seconds."""
61+
return self.max_delay.to_seconds()
62+
63+
@property
64+
def timeout_seconds(self) -> int | None:
65+
"""Get timeout in seconds."""
66+
if self.timeout is None:
67+
return None
68+
return self.timeout.to_seconds()
4569

4670

4771
def create_wait_strategy(
@@ -69,7 +93,7 @@ def wait_strategy(result: T, attempts_made: int) -> WaitDecision:
6993
# Ensure delay is an integer >= 1
7094
final_delay = max(1, round(delay_with_jitter))
7195

72-
return WaitDecision.wait(final_delay)
96+
return WaitDecision.wait(Duration(seconds=final_delay))
7397

7498
return wait_strategy
7599

@@ -79,17 +103,22 @@ class WaitForConditionDecision:
79103
"""Decision about whether to continue waiting."""
80104

81105
should_continue: bool
82-
delay_seconds: int
106+
delay: Duration
107+
108+
@property
109+
def delay_seconds(self) -> int:
110+
"""Get delay in seconds."""
111+
return self.delay.to_seconds()
83112

84113
@classmethod
85-
def continue_waiting(cls, delay_seconds: int) -> WaitForConditionDecision:
114+
def continue_waiting(cls, delay: Duration) -> WaitForConditionDecision:
86115
"""Create a decision to continue waiting for delay_seconds."""
87-
return cls(should_continue=True, delay_seconds=delay_seconds)
116+
return cls(should_continue=True, delay=delay)
88117

89118
@classmethod
90119
def stop_polling(cls) -> WaitForConditionDecision:
91120
"""Create a decision to stop polling."""
92-
return cls(should_continue=False, delay_seconds=-1)
121+
return cls(should_continue=False, delay=Duration())
93122

94123

95124
@dataclass(frozen=True)

tests/config_test.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
CheckpointMode,
1010
ChildConfig,
1111
CompletionConfig,
12+
Duration,
1213
ItemBatcher,
1314
ItemsPerBatchUnit,
1415
MapConfig,
@@ -85,7 +86,7 @@ def test_parallel_config_defaults():
8586

8687
def test_wait_for_condition_decision_continue():
8788
"""Test WaitForConditionDecision.continue_waiting factory method."""
88-
decision = WaitForConditionDecision.continue_waiting(30)
89+
decision = WaitForConditionDecision.continue_waiting(Duration.from_seconds(30))
8990
assert decision.should_continue is True
9091
assert decision.delay_seconds == 30
9192

@@ -94,14 +95,14 @@ def test_wait_for_condition_decision_stop():
9495
"""Test WaitForConditionDecision.stop_polling factory method."""
9596
decision = WaitForConditionDecision.stop_polling()
9697
assert decision.should_continue is False
97-
assert decision.delay_seconds == -1
98+
assert decision.delay_seconds == 0
9899

99100

100101
def test_wait_for_condition_config():
101102
"""Test WaitForConditionConfig with custom values."""
102103

103104
def wait_strategy(state, attempt):
104-
return WaitForConditionDecision.continue_waiting(10)
105+
return WaitForConditionDecision.continue_waiting(Duration.from_seconds(10))
105106

106107
serdes = Mock()
107108
config = WaitForConditionConfig(
@@ -237,7 +238,9 @@ def test_callback_config_with_values():
237238
"""Test CallbackConfig with custom values."""
238239
serdes = Mock()
239240
config = CallbackConfig(
240-
timeout_seconds=30, heartbeat_timeout_seconds=10, serdes=serdes
241+
timeout=Duration.from_seconds(30),
242+
heartbeat_timeout=Duration.from_seconds(10),
243+
serdes=serdes,
241244
)
242245
assert config.timeout_seconds == 30
243246
assert config.heartbeat_timeout_seconds == 10

0 commit comments

Comments
 (0)