Coverage for async_durable_execution/_primitive/step.py: 88%

263 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-08-30 23:43 +0000

1"""Implement the Durable step operation.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import logging 

7from dataclasses import dataclass 

8from enum import Enum 

9from typing import TYPE_CHECKING, TypeVar, cast 

10 

11from .base import OperationExecutor 

12from .._core import ( 

13 CallableRuntimeError, 

14 Duration, 

15 DurableContext, 

16 ErrorObject, 

17 ExecutionError, 

18 ExecutionState, 

19 InvocationError, 

20 Operation, 

21 OperationContext, 

22 OperationIdentifier, 

23 OperationStatus, 

24 OperationType, 

25 OperationUpdate, 

26 RetryStrategy, 

27 SerDes, 

28 TerminationReason, 

29 _encode_sdk_control_error_data, 

30 _restore_sdk_control_error, 

31 bind_current_context, 

32 duration_to_seconds, 

33 get_current_context, 

34 suspend_with_optional_resume_delay, 

35 suspend_with_optional_resume_timestamp, 

36) 

37 

38if TYPE_CHECKING: 

39 from collections.abc import Awaitable, Callable 

40 

41 from ..extension import ( 

42 ExtensionStepFunction, 

43 ExtensionStepResult, 

44 ExtensionStepRetryStrategy, 

45 ) 

46 

47logger = logging.getLogger(__name__) 

48 

49T = TypeVar("T") 

50 

51 

52def _error_object_from_exception( 

53 error: Exception, 

54 *, 

55 invocation_retryable: bool | None = None, 

56) -> ErrorObject: 

57 error_object = ErrorObject.from_exception(error) 

58 sdk_error_data = _encode_sdk_control_error_data( 

59 error, 

60 invocation_retryable=invocation_retryable, 

61 ) 

62 if sdk_error_data is None: 

63 return error_object 

64 return ErrorObject( 

65 message=error_object.message, 

66 type=error_object.type, 

67 data=sdk_error_data, 

68 stack_trace=error_object.stack_trace, 

69 ) 

70 

71 

72class StepInterruptedError(InvocationError): 

73 """Raised when a step is interrupted before it checkpointed at the end.""" 

74 

75 def __init__(self, message: str, step_id: str | None = None) -> None: 

76 super().__init__(message, TerminationReason.STEP_INTERRUPTED) 

77 self.step_id = step_id 

78 

79 

80class StepSemantics(Enum): 

81 """Checkpoint timing guarantees for a durable step attempt.""" 

82 

83 AT_MOST_ONCE_PER_RETRY = "AT_MOST_ONCE_PER_RETRY" 

84 AT_LEAST_ONCE_PER_RETRY = "AT_LEAST_ONCE_PER_RETRY" 

85 

86 

87class StepOperationExecutor(OperationExecutor[T]): 

88 """Executor for step operations.""" 

89 

90 SERDES_OPERATION_TYPE = OperationType.STEP 

91 

92 def __init__( 

93 self, 

94 func: Callable[[], Awaitable[T]], 

95 state: ExecutionState, 

96 operation_identifier: OperationIdentifier, 

97 retry_strategy: Callable[[Exception, int], Duration | None] | None = None, 

98 step_semantics: StepSemantics = StepSemantics.AT_LEAST_ONCE_PER_RETRY, 

99 serdes: SerDes | None = None, 

100 ) -> None: 

101 """Initialize the step operation executor. 

102 

103 Args: 

104 func: The step function to execute 

105 state: The execution state 

106 operation_identifier: The operation identifier 

107 retry_strategy: Optional retry strategy for step failures 

108 step_semantics: Checkpoint timing guarantee for the step attempt 

109 serdes: Optional serializer/deserializer for the step result 

