aws-durable-execution-sdk-python-testing 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. aws_durable_execution_sdk_python_testing/__about__.py +4 -0
  2. aws_durable_execution_sdk_python_testing/__init__.py +20 -0
  3. aws_durable_execution_sdk_python_testing/checkpoint/__init__.py +1 -0
  4. aws_durable_execution_sdk_python_testing/checkpoint/processor.py +101 -0
  5. aws_durable_execution_sdk_python_testing/checkpoint/processors/__init__.py +1 -0
  6. aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +199 -0
  7. aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +89 -0
  8. aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +59 -0
  9. aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +52 -0
  10. aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +124 -0
  11. aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +95 -0
  12. aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +104 -0
  13. aws_durable_execution_sdk_python_testing/checkpoint/validators/__init__.py +1 -0
  14. aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +242 -0
  15. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/__init__.py +1 -0
  16. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/callback.py +45 -0
  17. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/context.py +73 -0
  18. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/execution.py +47 -0
  19. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +56 -0
  20. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/step.py +106 -0
  21. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/wait.py +54 -0
  22. aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +66 -0
  23. aws_durable_execution_sdk_python_testing/cli.py +501 -0
  24. aws_durable_execution_sdk_python_testing/client.py +50 -0
  25. aws_durable_execution_sdk_python_testing/exceptions.py +288 -0
  26. aws_durable_execution_sdk_python_testing/execution.py +440 -0
  27. aws_durable_execution_sdk_python_testing/executor.py +1214 -0
  28. aws_durable_execution_sdk_python_testing/invoker.py +327 -0
  29. aws_durable_execution_sdk_python_testing/model.py +3267 -0
  30. aws_durable_execution_sdk_python_testing/observer.py +144 -0
  31. aws_durable_execution_sdk_python_testing/py.typed +1 -0
  32. aws_durable_execution_sdk_python_testing/runner.py +1185 -0
  33. aws_durable_execution_sdk_python_testing/scheduler.py +246 -0
  34. aws_durable_execution_sdk_python_testing/stores/__init__.py +1 -0
  35. aws_durable_execution_sdk_python_testing/stores/base.py +147 -0
  36. aws_durable_execution_sdk_python_testing/stores/filesystem.py +104 -0
  37. aws_durable_execution_sdk_python_testing/stores/memory.py +38 -0
  38. aws_durable_execution_sdk_python_testing/stores/sqlite.py +283 -0
  39. aws_durable_execution_sdk_python_testing/token.py +49 -0
  40. aws_durable_execution_sdk_python_testing/web/__init__.py +1 -0
  41. aws_durable_execution_sdk_python_testing/web/errors.py +8 -0
  42. aws_durable_execution_sdk_python_testing/web/handlers.py +807 -0
  43. aws_durable_execution_sdk_python_testing/web/models.py +266 -0
  44. aws_durable_execution_sdk_python_testing/web/routes.py +684 -0
  45. aws_durable_execution_sdk_python_testing/web/serialization.py +245 -0
  46. aws_durable_execution_sdk_python_testing/web/server.py +243 -0
  47. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/METADATA +221 -0
  48. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/RECORD +52 -0
  49. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/WHEEL +4 -0
  50. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/entry_points.txt +2 -0
  51. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/LICENSE +175 -0
  52. aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/NOTICE +1 -0
