Coverage for async-durable-execution/src/async_durable_execution/client.py: 100%
110 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
1from __future__ import annotations
3import asyncio
4import inspect
5import importlib
6import importlib.util
7import logging
8from collections.abc import Awaitable, Mapping
9from typing import Any, Protocol, cast
11from botocore.config import Config
12from botocore.session import get_session
14from .__about__ import __version__
15from .exceptions import CheckpointError, GetExecutionStateError
16from .models import (
17 CheckpointOutput,
18 OperationUpdate,
19 StateOutput,
20)
22logger = logging.getLogger(__name__)
25class LambdaApiClient(Protocol):
26 """Minimal Lambda client surface needed by durable execution."""
28 def checkpoint_durable_execution(
29 self, **kwargs: Any
30 ) -> Mapping[str, Any]: ... # pragma: no cover
32 def get_durable_execution_state(
33 self, **kwargs: Any
34 ) -> Mapping[str, Any]: ... # pragma: no cover
37class AsyncLambdaApiClient(Protocol):
38 """Minimal async Lambda client surface needed by durable execution."""
40 def checkpoint_durable_execution(
41 self, **kwargs: Any
42 ) -> Awaitable[Mapping[str, Any]]: ... # pragma: no cover
44 def get_durable_execution_state(
45 self, **kwargs: Any
46 ) -> Awaitable[Mapping[str, Any]]: ... # pragma: no cover
49class DurableServiceClient(Protocol):
50 """Durable Service clients must implement this interface."""
52 async def checkpoint(
53 self,
54 durable_execution_arn: str,
55 checkpoint_token: str,
56 updates: list[OperationUpdate],
57 client_token: str | None,
58 ) -> CheckpointOutput: ... # pragma: no cover
60 async def get_execution_state(
61 self,
62 durable_execution_arn: str,
63 checkpoint_token: str,
64 next_marker: str,
65 max_items: int = 1000,
66 ) -> StateOutput: ... # pragma: no cover
69def _create_client_config() -> Config:
70 user_agent = f"durable-execution-sdk-python/{__version__}-async"
71 return Config(
72 connect_timeout=5,
73 read_timeout=50,
74 user_agent_extra=user_agent,
75 )
78def aioboto_is_installed() -> bool:
79 """Return whether the optional aioboto dependency is available."""
80 return importlib.util.find_spec("aiobotocore") is not None
83def create_default_sync_client() -> LambdaApiClient:
84 """Create the default botocore Lambda client used for durable API calls."""
85 session = get_session()
86 return cast(
87 "LambdaApiClient",
88 session.create_client("lambda", config=_create_client_config()),
89 )
92def create_default_async_client() -> AsyncLambdaApiClient:
93 """Create the default aioboto Lambda client used for durable API calls."""
94 aiobotocore_session = importlib.import_module("aiobotocore.session")
95 session = aiobotocore_session.get_session()
96 return _AiobotocoreLambdaApiClient(
97 session.create_client("lambda", config=_create_client_config())
98 )
101def create_default_client() -> LambdaApiClient | AsyncLambdaApiClient:
102 """Create the default Lambda client, preferring async when aioboto is installed."""
103 if aioboto_is_installed():
104 return create_default_async_client()
105 return create_default_sync_client()
108def lambda_api_client_is_async(
109 client: LambdaApiClient | AsyncLambdaApiClient,
110) -> bool:
111 """Return whether a Lambda API client exposes async durable methods."""
112 return inspect.iscoroutinefunction(client.checkpoint_durable_execution)
115def create_default_service_client(
116 client: LambdaApiClient | AsyncLambdaApiClient | None = None,
117) -> DurableServiceClient:
118 """Create the default durable service client."""
119 lambda_client = client or create_default_client()
120 if lambda_api_client_is_async(lambda_client):
121 return AsyncLambdaClient(cast("AsyncLambdaApiClient", lambda_client))
122 return ThreadedSyncLambdaClient(cast("LambdaApiClient", lambda_client))
125class ThreadedSyncLambdaClient(DurableServiceClient):
126 """Adapt the sync botocore Lambda client to the async service interface."""
128 _cached_boto_client: LambdaApiClient | None = None
130 def __init__(self, client: LambdaApiClient | None) -> None:
131 self.client = client or create_default_sync_client()
133 async def checkpoint(
134 self,
135 durable_execution_arn: str,
136 checkpoint_token: str,
137 updates: list[OperationUpdate],
138 client_token: str | None,
139 ) -> CheckpointOutput:
140 try:
141 optional_params: dict[str, str] = {}
142 if client_token is not None:
143 optional_params["ClientToken"] = client_token
145 result = await asyncio.to_thread(
146 self.client.checkpoint_durable_execution,
147 DurableExecutionArn=durable_execution_arn,
148 CheckpointToken=checkpoint_token,
149 Updates=cast("Any", [o.to_dict() for o in updates]),
150 **optional_params,
151 )
153 return CheckpointOutput.from_dict(result)
154 except Exception as e:
155 checkpoint_error = CheckpointError.from_exception(e)
156 logger.exception(
157 "Failed to checkpoint.", extra=checkpoint_error.build_logger_extras()
158 )
159 raise checkpoint_error from None
161 async def get_execution_state(
162 self,
163 durable_execution_arn: str,
164 checkpoint_token: str,
165 next_marker: str,
166 max_items: int = 1000,
167 ) -> StateOutput:
168 try:
169 result = await asyncio.to_thread(
170 self.client.get_durable_execution_state,
171 DurableExecutionArn=durable_execution_arn,
172 CheckpointToken=checkpoint_token,
173 Marker=next_marker,
174 MaxItems=max_items,
175 )
176 return StateOutput.from_dict(result)
177 except Exception as e:
178 error = GetExecutionStateError.from_exception(e)
179 logger.exception(
180 "Failed to get execution state.", extra=error.build_logger_extras()
181 )
182 raise error from None
185class _AiobotocoreLambdaApiClient:
186 """Lazily enter an aiobotocore Lambda client context for durable API calls."""
188 def __init__(self, client_context: Any) -> None:
189 self._client_context = client_context
190 self._client: Any | None = None
192 async def _get_client(self) -> Any:
193 if self._client is None:
194 self._client = await self._client_context.__aenter__()
195 return self._client
197 async def checkpoint_durable_execution(self, **kwargs: Any) -> Any:
198 client = await self._get_client()
199 return await client.checkpoint_durable_execution(**kwargs)
201 async def get_durable_execution_state(self, **kwargs: Any) -> Any:
202 client = await self._get_client()
203 return await client.get_durable_execution_state(**kwargs)
205 async def aclose(self) -> None:
206 if self._client is None:
207 return
208 await self._client_context.__aexit__(None, None, None)
209 self._client = None
212class AsyncLambdaClient(DurableServiceClient):
213 """Adapt an async aioboto Lambda client to the durable service interface."""
215 def __init__(self, client: AsyncLambdaApiClient) -> None:
216 self.client = client
218 async def checkpoint(
219 self,
220 durable_execution_arn: str,
221 checkpoint_token: str,
222 updates: list[OperationUpdate],
223 client_token: str | None,
224 ) -> CheckpointOutput:
225 try:
226 optional_params: dict[str, str] = {}
227 if client_token is not None:
228 optional_params["ClientToken"] = client_token
230 result = await self.client.checkpoint_durable_execution(
231 DurableExecutionArn=durable_execution_arn,
232 CheckpointToken=checkpoint_token,
233 Updates=cast("Any", [o.to_dict() for o in updates]),
234 **optional_params,
235 )
237 return CheckpointOutput.from_dict(result)
238 except Exception as e:
239 checkpoint_error = CheckpointError.from_exception(e)
240 logger.exception(
241 "Failed to checkpoint.", extra=checkpoint_error.build_logger_extras()
242 )
243 raise checkpoint_error from None
245 async def get_execution_state(
246 self,
247 durable_execution_arn: str,
248 checkpoint_token: str,
249 next_marker: str,
250 max_items: int = 1000,
251 ) -> StateOutput:
252 try:
253 result = await self.client.get_durable_execution_state(
254 DurableExecutionArn=durable_execution_arn,
255 CheckpointToken=checkpoint_token,
256 Marker=next_marker,
257 MaxItems=max_items,
258 )
259 return StateOutput.from_dict(result)
260 except Exception as e:
261 error = GetExecutionStateError.from_exception(e)
262 logger.exception(
263 "Failed to get execution state.", extra=error.build_logger_extras()
264 )
265 raise error from None
267 async def aclose(self) -> None:
268 close = getattr(self.client, "aclose", None)
269 if close is None:
270 return
271 await close()
274__all__ = [
275 "AsyncLambdaClient",
276 "DurableServiceClient",
277 "ThreadedSyncLambdaClient",
278 "create_default_async_client",
279 "create_default_client",
280 "create_default_service_client",
281 "create_default_sync_client",
282 "lambda_api_client_is_async",
283]