110 """ 

111 super().__init__(state=state, operation_identifier=operation_identifier) 

112 self.func = func 

113 self.retry_strategy = retry_strategy 

114 self.step_semantics = step_semantics 

115 self.serdes = serdes 

116 

117 async def start(self) -> T: 

118 """Start a new step operation.""" 

119 start_operation: OperationUpdate = OperationUpdate.create_step_start( 

120 identifier=self.operation_identifier, 

121 ) 

122 is_sync: bool = self.step_semantics is StepSemantics.AT_MOST_ONCE_PER_RETRY 

123 await self.create_checkpoint(start_operation, is_sync=is_sync) 

124 

125 return await self.execute(None) 

126 

127 async def replay(self, operation: Operation) -> T: 

128 """Replay an existing step operation from its checkpoint.""" 

129 if operation.status is OperationStatus.SUCCEEDED: 

130 logger.debug( 

131 "Step already completed, skipping execution for id: %s, name: %s", 

132 self.operation_identifier.operation_id, 

133 self.operation_name, 

134 ) 

135 result_payload = ( 

136 operation.step_details.result if operation.step_details else None 

137 ) 

138 if result_payload is None: 

139 return cast("T", None) 

140 

141 result: T = await self.deserialize_value( 

142 data=result_payload, 

143 serdes=self.serdes, 

144 operation=operation, 

145 attempt=( 

146 operation.step_details.attempt 

147 if operation.step_details is not None 

148 else None 

149 ), 

150 ) 

151 return result 

152 

153 if operation.status is OperationStatus.FAILED: 

154 self._raise_callable_error(operation) 

155 

156 if operation.status is OperationStatus.PENDING: 

157 scheduled_timestamp = ( 

158 operation.step_details.next_attempt_timestamp 

159 if operation.step_details 

160 else None 

161 ) 

162 suspend_with_optional_resume_timestamp( 

163 msg=f"Retry scheduled for {self.operation_name or self.operation_identifier.operation_id} will retry at timestamp {scheduled_timestamp}", 

164 datetime_timestamp=scheduled_timestamp, 

165 ) 

166 

167 if ( 

168 operation.status is OperationStatus.STARTED 

169 and self.step_semantics is StepSemantics.AT_MOST_ONCE_PER_RETRY 

170 ): 

171 msg: str = f"Step operation_id={self.operation_identifier.operation_id} name={self.operation_identifier.name} was previously interrupted" 

172 await self.retry_handler(StepInterruptedError(msg), operation) 

173 self._raise_callable_error(operation) 

174 

175 if ( 

176 operation.status is OperationStatus.STARTED 

177 and self.step_semantics is StepSemantics.AT_LEAST_ONCE_PER_RETRY 

178 ): 

179 return await self.execute(operation) 

180 

181 if operation.status is OperationStatus.READY: 

182 start_operation: OperationUpdate = OperationUpdate.create_step_start( 

183 identifier=self.operation_identifier, 

184 ) 

185 is_sync: bool = self.step_semantics is StepSemantics.AT_MOST_ONCE_PER_RETRY 

186 await self.create_checkpoint(start_operation, is_sync=is_sync) 

187 

188 return await self.execute(operation) 

189 

190 return await self.execute(operation) 

191 

192 async def execute(self, operation: Operation | None) -> T: 

193 """Execute step function with error handling and retry logic. 

194 

195 Args: 

196 operation: The checkpointed operation state, if any 

197 

198 Returns: 

199 The result of executing the step function 

200 

201 Raises: 

202 ExecutionError: For fatal errors that should not be retried 

203 May raise other exceptions that will be handled by retry_handler 