@@ -0,0 +1,1185 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ Any,
11
+ Concatenate,
12
+ ParamSpec,
13
+ Protocol,
14
+ Self,
15
+ TypeVar,
16
+ cast,
17
+ )
18
+
19
+ import aws_durable_execution_sdk_python
20
+ import boto3 # type: ignore
21
+ from botocore.exceptions import ClientError # type: ignore
22
+ from aws_durable_execution_sdk_python.execution import (
23
+ InvocationStatus,
24
+ durable_execution,
25
+ )
26
+ from aws_durable_execution_sdk_python.lambda_service import (
27
+ ErrorObject,
28
+ OperationPayload,
29
+ OperationStatus,
30
+ OperationSubType,
31
+ OperationType,
32
+ )
33
+ from aws_durable_execution_sdk_python.lambda_service import Operation as SvcOperation
34
+
35
+ from aws_durable_execution_sdk_python_testing.checkpoint.processor import (
36
+ CheckpointProcessor,
37
+ )
38
+ from aws_durable_execution_sdk_python_testing.client import InMemoryServiceClient
39
+ from aws_durable_execution_sdk_python_testing.exceptions import (
40
+ DurableFunctionsLocalRunnerError,
41
+ DurableFunctionsTestError,
42
+ InvalidParameterValueException,
43
+ ResourceNotFoundException,
44
+ )
45
+ from aws_durable_execution_sdk_python_testing.executor import Executor
46
+ from aws_durable_execution_sdk_python_testing.invoker import (
47
+ InProcessInvoker,
48
+ LambdaInvoker,
49
+ )
50
+ from aws_durable_execution_sdk_python_testing.model import (
51
+ GetDurableExecutionHistoryResponse,
52
+ GetDurableExecutionResponse,
53
+ StartDurableExecutionInput,
54
+ StartDurableExecutionOutput,
55
+ events_to_operations,
56
+ )
57
+ from aws_durable_execution_sdk_python_testing.scheduler import Scheduler
58
+ from aws_durable_execution_sdk_python_testing.stores.base import (
59
+ ExecutionStore,
60
+ StoreType,
61
+ )
62
+ from aws_durable_execution_sdk_python_testing.stores.filesystem import (
63
+ FileSystemExecutionStore,
64
+ )
65
+ from aws_durable_execution_sdk_python_testing.stores.memory import (
66
+ InMemoryExecutionStore,
67
+ )
68
+ from aws_durable_execution_sdk_python_testing.stores.sqlite import SQLiteExecutionStore
69
+ from aws_durable_execution_sdk_python_testing.web.server import WebServer
70
+
71
+
72
+ if TYPE_CHECKING:
73
+ import datetime
74
+ from collections.abc import Callable, MutableMapping
75
+
76
+ from aws_durable_execution_sdk_python.context import DurableContext
77
+ from aws_durable_execution_sdk_python.execution import InvocationStatus
78
+
79
+ from aws_durable_execution_sdk_python_testing.execution import Execution
80
+ from aws_durable_execution_sdk_python_testing.web.server import WebServiceConfig
81
+ from aws_durable_execution_sdk_python_testing.model import Event
82
+
83
+
84
+ logger = logging.getLogger(__name__)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class WebRunnerConfig:
89
+ """Configuration for the WebRunner using composition pattern.
90
+
91
+ This configuration class encapsulates all settings needed to run the web server
92
+ for durable functions testing, including HTTP server configuration and Lambda
93
+ service configuration.
94
+ """
95
+
96
+ # HTTP server configuration (existing WebServiceConfig)
97
+ web_service: WebServiceConfig
98
+
99
+ # Lambda service configuration (web runner specific)
100
+ lambda_endpoint: str = "http://127.0.0.1:3001"
101
+ local_runner_endpoint: str = "http://0.0.0.0:5000"
102
+ local_runner_region: str = "us-west-2"
103
+ local_runner_mode: str = "local"
104
+
105
+ # Store configuration
106
+ store_type: StoreType = StoreType.MEMORY
107
+ store_path: str | None = None # Path for filesystem store
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Operation:
112
+ operation_id: str
113
+ operation_type: OperationType
114
+ status: OperationStatus
115
+ parent_id: str | None = field(default=None, kw_only=True)
116
+ name: str | None = field(default=None, kw_only=True)
117
+ sub_type: OperationSubType | None = field(default=None, kw_only=True)
118
+ start_timestamp: datetime.datetime | None = field(default=None, kw_only=True)
119
+ end_timestamp: datetime.datetime | None = field(default=None, kw_only=True)
120
+
121
+
122
+ T = TypeVar("T", bound=Operation)
123
+ P = ParamSpec("P")
124
+
125
+
126
+ class OperationFactory(Protocol):
127
+ @staticmethod
128
+ def from_svc_operation(
129
+ operation: SvcOperation, all_operations: list[SvcOperation] | None = None
130
+ ) -> Operation: ...
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class ExecutionOperation(Operation):
135
+ input_payload: str | None = None
136
+
137
+ @staticmethod
138
+ def from_svc_operation(
139
+ operation: SvcOperation,
140
+ all_operations: list[SvcOperation] | None = None, # noqa: ARG004
141
+ ) -> ExecutionOperation:
142
+ if operation.operation_type != OperationType.EXECUTION:
143
+ msg: str = f"Expected EXECUTION operation, got {operation.operation_type}"
144
+ raise InvalidParameterValueException(msg)
145
+ return ExecutionOperation(
146
+ operation_id=operation.operation_id,
147
+ operation_type=operation.operation_type,
148
+ status=operation.status,
149
+ parent_id=operation.parent_id,
150
+ name=operation.name,
151
+ sub_type=operation.sub_type,
152
+ start_timestamp=operation.start_timestamp,
153
+ end_timestamp=operation.end_timestamp,
154
+ input_payload=(
155
+ operation.execution_details.input_payload
156
+ if operation.execution_details
157
+ else None
158
+ ),
159
+ )
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class ContextOperation(Operation):
164
+ child_operations: list[Operation]
165
+ result: OperationPayload | None = None
166
+ error: ErrorObject | None = None
167
+
168
+ @staticmethod
169
+ def from_svc_operation(
170
+ operation: SvcOperation, all_operations: list[SvcOperation] | None = None
171
+ ) -> ContextOperation:
172
+ if operation.operation_type != OperationType.CONTEXT:
173
+ msg: str = f"Expected CONTEXT operation, got {operation.operation_type}"
174
+ raise InvalidParameterValueException(msg)
175
+
176
+ child_operations = []
177
+ if all_operations:
178
+ child_operations = [
179
+ create_operation(op, all_operations)
180
+ for op in all_operations
181
+ if op.parent_id == operation.operation_id
182
+ ]
183
+
184
+ return ContextOperation(
185
+ operation_id=operation.operation_id,
186
+ operation_type=operation.operation_type,
187
+ status=operation.status,
188
+ parent_id=operation.parent_id,
189
+ name=operation.name,
190
+ sub_type=operation.sub_type,
191
+ start_timestamp=operation.start_timestamp,
192
+ end_timestamp=operation.end_timestamp,
193
+ child_operations=child_operations,
194
+ result=operation.context_details.result
195
+ if operation.context_details
196
+ else None,
197
+ error=operation.context_details.error
198
+ if operation.context_details
199
+ else None,
200
+ )
201
+
202
+ def get_operation_by_name(self, name: str) -> Operation:
203
+ for operation in self.child_operations:
204
+ if operation.name == name:
205
+ return operation
206
+ msg: str = f"Child Operation with name '{name}' not found"
207
+ raise DurableFunctionsTestError(msg)
208
+
209
+ def get_step(self, name: str) -> StepOperation:
210
+ return cast(StepOperation, self.get_operation_by_name(name))
211
+
212
+ def get_wait(self, name: str) -> WaitOperation:
213
+ return cast(WaitOperation, self.get_operation_by_name(name))
214
+
215
+ def get_context(self, name: str) -> ContextOperation:
216
+ return cast(ContextOperation, self.get_operation_by_name(name))
217
+
218
+ def get_callback(self, name: str) -> CallbackOperation:
219
+ return cast(CallbackOperation, self.get_operation_by_name(name))
220
+
221
+ def get_invoke(self, name: str) -> InvokeOperation:
222
+ return cast(InvokeOperation, self.get_operation_by_name(name))
223
+
224
+ def get_execution(self, name: str) -> ExecutionOperation:
225
+ return cast(ExecutionOperation, self.get_operation_by_name(name))
226
+
227
+
228
+ @dataclass(frozen=True)
229
+ class StepOperation(ContextOperation):
230
+ attempt: int = 0
231
+ next_attempt_timestamp: datetime.datetime | None = None
232
+ result: OperationPayload | None = None
233
+ error: ErrorObject | None = None
234
+
235
+ @staticmethod
236
+ def from_svc_operation(
237
+ operation: SvcOperation, all_operations: list[SvcOperation] | None = None
238
+ ) -> StepOperation:
239
+ if operation.operation_type != OperationType.STEP:
240
+ msg: str = f"Expected STEP operation, got {operation.operation_type}"
241
+ raise InvalidParameterValueException(msg)
242
+
243
+ child_operations = []
244
+ if all_operations:
245
+ child_operations = [
246
+ create_operation(op, all_operations)
247
+ for op in all_operations
248
+ if op.parent_id == operation.operation_id
249
+ ]
250
+
251
+ return StepOperation(
252
+ operation_id=operation.operation_id,
253
+ operation_type=operation.operation_type,
254
+ status=operation.status,
255
+ parent_id=operation.parent_id,
256
+ name=operation.name,
257
+ sub_type=operation.sub_type,
258
+ start_timestamp=operation.start_timestamp,
259
+ end_timestamp=operation.end_timestamp,
260
+ child_operations=child_operations,
261
+ attempt=operation.step_details.attempt if operation.step_details else 0,
262
+ next_attempt_timestamp=(
263
+ operation.step_details.next_attempt_timestamp
264
+ if operation.step_details
265
+ else None
266
+ ),
267
+ result=operation.step_details.result if operation.step_details else None,
268
+ error=operation.step_details.error if operation.step_details else None,
269
+ )
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class WaitOperation(Operation):
274
+ scheduled_end_timestamp: datetime.datetime | None = None
275
+
276
+ @staticmethod
277
+ def from_svc_operation(
278
+ operation: SvcOperation,
279
+ all_operations: list[SvcOperation] | None = None, # noqa: ARG004
280
+ ) -> WaitOperation:
281
+ if operation.operation_type != OperationType.WAIT:
282
+ msg: str = f"Expected WAIT operation, got {operation.operation_type}"
283
+ raise InvalidParameterValueException(msg)
284
+ return WaitOperation(
285
+ operation_id=operation.operation_id,
286
+ operation_type=operation.operation_type,
287
+ status=operation.status,
288
+ parent_id=operation.parent_id,
289
+ name=operation.name,
290
+ sub_type=operation.sub_type,
291
+ start_timestamp=operation.start_timestamp,
292
+ end_timestamp=operation.end_timestamp,
293
+ scheduled_end_timestamp=(
294
+ operation.wait_details.scheduled_end_timestamp
295
+ if operation.wait_details
296
+ else None
297
+ ),
298
+ )
299
+
300
+
301
+ @dataclass(frozen=True)
302
+ class CallbackOperation(ContextOperation):
303
+ callback_id: str | None = None
304
+ result: OperationPayload | None = None
305
+ error: ErrorObject | None = None
306
+
307
+ @staticmethod
308
+ def from_svc_operation(
309
+ operation: SvcOperation, all_operations: list[SvcOperation] | None = None
310
+ ) -> CallbackOperation:
311
+ if operation.operation_type != OperationType.CALLBACK:
312
+ msg: str = f"Expected CALLBACK operation, got {operation.operation_type}"
313
+ raise InvalidParameterValueException(msg)
314
+
315
+ child_operations = []
316
+ if all_operations:
317
+ child_operations = [
318
+ create_operation(op, all_operations)
319
+ for op in all_operations
320
+ if op.parent_id == operation.operation_id
321
+ ]
322
+
323
+ return CallbackOperation(
324
+ operation_id=operation.operation_id,
325
+ operation_type=operation.operation_type,
326
+ status=operation.status,
327
+ parent_id=operation.parent_id,
328
+ name=operation.name,
329
+ sub_type=operation.sub_type,
330
+ start_timestamp=operation.start_timestamp,
331
+ end_timestamp=operation.end_timestamp,
332
+ child_operations=child_operations,
333
+ callback_id=(
334
+ operation.callback_details.callback_id
335
+ if operation.callback_details
336
+ else None
337
+ ),
338
+ result=operation.callback_details.result
339
+ if operation.callback_details
340
+ else None,
341
+ error=operation.callback_details.error
342
+ if operation.callback_details
343
+ else None,
344
+ )
345
+
346
+
347
+ @dataclass(frozen=True)
348
+ class InvokeOperation(Operation):
349
+ result: OperationPayload | None = None
350
+ error: ErrorObject | None = None
351
+
352
+ @staticmethod
353
+ def from_svc_operation(
354
+ operation: SvcOperation,
355
+ all_operations: list[SvcOperation] | None = None, # noqa: ARG004
356
+ ) -> InvokeOperation:
357
+ if operation.operation_type != OperationType.CHAINED_INVOKE:
358
+ msg: str = f"Expected INVOKE operation, got {operation.operation_type}"
359
+ raise InvalidParameterValueException(msg)
360
+ return InvokeOperation(
361
+ operation_id=operation.operation_id,
362
+ operation_type=operation.operation_type,
363
+ status=operation.status,
364
+ parent_id=operation.parent_id,
365
+ name=operation.name,
366
+ sub_type=operation.sub_type,
367
+ start_timestamp=operation.start_timestamp,
368
+ end_timestamp=operation.end_timestamp,
369
+ result=operation.chained_invoke_details.result
370
+ if operation.chained_invoke_details
371
+ else None,
372
+ error=operation.chained_invoke_details.error
373
+ if operation.chained_invoke_details
374
+ else None,
375
+ )
376
+
377
+
378
+ OPERATION_FACTORIES: MutableMapping[OperationType, type[OperationFactory]] = {
379
+ OperationType.EXECUTION: ExecutionOperation,
380
+ OperationType.CONTEXT: ContextOperation,
381
+ OperationType.STEP: StepOperation,
382
+ OperationType.WAIT: WaitOperation,
383
+ OperationType.CHAINED_INVOKE: InvokeOperation,
384
+ OperationType.CALLBACK: CallbackOperation,
385
+ }
386
+
387
+
388
+ def create_operation(
389
+ svc_operation: SvcOperation, all_operations: list[SvcOperation] | None = None
390
+ ) -> Operation:
391
+ operation_class: type[OperationFactory] | None = OPERATION_FACTORIES.get(
392
+ svc_operation.operation_type
393
+ )
394
+ if not operation_class:
395
+ msg: str = f"Unknown operation type: {svc_operation.operation_type}"
396
+ raise DurableFunctionsTestError(msg)
397
+ return operation_class.from_svc_operation(svc_operation, all_operations)
398
+
399
+
400
+ def _get_callback_id_from_events(
401
+ events: list[Event], name: str | None = None
402
+ ) -> str | None:
403
+ """
404
+ Get callback ID from execution history for callbacks that haven't completed.
405
+
406
+ Args:
407
+ execution_arn: The ARN of the execution to query.
408
+ name: Optional callback name to search for. If not provided, returns the latest callback.
409
+
410
+ Returns:
411
+ The callback ID string for a non-completed callback, or None if not found.
412
+
413
+ Raises:
414
+ DurableFunctionsTestError: If the named callback has already succeeded/failed/timed out.
415
+ """
416
+ callback_started_events = [
417
+ event for event in events if event.event_type == "CallbackStarted"
418
+ ]
419
+
420
+ if not callback_started_events:
421
+ return None
422
+
423
+ completed_callback_ids = {
424
+ event.event_id
425
+ for event in events
426
+ if event.event_type
427
+ in ["CallbackSucceeded", "CallbackFailed", "CallbackTimedOut"]
428
+ }
429
+
430
+ if name is not None:
431
+ for event in callback_started_events:
432
+ if event.name == name:
433
+ callback_id = event.event_id
434
+ if callback_id in completed_callback_ids:
435
+ raise DurableFunctionsTestError(
436
+ f"Callback {name} has already completed (succeeded/failed/timed out)"
437
+ )
438
+ return (
439
+ event.callback_started_details.callback_id
440
+ if event.callback_started_details
441
+ else None
442
+ )
443
+ return None
444
+
445
+ # If name is not provided, find the latest non-completed callback event
446
+ active_callbacks = [
447
+ event
448
+ for event in callback_started_events
449
+ if event.event_id not in completed_callback_ids
450
+ ]
451
+
452
+ if not active_callbacks:
453
+ return None
454
+
455
+ latest_event = active_callbacks[-1]
456
+ return (
457
+ latest_event.callback_started_details.callback_id
458
+ if latest_event.callback_started_details
459
+ else None
460
+ )
461
+
462
+
463
+ @dataclass(frozen=True)
464
+ class DurableFunctionTestResult:
465
+ status: InvocationStatus
466
+ operations: list[Operation]
467
+ result: OperationPayload | None = None
468
+ error: ErrorObject | None = None
469
+
470
+ @classmethod
471
+ def create(cls, execution: Execution) -> DurableFunctionTestResult:
472
+ operations = []
473
+ for operation in execution.operations:
474
+ if operation.operation_type is OperationType.EXECUTION:
475
+ # don't want the EXECUTION operations in the list test code asserts against
476
+ continue
477
+
478
+ if operation.parent_id is None:
479
+ operations.append(create_operation(operation, execution.operations))
480
+
481
+ if execution.result is None:
482
+ msg: str = "Execution result must exist to create test result."
483
+ raise DurableFunctionsTestError(msg)
484
+
485
+ return cls(
486
+ status=execution.result.status,
487
+ operations=operations,
488
+ result=execution.result.result,
489
+ error=execution.result.error,
490
+ )
491
+
492
+ @classmethod
493
+ def from_execution_history(
494
+ cls,
495
+ execution_response: GetDurableExecutionResponse,
496
+ history_response: GetDurableExecutionHistoryResponse,
497
+ ) -> DurableFunctionTestResult:
498
+ """Create test result from execution history responses.
499
+
500
+ Factory method for cloud runner that builds DurableFunctionTestResult
501
+ from GetDurableExecution and GetDurableExecutionHistory API responses.
502
+ """
503
+ # Map status string to InvocationStatus enum
504
+ try:
505
+ status = InvocationStatus[execution_response.status]
506
+ except KeyError:
507
+ logger.warning(
508
+ "Unknown status: %s, defaulting to FAILED", execution_response.status
509
+ )
510
+ status = InvocationStatus.FAILED
511
+
512
+ # Convert Events to Operations - group by operation_id and merge
513
+ try:
514
+ svc_operations = events_to_operations(history_response.events)
515
+ except Exception as e:
516
+ logger.warning("Failed to convert events to operations: %s", e)
517
+ svc_operations = []
518
+
519
+ # Build operation tree (exclude EXECUTION type from top level)
520
+ operations = []
521
+ for svc_op in svc_operations:
522
+ if svc_op.operation_type == OperationType.EXECUTION:
523
+ continue
524
+ if svc_op.parent_id is None:
525
+ operations.append(create_operation(svc_op, svc_operations))
526
+
527
+ return cls(
528
+ status=status,
529
+ operations=operations,
530
+ result=execution_response.result,
531
+ error=execution_response.error,
532
+ )
533
+
534
+ def get_operation_by_name(self, name: str) -> Operation:
535
+ for operation in self.operations:
536
+ if operation.name == name:
537
+ return operation
538
+ msg: str = f"Operation with name '{name}' not found"
539
+ raise DurableFunctionsTestError(msg)
540
+
541
+ def get_step(self, name: str) -> StepOperation:
542
+ return cast(StepOperation, self.get_operation_by_name(name))
543
+
544
+ def get_wait(self, name: str) -> WaitOperation:
545
+ return cast(WaitOperation, self.get_operation_by_name(name))
546
+
547
+ def get_context(self, name: str) -> ContextOperation:
548
+ return cast(ContextOperation, self.get_operation_by_name(name))
549
+
550
+ def get_callback(self, name: str) -> CallbackOperation:
551
+ return cast(CallbackOperation, self.get_operation_by_name(name))
552
+
553
+ def get_invoke(self, name: str) -> InvokeOperation:
554
+ return cast(InvokeOperation, self.get_operation_by_name(name))
555
+
556
+ def get_execution(self, name: str) -> ExecutionOperation:
557
+ return cast(ExecutionOperation, self.get_operation_by_name(name))
558
+
559
+ def get_all_operations(self) -> list[Operation]:
560
+ """Recursively get all operations including nested ones."""
561
+ all_ops = []
562
+ stack = list(self.operations)
563
+ while stack:
564
+ op = stack.pop()
565
+ all_ops.append(op)
566
+ # Add child operations to stack (if they exist)
567
+ if hasattr(op, "child_operations") and op.child_operations:
568
+ stack.extend(op.child_operations)
569
+ return all_ops
570
+
571
+
572
+ class DurableFunctionTestRunner:
573
+ def __init__(self, handler: Callable, poll_interval: float = 1.0):
574
+ self._scheduler: Scheduler = Scheduler()
575
+ self._scheduler.start()
576
+ self._store = InMemoryExecutionStore()
577
+ self.poll_interval = poll_interval
578
+ self._checkpoint_processor = CheckpointProcessor(
579
+ store=self._store, scheduler=self._scheduler
580
+ )
581
+ self._service_client = InMemoryServiceClient(self._checkpoint_processor)
582
+ self._invoker = InProcessInvoker(handler, self._service_client)
583
+ self._executor = Executor(
584
+ store=self._store,
585
+ scheduler=self._scheduler,
586
+ invoker=self._invoker,
587
+ checkpoint_processor=self._checkpoint_processor,
588
+ )
589
+
590
+ # Wire up observer pattern - CheckpointProcessor uses this to notify executor of state changes
591
+ self._checkpoint_processor.add_execution_observer(self._executor)
592
+
593
+ def __enter__(self):
594
+ return self
595
+
596
+ def __exit__(self, exc_type, exc_val, exc_tb):
597
+ self.close()
598
+
599
+ def close(self):
600
+ self._scheduler.stop()
601
+
602
+ def run(
603
+ self,
604
+ input: str | None = None, # noqa: A002
605
+ timeout: int = 900,
606
+ function_name: str = "test-function",
607
+ execution_name: str = "execution-name",
608
+ account_id: str = "123456789012",
609
+ ) -> DurableFunctionTestResult:
610
+ execution_arn = self.run_async(
611
+ input=input,
612
+ timeout=timeout,
613
+ function_name=function_name,
614
+ execution_name=execution_name,
615
+ account_id=account_id,
616
+ )
617
+
618
+ return self.wait_for_result(execution_arn=execution_arn, timeout=timeout)
619
+
620
+ def send_callback_success(
621
+ self, callback_id: str, result: bytes | None = None
622
+ ) -> None:
623
+ self._executor.send_callback_success(callback_id=callback_id, result=result)
624
+
625
+ def send_callback_failure(
626
+ self, callback_id: str, error: ErrorObject | None = None
627
+ ) -> None:
628
+ self._executor.send_callback_failure(callback_id=callback_id, error=error)
629
+
630
+ def send_callback_heartbeat(self, callback_id: str) -> None:
631
+ self._executor.send_callback_heartbeat(callback_id=callback_id)
632
+
633
+ def run_async(
634
+ self,
635
+ input: str | None = None, # noqa: A002
636
+ timeout: int = 900,
637
+ function_name: str = "test-function",
638
+ execution_name: str = "execution-name",
639
+ account_id: str = "123456789012",
640
+ ) -> str:
641
+ start_input = StartDurableExecutionInput(
642
+ account_id=account_id,
643
+ function_name=function_name,
644
+ function_qualifier="$LATEST",
645
+ execution_name=execution_name,
646
+ execution_timeout_seconds=timeout,
647
+ execution_retention_period_days=7,
648
+ invocation_id="inv-12345678-1234-1234-1234-123456789012",
649
+ trace_fields={"trace_id": "abc123", "span_id": "def456"},
650
+ tenant_id="tenant-001",
651
+ input=input,
652
+ )
653
+
654
+ output: StartDurableExecutionOutput = self._executor.start_execution(
655
+ start_input
656
+ )
657
+
658
+ if output.execution_arn is None:
659
+ msg_arn: str = "Execution ARN must exist to run test."
660
+ raise DurableFunctionsTestError(msg_arn)
661
+ return output.execution_arn
662
+
663
+ def wait_for_result(
664
+ self, execution_arn: str, timeout: int = 60
665
+ ) -> DurableFunctionTestResult:
666
+ # Block until completion
667
+ completed = self._executor.wait_until_complete(execution_arn, timeout)
668
+
669
+ if not completed:
670
+ msg_timeout: str = "Execution did not complete within timeout"
671
+
672
+ raise TimeoutError(msg_timeout)
673
+
674
+ execution: Execution = self._store.load(execution_arn)
675
+ return DurableFunctionTestResult.create(execution=execution)
676
+
677
+ def wait_for_callback(
678
+ self, execution_arn: str, name: str | None = None, timeout: int = 60
679
+ ) -> str:
680
+ start_time = time.time()
681
+
682
+ while time.time() - start_time < timeout:
683
+ try:
684
+ history_response = self._executor.get_execution_history(execution_arn)
685
+ callback_id = _get_callback_id_from_events(
686
+ events=history_response.events, name=name
687
+ )
688
+ if callback_id:
689
+ return callback_id
690
+ except ResourceNotFoundException as e:
691
+ pass
692
+ except Exception as e:
693
+ msg = f"Failed to fetch execution history: {e}"
694
+ raise DurableFunctionsTestError(msg) from e
695
+
696
+ # Wait before next poll
697
+ time.sleep(self.poll_interval)
698
+
699
+ # Timeout reached
700
+ elapsed = time.time() - start_time
701
+ msg = (
702
+ f"Callback did not available within {timeout}s "
703
+ f"(elapsed: {elapsed:.1f}s."
704
+ )
705
+ raise TimeoutError(msg)
706
+
707
+
708
+ class DurableChildContextTestRunner(DurableFunctionTestRunner):
709
+ """Test a durable block, annotated with @durable_with_child_context, in isolation."""
710
+
711
+ def __init__(
712
+ self,
713
+ context_function: Callable[Concatenate[DurableContext, P], Any],
714
+ *args,
715
+ **kwargs,
716
+ ):
717
+ # wrap the durable context around a durable execution handler as a convenience to run directly
718
+ @durable_execution
719
+ def handler(event: Any, context: DurableContext): # noqa: ARG001
720
+ return context_function(*args, **kwargs)(context)
721
+
722
+ super().__init__(handler)
723
+
724
+
725
+ class WebRunner:
726
+ """Web server runner for durable functions testing with HTTP API endpoints."""
727
+
728
+ def __init__(self, config: WebRunnerConfig) -> None:
729
+ """Initialize WebRunner with configuration.
730
+
731
+ Args:
732
+ config: WebRunnerConfig containing server and Lambda service settings
733
+ """
734
+ self._config = config
735
+ self._server: WebServer | None = None
736
+ self._scheduler: Scheduler | None = None
737
+ self._store: ExecutionStore | None = None
738
+ self._invoker: LambdaInvoker | None = None
739
+ self._executor: Executor | None = None
740
+
741
+ def __enter__(self) -> Self:
742
+ """Context manager entry point.
743
+
744
+ Returns:
745
+ WebRunner: Self for use in with statement
746
+ """
747
+ self.start()
748
+ return self
749
+
750
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
751
+ """Context manager exit point with cleanup.
752
+
753
+ Args:
754
+ exc_type: Exception type if an exception occurred
755
+ exc_val: Exception value if an exception occurred
756
+ exc_tb: Exception traceback if an exception occurred
757
+ """
758
+ self.stop()
759
+
760
+ def start(self) -> None:
761
+ """Start the server and initialize all dependencies.
762
+
763
+ Creates and configures all required components including scheduler,
764
+ store, invoker, executor, and web server. It does not however start
765
+ serving web requests, for that you need serve_forever.
766
+
767
+ Raises:
768
+ DurableFunctionsLocalRunnerError: If server is already started
769
+ """
770
+ if self._server is not None:
771
+ msg = "Server is already running"
772
+ raise DurableFunctionsLocalRunnerError(msg)
773
+
774
+ # Create dependencies and server
775
+ if self._config.store_type == StoreType.SQLITE:
776
+ store_path = self._config.store_path
777
+ self._store = SQLiteExecutionStore.create_and_initialize(store_path)
778
+ elif self._config.store_type == StoreType.FILESYSTEM:
779
+ store_path = self._config.store_path or ".durable_executions"
780
+ self._store = FileSystemExecutionStore.create(store_path)
781
+ else:
782
+ self._store = InMemoryExecutionStore()
783
+ self._scheduler = Scheduler()
784
+ self._invoker = LambdaInvoker(self._create_boto3_client())
785
+
786
+ # Create shared CheckpointProcessor
787
+ checkpoint_processor = CheckpointProcessor(self._store, self._scheduler)
788
+
789
+ # Create executor with all dependencies including checkpoint processor
790
+ self._executor = Executor(
791
+ store=self._store,
792
+ scheduler=self._scheduler,
793
+ invoker=self._invoker,
794
+ checkpoint_processor=checkpoint_processor,
795
+ )
796
+
797
+ # Add executor as observer to the checkpoint processor
798
+ checkpoint_processor.add_execution_observer(self._executor)
799
+
800
+ # Start the scheduler
801
+ self._scheduler.start()
802
+
803
+ # Create web server with configuration and executor
804
+ self._server = WebServer(
805
+ config=self._config.web_service, executor=self._executor
806
+ )
807
+
808
+ def serve_forever(self) -> None:
809
+ """Start serving HTTP requests indefinitely.
810
+
811
+ Delegates to the underlying WebServer.serve_forever() method.
812
+ This method blocks until the server is stopped.
813
+
814
+ Raises:
815
+ DurableFunctionsLocalRunnerError: If server has not been started
816
+ """
817
+ if self._server is None:
818
+ msg = "Server not started"
819
+ raise DurableFunctionsLocalRunnerError(msg)
820
+
821
+ # This blocks until KeyboardInterrupt - let caller handle the exception
822
+ self._server.serve_forever()
823
+
824
+ def stop(self) -> None:
825
+ """Stop the web server and cleanup resources.
826
+
827
+ Gracefully shuts down the server, scheduler, and cleans up
828
+ all allocated resources. Safe to call multiple times.
829
+ Handles cleanup exceptions gracefully to ensure all resources
830
+ are cleaned up even if some fail.
831
+ """
832
+ if self._server is not None:
833
+ try:
834
+ self._server.server_close()
835
+ except Exception:
836
+ # Log the exception but continue cleanup
837
+ logger.exception("error closing web server")
838
+
839
+ self._server = None
840
+
841
+ if self._scheduler is not None:
842
+ try:
843
+ self._scheduler.stop()
844
+ except Exception:
845
+ logger.exception("error stopping scheduler")
846
+ self._scheduler = None
847
+
848
+ self._store = None
849
+ self._invoker = None
850
+ self._executor = None
851
+
852
+ def _create_boto3_client(self) -> Any:
853
+ """Create boto3 client for lambdainternal service.
854
+
855
+ Configures AWS data path and creates a boto3 client with the
856
+ local runner endpoint and region from configuration.
857
+
858
+ Returns:
859
+ Configured boto3 client for lambdainternal service
860
+
861
+ Raises:
862
+ Exception: If client creation fails - exceptions propagate naturally
863
+ for CLI to handle as general Exception
864
+ """
865
+ # Set up AWS data path for boto models
866
+ package_path = os.path.dirname(aws_durable_execution_sdk_python.__file__)
867
+ data_path = f"{package_path}/botocore/data"
868
+ os.environ["AWS_DATA_PATH"] = data_path
869
+
870
+ # Create client with Lambda endpoint configuration
871
+ return boto3.client(
872
+ "lambdainternal",
873
+ endpoint_url=self._config.lambda_endpoint,
874
+ region_name=self._config.local_runner_region,
875
+ )
876
+
877
+
878
+ class DurableFunctionCloudTestRunner:
879
+ """Test runner that executes durable functions against actual AWS Lambda backend.
880
+
881
+ This runner invokes deployed Lambda functions and polls for execution completion,
882
+ providing the same interface as DurableFunctionTestRunner for seamless test
883
+ compatibility between local and cloud modes.
884
+
885
+ Example:
886
+ >>> runner = DurableFunctionCloudTestRunner(
887
+ ... function_name="HelloWorld-Python-PR-123", region="us-west-2"
888
+ ... )
889
+ >>> with runner:
890
+ ... result = runner.run(input={"name": "World"}, timeout=60)
891
+ >>> assert result.current_status == InvocationStatus.SUCCEEDED
892
+ """
893
+
894
+ def __init__(
895
+ self,
896
+ function_name: str,
897
+ region: str = "us-west-2",
898
+ lambda_endpoint: str | None = None,
899
+ poll_interval: float = 1.0,
900
+ ):
901
+ """Initialize cloud test runner."""
902
+ self.function_name = function_name
903
+ self.region = region
904
+ self.lambda_endpoint = lambda_endpoint
905
+ self.poll_interval = poll_interval
906
+
907
+ # Set up AWS data path for custom boto models (durable execution fields)
908
+ package_path = os.path.dirname(aws_durable_execution_sdk_python.__file__)
909
+ data_path = f"{package_path}/botocore/data"
910
+ os.environ["AWS_DATA_PATH"] = data_path
911
+
912
+ client_config = boto3.session.Config(parameter_validation=False)
913
+ self.lambda_client = boto3.client(
914
+ "lambdainternal",
915
+ endpoint_url=lambda_endpoint,
916
+ region_name=region,
917
+ config=client_config,
918
+ )
919
+
920
+ def run(
921
+ self,
922
+ input: str | None = None, # noqa: A002
923
+ timeout: int = 60,
924
+ ) -> DurableFunctionTestResult:
925
+ """Execute function on AWS Lambda and wait for completion."""
926
+ logger.info(
927
+ "Invoking Lambda function: %s (timeout: %ds)", self.function_name, timeout
928
+ )
929
+
930
+ # JSON encode input
931
+ payload = json.dumps(input)
932
+
933
+ # Invoke Lambda function
934
+ try:
935
+ response = self.lambda_client.invoke(
936
+ FunctionName=self.function_name,
937
+ InvocationType="RequestResponse",
938
+ Payload=payload,
939
+ )
940
+ except Exception as e:
941
+ msg = f"Failed to invoke Lambda function {self.function_name}: {e}"
942
+ raise DurableFunctionsTestError(msg) from e
943
+
944
+ # Check HTTP status code, 200 for RequestResponse
945
+ status_code = response.get("StatusCode")
946
+ if status_code != 200:
947
+ error_payload = response["Payload"].read().decode("utf-8")
948
+ msg = f"Lambda invocation failed with status {status_code}: {error_payload}"
949
+ raise DurableFunctionsTestError(msg)
950
+
951
+ # Check for function errors, we want to return function error for testing purpose
952
+ if "FunctionError" in response:
953
+ error_payload = response["Payload"].read().decode("utf-8")
954
+ logger.warning("Lambda function failed: %s", error_payload)
955
+
956
+ result_payload = response["Payload"].read().decode("utf-8")
957
+ logger.info(
958
+ "Lambda invocation completed, response: %s",
959
+ result_payload,
960
+ )
961
+
962
+ # Extract durable execution ARN from response headers
963
+ # The InvocationResponse includes X-Amz-Durable-Execution-Arn header
964
+ execution_arn = response.get("DurableExecutionArn")
965
+ if not execution_arn:
966
+ msg = (
967
+ f"No DurableExecutionArn in response for function {self.function_name}"
968
+ )
969
+ raise DurableFunctionsTestError(msg)
970
+
971
+ return self.wait_for_result(execution_arn=execution_arn, timeout=timeout)
972
+
973
+ def run_async(
974
+ self,
975
+ input: str | None = None, # noqa: A002
976
+ timeout: int = 60,
977
+ ) -> str:
978
+ """Execute function on AWS Lambda asynchronously"""
979
+ logger.info(
980
+ "Invoking Lambda function: %s (timeout: %ds)", self.function_name, timeout
981
+ )
982
+ payload = json.dumps(input)
983
+ try:
984
+ response = self.lambda_client.invoke(
985
+ FunctionName=self.function_name,
986
+ InvocationType="Event",
987
+ Payload=payload,
988
+ )
989
+ except Exception as e:
990
+ msg = f"Failed to invoke Lambda function {self.function_name}: {e}"
991
+ raise DurableFunctionsTestError(msg) from e
992
+
993
+ # Check HTTP status code, 202 for Event
994
+ status_code = response.get("StatusCode")
995
+ if status_code != 202:
996
+ error_payload = response["Payload"].read().decode("utf-8")
997
+ msg = f"Lambda invocation failed with status {status_code}: {error_payload}"
998
+ raise DurableFunctionsTestError(msg)
999
+
1000
+ return response.get("DurableExecutionArn")
1001
+
1002
+ def send_callback_success(
1003
+ self, callback_id: str, result: bytes | None = None
1004
+ ) -> None:
1005
+ try:
1006
+ self.lambda_client.send_durable_execution_callback_success(
1007
+ CallbackId=callback_id, Result=result
1008
+ )
1009
+ except Exception as e:
1010
+ msg = f"Failed to send callback success for {self.function_name}, callback_id {callback_id}: {e}"
1011
+ raise DurableFunctionsTestError(msg) from e
1012
+
1013
+ def send_callback_failure(
1014
+ self, callback_id: str, error: ErrorObject | None = None
1015
+ ) -> None:
1016
+ try:
1017
+ self.lambda_client.send_durable_execution_callback_failure(
1018
+ CallbackId=callback_id, Error=error.to_dict() if error else None
1019
+ )
1020
+ except Exception as e:
1021
+ msg = f"Failed to send callback failure for {self.function_name}, callback_id {callback_id}: {e}"
1022
+ raise DurableFunctionsTestError(msg) from e
1023
+
1024
+ def send_callback_heartbeat(self, callback_id: str) -> None:
1025
+ try:
1026
+ self.lambda_client.send_durable_execution_callback_heartbeat(
1027
+ CallbackId=callback_id
1028
+ )
1029
+ except Exception as e:
1030
+ msg = f"Failed to send callback heartbeat for {self.function_name}, callback_id {callback_id}: {e}"
1031
+ raise DurableFunctionsTestError(msg) from e
1032
+
1033
+ def _wait_for_completion(
1034
+ self, execution_arn: str, timeout: int
1035
+ ) -> GetDurableExecutionResponse:
1036
+ """Poll execution status until completion or timeout.
1037
+
1038
+ Args:
1039
+ execution_arn: ARN of the durable execution
1040
+ timeout: Maximum seconds to wait
1041
+
1042
+ Returns:
1043
+ GetDurableExecutionResponse with typed execution details
1044
+
1045
+ Raises:
1046
+ TimeoutError: If execution doesn't complete within timeout
1047
+ DurableFunctionsTestError: If status check fails
1048
+ """
1049
+ start_time = time.time()
1050
+ last_status = None
1051
+
1052
+ while time.time() - start_time < timeout:
1053
+ try:
1054
+ execution_dict = self.lambda_client.get_durable_execution(
1055
+ DurableExecutionArn=execution_arn
1056
+ )
1057
+ execution = GetDurableExecutionResponse.from_dict(execution_dict)
1058
+ except Exception as e:
1059
+ msg = f"Failed to get execution status: {e}"
1060
+ raise DurableFunctionsTestError(msg) from e
1061
+
1062
+ # Log status changes
1063
+ if execution.status != last_status:
1064
+ logger.info("Execution status: %s", execution.status)
1065
+ last_status = execution.status
1066
+
1067
+ # Check if execution completed
1068
+ if execution.status == "SUCCEEDED":
1069
+ logger.info("Execution succeeded")
1070
+ return execution
1071
+ if execution.status == "FAILED":
1072
+ logger.warning("Execution failed")
1073
+ return execution
1074
+ if execution.status in ["TIMED_OUT", "ABORTED"]:
1075
+ logger.warning("Execution terminated: %s", execution.status)
1076
+ return execution
1077
+
1078
+ # Wait before next poll
1079
+ time.sleep(self.poll_interval)
1080
+
1081
+ # Timeout reached
1082
+ elapsed = time.time() - start_time
1083
+ msg = (
1084
+ f"Execution did not complete within {timeout}s "
1085
+ f"(elapsed: {elapsed:.1f}s, last status: {last_status})"
1086
+ )
1087
+ raise TimeoutError(msg)
1088
+
1089
+ def wait_for_result(
1090
+ self, execution_arn: str, timeout: int = 60
1091
+ ) -> DurableFunctionTestResult:
1092
+ # Poll for completion
1093
+ execution_response = self._wait_for_completion(execution_arn, timeout)
1094
+
1095
+ try:
1096
+ history_response = self._fetch_execution_history(execution_arn)
1097
+ except Exception as e:
1098
+ msg = f"Failed to fetch execution history: {e}"
1099
+ raise DurableFunctionsTestError(msg) from e
1100
+
1101
+ # Build test result from execution history
1102
+ return DurableFunctionTestResult.from_execution_history(
1103
+ execution_response, history_response
1104
+ )
1105
+
1106
+ def wait_for_callback(
1107
+ self, execution_arn: str, name: str | None = None, timeout: int = 60
1108
+ ) -> str:
1109
+ """
1110
+ Wait for and retrieve a callback ID from a Step Functions execution.
1111
+
1112
+ Polls the execution history at regular intervals until a callback ID is found
1113
+ or the timeout is reached.
1114
+
1115
+ Args:
1116
+ execution_arn: Execution Arn
1117
+ name: Specific callback name, default to None
1118
+ timeout: Maximum time in seconds to wait for callback. Defaults to 60.
1119
+
1120
+ Returns:
1121
+ str: The callback ID/token retrieved from the execution history
1122
+
1123
+ Raises:
1124
+ TimeoutError: If callback is not found within the specified timeout period
1125
+ DurableFunctionsTestError: If there's an error fetching execution history
1126
+ (excluding retryable errors)
1127
+ """
1128
+ start_time = time.time()
1129
+
1130
+ while time.time() - start_time < timeout:
1131
+ try:
1132
+ history_response = self._fetch_execution_history(execution_arn)
1133
+ callback_id = _get_callback_id_from_events(
1134
+ events=history_response.events, name=name
1135
+ )
1136
+ if callback_id:
1137
+ return callback_id
1138
+ except ClientError as e:
1139
+ error_code = e.response["Error"]["Code"]
1140
+ # retryable error, the execution may not start yet in async invoke situation
1141
+ if error_code in ["ResourceNotFoundException"]:
1142
+ pass
1143
+ else:
1144
+ msg = f"Failed to fetch execution history: {e}"
1145
+ raise DurableFunctionsTestError(msg) from e
1146
+ except DurableFunctionsTestError as e:
1147
+ raise e
1148
+ except Exception as e:
1149
+ msg = f"Failed to fetch execution history: {e}"
1150
+ raise DurableFunctionsTestError(msg) from e
1151
+
1152
+ # Wait before next poll
1153
+ time.sleep(self.poll_interval)
1154
+
1155
+ # Timeout reached
1156
+ elapsed = time.time() - start_time
1157
+ msg = (
1158
+ f"Callback did not available within {timeout}s "
1159
+ f"(elapsed: {elapsed:.1f}s."
1160
+ )
1161
+ raise TimeoutError(msg)
1162
+
1163
+ def _fetch_execution_history(
1164
+ self, execution_arn: str
1165
+ ) -> GetDurableExecutionHistoryResponse:
1166
+ """Retrieve execution history from Lambda service.
1167
+
1168
+ Args:
1169
+ execution_arn: ARN of the durable execution
1170
+
1171
+ Returns:
1172
+ GetDurableExecutionHistoryResponse with typed Event objects
1173
+
1174
+ Raises:
1175
+ ClientError: If lambda client encounter error
1176
+ """
1177
+ history_dict = self.lambda_client.get_durable_execution_history(
1178
+ DurableExecutionArn=execution_arn,
1179
+ IncludeExecutionData=True,
1180
+ )
1181
+ history_response = GetDurableExecutionHistoryResponse.from_dict(history_dict)
1182
+
1183
+ logger.info("Retrieved %d events from history", len(history_response.events))
1184
+
1185
+ return history_response