Coverage for async_durable_execution/_operation/with_retry.py: 100%
40 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-30 23:43 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-30 23:43 +0000
1from __future__ import annotations
3import asyncio
4from dataclasses import dataclass
5from typing import TYPE_CHECKING, Awaitable, Callable, TypeVar
7from .._core import (
8 Duration,
9 DurableContext,
10 OperationSubType,
11 RetryStrategy,
12 SerDes,
13 bind_current_context,
14 get_current_context,
15 get_durable_context,
16)
17from ..extension import get_extension_context
19if TYPE_CHECKING:
20 from .child import SummaryGenerator
22T = TypeVar("T")
25def wait(
26 duration: Duration,
27 *,
28 name: str | None = None,
29) -> asyncio.Task[None]:
30 """Run an SDK-owned wait operation through the stable operation SPI."""
31 return (
32 get_extension_context()
33 ._reserve_sdk_operation(name) # noqa: SLF001
34 ._run_wait( # noqa: SLF001
35 duration,
36 sub_type=OperationSubType.WAIT,
37 )
38 )
41def run_in_child_context(
42 func: Callable[[], Awaitable[T]],
43 *,
44 name: str | None = None,
45 serdes: SerDes | None = None,
46 summary_generator: SummaryGenerator | None = None,
47 is_virtual: bool = False,
48) -> asyncio.Task[T]:
49 """Run an SDK-owned retry scope through the stable operation SPI."""
50 return (
51 get_extension_context()
52 ._reserve_sdk_operation(name) # noqa: SLF001
53 ._run_in_child_context( # noqa: SLF001
54 func,
55 sub_type=OperationSubType.RUN_IN_CHILD_CONTEXT,
56 serdes=serdes,
57 summary_generator=summary_generator,
58 is_virtual=is_virtual,
59 )
60 )
63@dataclass(frozen=True)
64class WithRetryContext(DurableContext):
65 """Context available while a with_retry body is executing."""
67 attempt: int = 1
70def get_with_retry_context() -> WithRetryContext:
71 """Return the active `WithRetryContext`."""
72 current_context = get_current_context()
73 if not isinstance(current_context, WithRetryContext):
74 msg = (
75 "get_with_retry_context() can only be used while a with_retry body "
76 "is executing."
77 )
78 raise RuntimeError(msg)
79 return current_context
82def with_retry(
83 func: Callable[[], Awaitable[T]],
84 *,
85 name: str | None = None,
86 retry_strategy: Callable[[Exception, int], Duration | None] | None = None,
87 serdes: SerDes | None = None,
88 summary_generator: SummaryGenerator | None = None,
89 is_virtual: bool = False,
90) -> asyncio.Task[T]:
91 """Retry a block of durable logic with configurable backoff.
93 Args:
94 func: Async callable to retry. Use get_with_retry_context().attempt inside
95 the callable to access the current attempt number.
96 name: Optional durable operation name.
97 retry_strategy: Optional strategy that returns a retry delay or None to stop.
98 serdes: Optional serializer for the child context result.
99 summary_generator: Optional summary generator for large child results.
100 is_virtual: Whether the child context should skip lifecycle checkpoints.
101 """
103 async def run_loop() -> T:
104 retry = retry_strategy or RetryStrategy()
105 attempt = 0
106 while True:
107 attempt += 1
108 try:
109 context = get_durable_context()
110 retry_context = WithRetryContext(
111 execution_state=context.execution_state,
112 operation_identifier=context.operation_identifier,
113 step_id_prefix=context.step_id_prefix,
114 replaying=context.is_replaying(),
115 attempt=attempt,
116 )
117 if "step_counter" in context.__dict__:
118 retry_context.__dict__["step_counter"] = context.__dict__[
119 "step_counter"
120 ]
121 with bind_current_context(retry_context):
122 return await func()
123 except Exception as err:
124 delay = retry(err, attempt)
125 if delay is None:
126 raise
128 wait_name = (
129 f"{name}-backoff-{attempt}" if name else f"backoff-{attempt}"
130 )
131 await wait(duration=delay, name=wait_name)
133 return run_in_child_context(
134 run_loop,
135 name=name or "with-retry",
136 serdes=serdes,
137 summary_generator=summary_generator,
138 is_virtual=is_virtual,
139 )