204 """ 

205 # Get current attempt - checkpointed attempts + 1 

206 attempt: int = 1 

207 if operation and operation.step_details: 

208 attempt = operation.step_details.attempt + 1 

209 

210 step_context: StepContext = StepContext( 

211 attempt=attempt, 

212 execution_state=self.state, 

213 operation_identifier=self.operation_identifier, 

214 ) 

215 

216 try: 

217 # This is the actual code provided by the caller to execute durably inside the step 

218 with bind_current_context(step_context): 

219 raw_result = await self.func() 

220 

221 serialized_result: str = await self.serialize_value( 

222 value=raw_result, 

223 serdes=self.serdes, 

224 attempt=attempt, 

225 ) 

226 

227 success_operation: OperationUpdate = OperationUpdate.create_step_succeed( 

228 identifier=self.operation_identifier, 

229 payload=serialized_result, 

230 ) 

231 

232 # Checkpoint SUCCEED operation with blocking (is_sync=True, default). 

233 # Must ensure the success state is persisted before returning the result to the caller. 

234 # This guarantees the step result is durable and won't be lost if Lambda terminates. 

235 await self.create_checkpoint(success_operation) 

236 

237 logger.debug( 

238 "✅ Successfully completed step for id: %s, name: %s", 

239 self.operation_identifier.operation_id, 

240 self.operation_identifier.name, 

241 ) 

242 except Exception as e: 

243 if isinstance(e, ExecutionError): 

244 # No retry on fatal - e.g checkpoint exception 

245 logger.debug( 

246 "💥 Fatal error for id: %s, name: %s", 

247 self.operation_identifier.operation_id, 

248 self.operation_identifier.name, 

249 ) 

250 # This bubbles up to execution.durable_execution, where it will exit with FAILED 

251 raise 

252 

253 logger.exception( 

254 "❌ failed step for id: %s, name: %s", 

255 self.operation_identifier.operation_id, 

256 self.operation_identifier.name, 

257 ) 

258 

259 await self.retry_handler(e, operation) 

260 # If we've failed to raise an exception from the retry_handler, then we are in a 

261 # weird state, and should crash terminate the execution 

262 msg = "retry handler should have raised an exception, but did not." 

263 raise ExecutionError(msg) from None 

264 

265 # SUCCEED is already durable. A retryable read failure must retry the 

266 # invocation and replay this completed step, not checkpoint RETRY/FAIL 

267 # after the terminal operation transition. 

268 return await self.deserialize_value( 

269 data=serialized_result, 

270 serdes=self.serdes, 

271 attempt=attempt, 

272 ) 

273 

274 async def retry_handler( 

275 self, 

276 error: Exception, 

277 operation: Operation | None, 

278 ) -> None: 

279 """Checkpoint and suspend for replay if retry required, otherwise raise error. 

280 

281 Args: 

282 error: The exception that occurred during step execution 

283 operation: The checkpointed operation state, if any 

284 

285 Raises: 

286 SuspendExecution: If retry is scheduled 

287 StepInterruptedError: If the error is a StepInterruptedError 

288 CallableRuntimeError: If retry is exhausted or error is not retryable 

289 """ 

290 error_object = _error_object_from_exception(error) 

291 

292 retry_strategy = self.retry_strategy or RetryStrategy.default() 

293 

294 retry_attempt: int = ( 

295 operation.step_details.attempt 

296 if operation and operation.step_details 

297 else 0 

298 ) 

299 delay_seconds: int | None = None 

300 try: 

301 retry_delay = retry_strategy(error, retry_attempt + 1) 

302 if retry_delay is not None: 

303 delay_seconds = duration_to_seconds(retry_delay, "retry delay") 

304 except Exception as retry_error: 

305 fail_error_object = _error_object_from_exception( 

306 retry_error, 

307 invocation_retryable=False, 

308 ) 

309 fail_operation: OperationUpdate = OperationUpdate.create_step_fail( 

310 identifier=self.operation_identifier, error=fail_error_object 

311 ) 

312 

313 # Checkpoint FAIL operation with blocking (is_sync=True, default). 

314 # Must ensure the failure state is persisted before raising the exception. 

315 # This guarantees the error is durable and the step won't be retried on replay. 

316 await self.create_checkpoint(fail_operation) 

317 

318 if isinstance(retry_error, StepInterruptedError): 

319 raise retry_error 

320 

321 raise CallableRuntimeError.from_error_object(fail_error_object) 

322 

323 if retry_delay is None: 

324 fail_error_object = _error_object_from_exception( 

325 error, 

326 invocation_retryable=False, 

327 ) 

328 fail_operation = OperationUpdate.create_step_fail( 

329 identifier=self.operation_identifier, error=fail_error_object 

330 ) 

331 await self.create_checkpoint(fail_operation) 

332 

333 if isinstance(error, StepInterruptedError): 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true

334 raise error 

335 

336 raise CallableRuntimeError.from_error_object(fail_error_object) 

337 

338 assert delay_seconds is not None 

339 

340 logger.debug( 

341 "Retrying step for id: %s, name: %s, attempt: %s", 

342 self.operation_identifier.operation_id, 

343 self.operation_identifier.name, 

344 retry_attempt + 1, 

345 ) 

346 

347 # Because we are issuing a retry and create an OperationUpdate, enforce a 

348 # minimum delay of one second here to match model behavior. 

349 if delay_seconds < 1: 

350 logger.warning( 

351 ( 

352 "Retry delay_seconds step for id: %s, name: %s," 

353 "attempt: %s is %d < 1. Setting to minimum of 1 seconds." 

354 ), 

355 self.operation_identifier.operation_id, 

356 self.operation_identifier.name, 

357 retry_attempt + 1, 

358 delay_seconds, 

359 ) 

360 delay_seconds = 1 

361 

362 retry_operation: OperationUpdate = OperationUpdate.create_step_retry( 

363 identifier=self.operation_identifier, 

364 error=error_object, 

365 next_attempt_delay_seconds=delay_seconds, 

366 ) 

367 

368 # Checkpoint RETRY operation with blocking (is_sync=True, default). 

369 # Must ensure retry state is persisted before suspending execution. 

370 # This guarantees the retry attempt count and next attempt timestamp are durable. 

371 await self.create_checkpoint(retry_operation) 

372 

373 suspend_with_optional_resume_delay( 

374 msg=( 

375 f"Retry scheduled for {self.operation_identifier.operation_id} " 

376 f"in {delay_seconds} seconds" 

377 ), 

378 delay_seconds=delay_seconds, 

379 ) 

380 

381 @staticmethod 

382 def _raise_callable_error(operation: Operation) -> None: 

383 error = operation.step_details.error if operation.step_details else None 

384 if error is None: 

385 msg = "Unknown error. No ErrorObject exists on the Checkpoint Operation." 

386 raise CallableRuntimeError( 

387 message=msg, 

388 error_type=None, 

389 data=None, 

390 stack_trace=None, 

391 ) 

392 

393 raise CallableRuntimeError.from_error_object(error) 

394 

395 

396class StatefulStepOperationExecutor(OperationExecutor[T]): 

397 """Executor for stateful extension-authored step operations.""" 

398 

399 SERDES_OPERATION_TYPE = OperationType.STEP 

400 

401 def __init__( 

402 self, 

403 func: ExtensionStepFunction[T], 

404 state: ExecutionState, 

405 operation_identifier: OperationIdentifier, 

406 *, 

407 initial_state: T | None, 

408 retry_strategy: ExtensionStepRetryStrategy[T] | None, 

409 step_semantics: StepSemantics, 

410 serdes: SerDes[T] | None, 

411 raise_original_error: bool = False, 

412 ) -> None: 

413 super().__init__(state=state, operation_identifier=operation_identifier) 

414 self.func = func 

415 self.initial_state = initial_state 

416 self.retry_strategy = retry_strategy 

417 self.step_semantics = step_semantics 

418 self.serdes = serdes 

419 self.raise_original_error = raise_original_error 

420 

421 async def start(self) -> T: 

422 start = OperationUpdate.create_step_start(self.operation_identifier) 

423 await self.create_checkpoint( 

424 start, 

425 is_sync=self.step_semantics is StepSemantics.AT_MOST_ONCE_PER_RETRY, 

426 ) 

427 return await self._execute(None) 

428 

429 async def replay(self, operation: Operation) -> T: 

430 if operation.status is OperationStatus.SUCCEEDED: 

431 payload = operation.step_details.result if operation.step_details else None 

432 if payload is None: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true

433 return cast("T", None) 

434 return await self.deserialize_value( 

435 payload, 

436 self.serdes, 

437 operation=operation, 

438 attempt=( 

439 operation.step_details.attempt 

440 if operation.step_details is not None 

441 else None 

442 ), 

443 ) 

444 

445 if operation.status is OperationStatus.FAILED: 

446 self._raise_failed_operation(operation) 

447 

448 if operation.status is OperationStatus.PENDING: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 resume_at = ( 

450 operation.step_details.next_attempt_timestamp 

451 if operation.step_details 

452 else None 

453 ) 

454 suspend_with_optional_resume_timestamp( 

455 msg=f"Extension step {self.operation_name or self.operation_id} is pending", 

456 datetime_timestamp=resume_at, 

457 ) 

458 

459 if ( 

460 operation.status is OperationStatus.STARTED 

461 and self.step_semantics is StepSemantics.AT_MOST_ONCE_PER_RETRY 

462 ): 

463 msg = ( 

464 f"Extension step operation_id={self.operation_id} " 

465 f"name={self.operation_name} was previously interrupted" 

466 ) 

467 attempt = self._attempt(operation) 

468 state = self.initial_state 

469 try: 

470 state = await self._load_state(operation) 

471 except InvocationError as error: 

472 if error.is_retryable(): 

473 raise 

474 return await self._handle_failure(error, state, attempt) 

475 except Exception as error: 

476 return await self._handle_failure(error, state, attempt) 

477 return await self._handle_failure( 

478 StepInterruptedError(msg, self.operation_id), 

479 state, 

480 attempt, 

481 ) 

482 

483 if operation.status is OperationStatus.READY: 

484 start = OperationUpdate.create_step_start(self.operation_identifier) 

485 await self.create_checkpoint( 

486 start, 

487 is_sync=self.step_semantics is StepSemantics.AT_MOST_ONCE_PER_RETRY, 

488 ) 

489 

490 return await self._execute(operation) 

491 

492 async def _execute(self, operation: Operation | None) -> T: 

493 from ..extension import ExtensionStepResult 

494 

495 state = self.initial_state 

496 attempt = self._attempt(operation) 

497 try: 

498 state = await self._load_state(operation) 

499 step_context = StepContext( 

500 attempt=attempt, 

501 execution_state=self.state, 

502 operation_identifier=self.operation_identifier, 

503 ) 

504 with bind_current_context(step_context): 

505 outcome = await self.func(state) 

506 

507 if not isinstance(outcome, ExtensionStepResult): 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true

508 msg = ( 

509 "Extension step functions must return " 

510 "ExtensionStepResult.succeed(...) or ExtensionStepResult.retry(...)" 

511 ) 

512 raise TypeError(msg) 

513 

514 if outcome.is_retry: 

515 delay_seconds, payload = await self._prepare_retry(outcome, attempt) 

516 else: 

517 payload = await self.serialize_value( 

518 outcome.value, 

519 self.serdes, 

520 attempt=attempt, 

521 ) 

522 except InvocationError as error: 

523 if error.is_retryable(): 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true

524 raise 

525 return await self._handle_failure(error, state, attempt) 

526 except Exception as error: 

527 return await self._handle_failure(error, state, attempt) 

528 

529 if outcome.is_retry: 

530 return await self._schedule_retry( 

531 delay_seconds=delay_seconds, 

532 payload=payload, 

533 ) 

534 

535 await self.create_checkpoint( 

536 OperationUpdate.create_step_succeed( 

537 self.operation_identifier, 

538 payload, 

539 ) 

540 ) 

541 return await self.deserialize_value( 

542 payload, 

543 self.serdes, 

544 attempt=attempt, 

545 ) 

546 

547 async def _load_state(self, operation: Operation | None) -> T | None: 

548 if ( 

549 operation is not None 

550 and operation.step_details is not None 

551 and operation.step_details.result is not None 

552 ): 

553 return await self.deserialize_value( 

554 operation.step_details.result, 

555 self.serdes, 

556 operation=operation, 

557 attempt=operation.step_details.attempt, 

558 ) 

559 return self.initial_state 

560 

561 @staticmethod 

562 def _attempt(operation: Operation | None) -> int: 

563 if operation is None or operation.step_details is None: 

564 return 1 

565 return operation.step_details.attempt + 1 

566 

567 async def _handle_failure( 

568 self, 

569 error: Exception, 

570 state: T | None, 

571 attempt: int, 

572 ) -> T: 

573 from ..extension import ExtensionStepResult 

574 

575 if self.retry_strategy is None: 

576 return await self._fail(error) 

577 

578 try: 

579 decision = self.retry_strategy(error, state, attempt) 

580 except Exception as retry_error: 

581 return await self._fail(retry_error) 

582 

583 if decision is None: 583 ↛ 584line 583 didn't jump to line 584 because the condition on line 583 was never true

584 return await self._fail(error) 

585 if not isinstance(decision, ExtensionStepResult) or not decision.is_retry: 585 ↛ 586line 585 didn't jump to line 586 because the condition on line 585 was never true

586 msg = ( 

587 "Extension step retry_strategy must return " 

588 "ExtensionStepResult.retry(...) or None" 

589 ) 

590 return await self._fail(TypeError(msg)) 

591 

592 try: 

593 delay_seconds, payload = await self._prepare_retry(decision, attempt) 

594 except InvocationError as retry_error: 

595 if retry_error.is_retryable(): 595 ↛ 597line 595 didn't jump to line 597 because the condition on line 595 was always true

596 raise 

597 return await self._fail(retry_error) 

598 except Exception as retry_error: 

599 return await self._fail(retry_error) 

600 

601 return await self._schedule_retry( 

602 delay_seconds=delay_seconds, 

603 payload=payload, 

604 ) 

605 

606 async def _prepare_retry( 

607 self, 

608 outcome: ExtensionStepResult[T], 

609 attempt: int, 

610 ) -> tuple[int, str]: 

611 assert outcome.retry_delay is not None 

612 delay_seconds = max( 

613 1, 

614 duration_to_seconds(outcome.retry_delay, "retry delay"), 

615 ) 

616 payload = await self.serialize_value( 

617 outcome.value, 

618 self.serdes, 

619 attempt=attempt, 

620 ) 

621 return delay_seconds, payload 

622 

623 async def _schedule_retry( 

624 self, 

625 *, 

626 delay_seconds: int, 

627 payload: str, 

628 ) -> T: 

629 await self.create_checkpoint( 

630 OperationUpdate.create_step_retry( 

631 self.operation_identifier, 

632 next_attempt_delay_seconds=delay_seconds, 

633 payload=payload, 

634 error=None, 

635 ) 

636 ) 

637 suspend_with_optional_resume_delay( 

638 msg=f"Extension step {self.operation_id} will retry", 

639 delay_seconds=delay_seconds, 

640 ) 

641 raise AssertionError("suspend_with_optional_resume_delay must raise") 

642 

643 async def _fail(self, error: Exception) -> T: 

644 error_object = _error_object_from_exception( 

645 error, 

646 invocation_retryable=False, 

647 ) 

648 await self.create_checkpoint( 

649 OperationUpdate.create_step_fail( 

650 self.operation_identifier, 

651 error_object, 

652 ) 

653 ) 

654 if self.raise_original_error: 

655 raise error 

656 control_error = _restore_sdk_control_error( 

657 error_object.message or str(error), 

658 error_object.type, 

659 error_object.data, 

660 ) 

661 if control_error is not None: 

662 raise control_error 

663 raise CallableRuntimeError.from_error_object(error_object) 

664 

665 @staticmethod 

666 def _raise_failed_operation(operation: Operation) -> None: 

667 error = operation.step_details.error if operation.step_details else None 

668 if error is None: 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true

669 error = ErrorObject.from_message( 

670 "Unknown error. No ErrorObject exists on the checkpoint operation." 

671 ) 

672 control_error = _restore_sdk_control_error( 

673 error.message or "Extension step failed", 

674 error.type, 

675 error.data, 

676 ) 

677 if control_error is not None: 677 ↛ 679line 677 didn't jump to line 679 because the condition on line 677 was always true

678 raise control_error 

679 raise CallableRuntimeError.from_error_object(error) 

680 

681 

682def step( 

683 func: Callable[[], Awaitable[T]], 

684 *, 

685 name: str | None = None, 

686 retry_strategy: Callable[[Exception, int], Duration | None] | None = None, 

687 step_semantics: StepSemantics = StepSemantics.AT_LEAST_ONCE_PER_RETRY, 

688 serdes: SerDes | None = None, 

689) -> asyncio.Task[T]: 

690 """Compatibility import for the canonical operation-layer helper.""" 

691 from .._operation.step import step as operation_step 

692 

693 return operation_step( 

694 func, 

695 name=name, 

696 retry_strategy=retry_strategy, 

697 step_semantics=step_semantics, 

698 serdes=serdes, 

699 ) 

700 

701 

702async def _step( 

703 func: Callable[[], Awaitable[T]], 

704 *, 

705 context: DurableContext, 

706 operation_identifier: OperationIdentifier, 

707 retry_strategy: Callable[[Exception, int], Duration | None] | None = None, 

708 step_semantics: StepSemantics = StepSemantics.AT_LEAST_ONCE_PER_RETRY, 

709 serdes: SerDes | None = None, 

710) -> T: 

711 executor: StepOperationExecutor[T] = StepOperationExecutor( 

712 func=func, 

713 state=context.execution_state, 

714 operation_identifier=operation_identifier, 

715 retry_strategy=retry_strategy, 

716 step_semantics=step_semantics, 

717 serdes=serdes, 

718 ) 

719 return await executor.process() 

720 

721 

722async def _stateful_step( 

723 func: ExtensionStepFunction[T], 

724 *, 

725 context: DurableContext, 

726 operation_identifier: OperationIdentifier, 

727 initial_state: T | None, 

728 retry_strategy: ExtensionStepRetryStrategy[T] | None, 

729 step_semantics: StepSemantics, 

730 serdes: SerDes[T] | None, 

731 raise_original_error: bool = False, 

732) -> T: 

733 executor: StatefulStepOperationExecutor[T] = StatefulStepOperationExecutor( 

734 func=func, 

735 state=context.execution_state, 

736 operation_identifier=operation_identifier, 

737 initial_state=initial_state, 

738 retry_strategy=retry_strategy, 

739 step_semantics=step_semantics, 

740 serdes=serdes, 

741 raise_original_error=raise_original_error, 

742 ) 

743 return await executor.process() 

744 

745 

746@dataclass(frozen=True) 

747class StepContext(OperationContext): 

748 """Context exposed while a step function is executing.""" 

749 

750 attempt: int | None = None 

751 

752 

753def get_step_context() -> StepContext: 

754 """Return the active `StepContext`.""" 

755 current_context = get_current_context() 

756 if not isinstance(current_context, StepContext): 756 ↛ 757line 756 didn't jump to line 757 because the condition on line 756 was never true

757 msg = "get_step_context() can only be used while a step function is executing." 

758 raise RuntimeError(msg) 

759 return current_context