Coverage for async_durable_execution/_runner/cloud/__init__.py: 94%

334 statements  

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

1from __future__ import annotations 

2 

3import asyncio 

4import inspect 

5import json 

6import logging 

7import time 

8from typing import Any, cast 

9from uuid import uuid4 

10 

11from botocore.config import Config 

12from botocore.session import get_session 

13 

14from ..._core import ( 

15 DurableExecutionInvocationInput, 

16 DurableExecutionInvocationOutput, 

17 ErrorObject, 

18 create_default_async_client, 

19 create_default_sync_client, 

20 httpx_is_installed, 

21) 

22from ..exceptions import ( 

23 DurableFunctionsTestError, 

24 InvalidParameterValueException, 

25 ResourceNotFoundException, 

26) 

27from ..model import ( 

28 DurableFunctionTestResult, 

29 GetDurableExecutionResponse, 

30 GetDurableExecutionHistoryResponse, 

31 InvokeResponse, 

32 _get_callback_id_from_events, 

33) 

34 

35 

36logger = logging.getLogger(__name__) 

37 

38 

39class ThreadedSyncCloudLambdaClient: 

40 """Adapt a sync Lambda client to the async cloud runner interface.""" 

41 

42 def __init__(self, client: Any) -> None: 

43 self.client = client 

44 

45 @property 

46 def exceptions(self) -> Any: 

47 return self.client.exceptions 

48 

49 async def invoke(self, **kwargs: Any) -> dict[str, Any]: 

50 return cast( 

51 dict[str, Any], await asyncio.to_thread(self.client.invoke, **kwargs) 

52 ) 

53 

54 async def get_durable_execution(self, **kwargs: Any) -> dict[str, Any]: 

55 return cast( 

56 dict[str, Any], 

57 await asyncio.to_thread(self.client.get_durable_execution, **kwargs), 

58 ) 

59 

60 async def get_durable_execution_history(self, **kwargs: Any) -> dict[str, Any]: 

61 return cast( 

62 dict[str, Any], 

63 await asyncio.to_thread( 

64 self.client.get_durable_execution_history, **kwargs 

65 ), 

66 ) 

67 

68 async def send_durable_execution_callback_success( 

69 self, **kwargs: Any 

70 ) -> dict[str, Any]: 

71 return cast( 

72 dict[str, Any], 

73 await asyncio.to_thread( 

74 self.client.send_durable_execution_callback_success, **kwargs 

75 ), 

76 ) 

77 

78 async def send_durable_execution_callback_failure( 

79 self, **kwargs: Any 

80 ) -> dict[str, Any]: 

81 return cast( 

82 dict[str, Any], 

83 await asyncio.to_thread( 

84 self.client.send_durable_execution_callback_failure, **kwargs 

85 ), 

86 ) 

87 

88 async def send_durable_execution_callback_heartbeat( 

89 self, **kwargs: Any 

90 ) -> dict[str, Any]: 

91 return cast( 

92 dict[str, Any], 

93 await asyncio.to_thread( 

94 self.client.send_durable_execution_callback_heartbeat, **kwargs 

95 ), 

96 ) 

97 

98 def close(self) -> None: 

99 close = getattr(self.client, "close", None) 

100 if callable(close): 100 ↛ exitline 100 didn't return from function 'close' because the condition on line 100 was always true

101 close() 

102 

103 

104class AsyncCloudLambdaClient: 

105 """Adapt an async Lambda client to the cloud runner interface.""" 

106 

107 def __init__(self, client: Any) -> None: 

108 self._client_context = client if hasattr(client, "__aenter__") else None 

109 self._client = None if self._client_context is not None else client 

110 self._entered_client: Any | None = None 

111 

112 @property 

113 def exceptions(self) -> Any: 

114 client = self._entered_client or self._client 

115 if client is None: 

116 msg = "Async Lambda client has not been initialized" 

117 raise AttributeError(msg) 

118 return client.exceptions 

119 

120 async def _get_client(self) -> Any: 

121 if self._client is not None: 

122 return self._client 

123 if self._entered_client is None: 123 ↛ 126line 123 didn't jump to line 126 because the condition on line 123 was always true

124 assert self._client_context is not None 

125 self._entered_client = await self._client_context.__aenter__() 

126 return self._entered_client 

127 

128 async def _call(self, method_name: str, **kwargs: Any) -> dict[str, Any]: 

129 client = await self._get_client() 

130 result = getattr(client, method_name)(**kwargs) 

131 if inspect.isawaitable(result): 131 ↛ 133line 131 didn't jump to line 133 because the condition on line 131 was always true

132 result = await result 

133 return cast(dict[str, Any], result) 

134 

135 async def invoke(self, **kwargs: Any) -> dict[str, Any]: 

136 return await self._call("invoke", **kwargs) 

137 

138 async def get_durable_execution(self, **kwargs: Any) -> dict[str, Any]: 

139 return await self._call("get_durable_execution", **kwargs) 

140 

141 async def get_durable_execution_history(self, **kwargs: Any) -> dict[str, Any]: 

142 return await self._call("get_durable_execution_history", **kwargs) 

143 

144 async def send_durable_execution_callback_success( 

145 self, **kwargs: Any 

146 ) -> dict[str, Any]: 

147 return await self._call("send_durable_execution_callback_success", **kwargs) 

148 

149 async def send_durable_execution_callback_failure( 

150 self, **kwargs: Any 

151 ) -> dict[str, Any]: 

152 return await self._call("send_durable_execution_callback_failure", **kwargs) 

153 

154 async def send_durable_execution_callback_heartbeat( 

155 self, **kwargs: Any 

156 ) -> dict[str, Any]: 

157 return await self._call("send_durable_execution_callback_heartbeat", **kwargs) 

158 

159 async def aclose(self) -> None: 

160 if self._entered_client is not None: 

161 assert self._client_context is not None 

162 await self._client_context.__aexit__(None, None, None) 

163 self._entered_client = None 

164 return 

165 

166 close = getattr(self._client, "aclose", None) 

167 if callable(close): 167 ↛ exitline 167 didn't return from function 'aclose' because the condition on line 167 was always true

168 await close() 

169 

170 

171async def _read_payload(payload: Any) -> str: 

172 read = getattr(payload, "read", None) 

173 if callable(read): 173 ↛ 181line 173 didn't jump to line 181 because the condition on line 173 was always true

174 if inspect.iscoroutinefunction(read): 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true

175 data = await read() 

176 else: 

177 data = await asyncio.to_thread(read) 

178 if inspect.isawaitable(data): 

179 data = await data 

180 else: 

181 data = payload 

182 

183 if isinstance(data, bytes): 183 ↛ 185line 183 didn't jump to line 185 because the condition on line 183 was always true

184 return data.decode("utf-8") 

185 return str(data) 

186 

187 

188def _cloud_lambda_client_is_async(client: Any) -> bool: 

189 return inspect.iscoroutinefunction(getattr(client, "invoke", None)) 

190 

191 

192def adapt_lambda_client(client: Any) -> Any: 

193 """Adapt a raw Lambda client to the async cloud runner interface.""" 

194 if isinstance(client, ThreadedSyncCloudLambdaClient | AsyncCloudLambdaClient): 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true

195 return client 

196 if _cloud_lambda_client_is_async(client): 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true

197 return AsyncCloudLambdaClient(client) 

198 return ThreadedSyncCloudLambdaClient(client) 

199 

200 

201_KNOWN_LAMBDA_ERROR_CODES = ( 

202 "ResourceNotFoundException", 

203 "InvalidParameterValueException", 

204 "TooManyRequestsException", 

205 "ServiceException", 

206 "ResourceConflictException", 

207 "InvalidRequestContentException", 

208 "RequestTooLargeException", 

209 "UnsupportedMediaTypeException", 

210 "InvalidRuntimeException", 

211 "InvalidZipFileException", 

212 "ResourceNotReadyException", 

213 "SnapStartTimeoutException", 

214 "SnapStartNotReadyException", 

215 "SnapStartException", 

216 "RecursiveInvocationException", 

217 "InvalidSecurityGroupIDException", 

218 "EC2ThrottledException", 

219 "EFSMountConnectivityException", 

220 "SubnetIPAddressLimitReachedException", 

221 "EC2UnexpectedException", 

222 "InvalidSubnetIDException", 

223 "EC2AccessDeniedException", 

224 "EFSIOException", 

225 "ENILimitReachedException", 

226 "EFSMountTimeoutException", 

227 "EFSMountFailureException", 

228 "KMSAccessDeniedException", 

229 "KMSDisabledException", 

230 "KMSNotFoundException", 

231 "KMSInvalidStateException", 

232 "DurableExecutionAlreadyStartedException", 

233) 

234 

235 

236def _aws_error_code(error: Exception, client: Any = None) -> str: 

237 response = getattr(error, "response", None) 

238 if isinstance(response, dict): 

239 aws_error = response.get("Error") 

240 if isinstance(aws_error, dict) and aws_error.get("Code"): 240 ↛ 243line 240 didn't jump to line 243 because the condition on line 240 was always true

241 return str(aws_error["Code"]) 

242 

243 exceptions = getattr(client, "exceptions", None) 

244 if exceptions is not None: 244 ↛ 249line 244 didn't jump to line 249 because the condition on line 244 was always true

245 for error_code in _KNOWN_LAMBDA_ERROR_CODES: 

246 exception_type = getattr(exceptions, error_code, None) 

247 if isinstance(exception_type, type) and isinstance(error, exception_type): 

248 return error_code 

249 return type(error).__name__ 

250 

251 

252def create_cloud_runner( 

253 *, 

254 function_name: str, 

255 region: str = "us-west-2", 

256 lambda_endpoint: str | None = None, 

257 poll_interval: float = 1.0, 

258 input: Any = None, # noqa: A002 

259 timeout: int = 60, 

260) -> DurableFunctionCloudTestRunner: 

261 """Create a configured cloud durable function runner.""" 

262 return DurableFunctionCloudTestRunner( 

263 function_name=function_name, 

264 region=region, 

265 lambda_endpoint=lambda_endpoint, 

266 poll_interval=poll_interval, 

267 input=input, 

268 timeout=timeout, 

269 ) 

270 

271 

272class DurableFunctionCloudTestRunner: 

273 """Test runner that executes durable functions against actual AWS Lambda backend. 

274 

275 This runner invokes deployed Lambda functions and polls for execution completion, 

276 providing the same interface as DurableFunctionLocalTestRunner for seamless test 

277 compatibility between local and cloud modes. 

278 """ 

279 

280 def __init__( 

281 self, 

282 function_name: str, 

283 region: str = "us-west-2", 

284 lambda_endpoint: str | None = None, 

285 poll_interval: float = 1.0, 

286 input: Any = None, # noqa: A002 

287 timeout: int = 60, 

288 ) -> None: 

289 """Initialize cloud test runner.""" 

290 self.mode = "cloud" 

291 self.function_name = function_name 

292 self.region = region 

293 self.lambda_endpoint = lambda_endpoint 

294 self.poll_interval = poll_interval 

295 self._default_input = input 

296 self._default_timeout = timeout 

297 

298 self.lambda_client: Any = create_lambda_client(lambda_endpoint, region) 

299 

300 async def __aenter__(self) -> DurableFunctionCloudTestRunner: 

301 return self 

302 

303 async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: 

304 await self.aclose() 

305 

306 def close(self) -> None: 

307 """Close the underlying sync client when supported.""" 

308 close = getattr(self.lambda_client, "close", None) 

309 if callable(close): 

310 close() 

311 

312 async def aclose(self) -> None: 

313 """Close the underlying client when supported.""" 

314 aclose = getattr(self.lambda_client, "aclose", None) 

315 if callable(aclose): 

316 await aclose() 

317 return 

318 self.close() 

319 

320 async def run( 

321 self, 

322 ) -> DurableFunctionTestResult: 

323 """Execute function on AWS Lambda and wait for completion.""" 

324 execution_arn = await self._invoke_for_execution( 

325 invocation_type="RequestResponse", 

326 expected_status_code=200, 

327 ) 

328 return await self.wait_for_result( 

329 execution_arn=execution_arn, timeout=self._default_timeout 

330 ) 

331 

332 async def run_async( 

333 self, 

334 ) -> str: 

335 """Execute function on AWS Lambda asynchronously""" 

336 return await self._invoke_for_execution( 

337 invocation_type="Event", 

338 expected_status_code=202, 

339 ) 

340 

341 async def _invoke_for_execution( 

342 self, 

343 *, 

344 invocation_type: str, 

345 expected_status_code: int, 

346 ) -> str: 

347 logger.info( 

348 "Invoking Lambda function: %s (timeout: %ds)", 

349 self.function_name, 

350 self._default_timeout, 

351 ) 

352 payload = json.dumps(self._default_input) 

353 try: 

354 response = cast( 

355 dict[str, Any], 

356 await self.lambda_client.invoke( 

357 FunctionName=self.function_name, 

358 InvocationType=invocation_type, 

359 Payload=payload, 

360 ), 

361 ) 

362 except Exception as e: 

363 msg = f"Failed to invoke Lambda function {self.function_name}: {e}" 

364 raise DurableFunctionsTestError(msg) from e 

365 

366 status_code = response.get("StatusCode") 

367 if status_code != expected_status_code: 

368 error_payload = await _read_payload(response["Payload"]) 

369 msg = f"Lambda invocation failed with status {status_code}: {error_payload}" 

370 raise DurableFunctionsTestError(msg) 

371 

372 if "FunctionError" in response: 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 error_payload = await _read_payload(response["Payload"]) 

374 logger.warning("Lambda function failed: %s", error_payload) 

375 

376 execution_arn = cast(str | None, response.get("DurableExecutionArn")) 

377 if execution_arn is None: 

378 msg = ( 

379 f"No DurableExecutionArn in response for function {self.function_name}" 

380 ) 

381 raise DurableFunctionsTestError(msg) 

382 return execution_arn 

383 

384 async def send_callback_success( 

385 self, callback_id: str, result: bytes | None = None 

386 ) -> None: 

387 try: 

388 await self.lambda_client.send_durable_execution_callback_success( 

389 CallbackId=callback_id, 

390 Result=cast(Any, result), 

391 ) 

392 except Exception as e: 

393 msg = f"Failed to send callback success for {self.function_name}, callback_id {callback_id}: {e}" 

394 raise DurableFunctionsTestError(msg) from e 

395 

396 async def send_callback_failure( 

397 self, callback_id: str, error: ErrorObject | None = None 

398 ) -> None: 

399 try: 

400 await self.lambda_client.send_durable_execution_callback_failure( 

401 CallbackId=callback_id, 

402 Error=cast(Any, error.to_dict() if error else None), 

403 ) 

404 except Exception as e: 

405 msg = f"Failed to send callback failure for {self.function_name}, callback_id {callback_id}: {e}" 

406 raise DurableFunctionsTestError(msg) from e 

407 

408 async def send_callback_heartbeat(self, callback_id: str) -> None: 

409 try: 

410 await self.lambda_client.send_durable_execution_callback_heartbeat( 

411 CallbackId=callback_id, 

412 ) 

413 except Exception as e: 

414 msg = f"Failed to send callback heartbeat for {self.function_name}, callback_id {callback_id}: {e}" 

415 raise DurableFunctionsTestError(msg) from e 

416 

417 async def _wait_for_completion( 

418 self, execution_arn: str, timeout: int 

419 ) -> GetDurableExecutionResponse: 

420 """Poll execution status until completion or timeout. 

421 

422 Args: 

423 execution_arn: ARN of the durable execution 

424 timeout: Maximum seconds to wait 

425 

426 Returns: 

427 GetDurableExecutionResponse with typed execution details 

428 

429 Raises: 

430 TimeoutError: If execution doesn't complete within timeout 

431 DurableFunctionsTestError: If status check fails 

432 """ 

433 start_time = time.time() 

434 last_status = None 

435 

436 while time.time() - start_time < timeout: 

437 try: 

438 execution_dict = await self.lambda_client.get_durable_execution( 

439 DurableExecutionArn=execution_arn, 

440 IncludeExecutionData=True, 

441 ) 

442 execution = GetDurableExecutionResponse.from_dict(execution_dict) 

443 except Exception as e: 

444 if ( 

445 _aws_error_code(e, self.lambda_client) 

446 == "ResourceNotFoundException" 

447 ): 

448 logger.info( 

449 "Execution status not available yet for %s; retrying", 

450 execution_arn, 

451 ) 

452 else: 

453 msg = f"Failed to get execution status: {e}" 

454 raise DurableFunctionsTestError(msg) from e 

455 else: 

456 # Log status changes 

457 if execution.status != last_status: 

458 logger.info("Execution status: %s", execution.status) 

459 last_status = execution.status 

460 

461 # Check if execution completed 

462 if execution.status == "SUCCEEDED": 

463 logger.info("Execution succeeded") 

464 return execution 

465 if execution.status == "FAILED": 

466 logger.warning("Execution failed") 

467 return execution 

468 if execution.status in ["TIMED_OUT", "ABORTED"]: 

469 logger.warning("Execution terminated: %s", execution.status) 

470 return execution 

471 

472 await asyncio.sleep(self.poll_interval) 

473 

474 # Timeout reached 

475 elapsed = time.time() - start_time 

476 msg = ( 

477 f"Execution did not complete within {timeout}s " 

478 f"(elapsed: {elapsed:.1f}s, last status: {last_status})" 

479 ) 

480 raise TimeoutError(msg) 

481 

482 async def wait_for_result( 

483 self, execution_arn: str, timeout: int = 60 

484 ) -> DurableFunctionTestResult: 

485 execution_result = self._wait_for_completion(execution_arn, timeout) 

486 execution_response = ( 

487 await execution_result 

488 if inspect.isawaitable(execution_result) 

489 else execution_result 

490 ) 

491 

492 try: 

493 history_result = self._fetch_execution_history(execution_arn) 

494 history_response = ( 

495 await history_result 

496 if inspect.isawaitable(history_result) 

497 else history_result 

498 ) 

499 except Exception as e: 

500 msg = f"Failed to fetch execution history: {e}" 

501 raise DurableFunctionsTestError(msg) from e 

502 

503 # Build test result from execution history 

504 return DurableFunctionTestResult.from_execution_history( 

505 execution_response, history_response 

506 ) 

507 

508 async def wait_for_callback( 

509 self, execution_arn: str, name: str | None = None, timeout: int = 60 

510 ) -> str: 

511 """ 

512 Wait for and retrieve a callback ID from a durable execution. 

513 

514 Polls the execution history at regular intervals until a callback ID is found 

515 or the timeout is reached. 

516 

517 Args: 

518 execution_arn: Execution Arn 

519 name: Specific callback name, default to None 

520 timeout: Maximum time in seconds to wait for callback. Defaults to 60. 

521 

522 Returns: 

523 str: The callback ID/token retrieved from the execution history 

524 

525 Raises: 

526 TimeoutError: If callback is not found within the specified timeout period 

527 DurableFunctionsTestError: If there's an error fetching execution history 

528 (excluding retryable errors) 

529 """ 

530 start_time = time.time() 

531 

532 while time.time() - start_time < timeout: 

533 try: 

534 history_response = await self._fetch_execution_history(execution_arn) 

535 callback_id = _get_callback_id_from_events( 

536 events=history_response.events, name=name 

537 ) 

538 if callback_id: 

539 return callback_id 

540 except DurableFunctionsTestError: 

541 raise 

542 except Exception as e: 

543 # Retry while an asynchronously invoked execution is not yet visible. 

544 if ( 

545 _aws_error_code(e, self.lambda_client) 

546 != "ResourceNotFoundException" 

547 ): 

548 msg = f"Failed to fetch execution history: {e}" 

549 raise DurableFunctionsTestError(msg) from e 

550 

551 await asyncio.sleep(self.poll_interval) 

552 

553 # Timeout reached 

554 elapsed = time.time() - start_time 

555 msg = f"Callback was not available within {timeout}s (elapsed: {elapsed:.1f}s)." 

556 raise TimeoutError(msg) 

557 

558 async def _fetch_execution_history( 

559 self, execution_arn: str 

560 ) -> GetDurableExecutionHistoryResponse: 

561 """Retrieve the complete execution history from Lambda service. 

562 

563 Args: 

564 execution_arn: ARN of the durable execution 

565 

566 Returns: 

567 GetDurableExecutionHistoryResponse with typed Event objects 

568 

569 Raises: 

570 Exception: If the Lambda API client encounters an error 

571 """ 

572 events = [] 

573 next_marker: str | None = None 

574 seen_markers: set[str] = set() 

575 page_count = 0 

576 

577 while True: 

578 request: dict[str, Any] = { 

579 "DurableExecutionArn": execution_arn, 

580 "IncludeExecutionData": True, 

581 } 

582 if next_marker: 

583 request["Marker"] = next_marker 

584 

585 history_dict = await self.lambda_client.get_durable_execution_history( 

586 **request 

587 ) 

588 history_response = GetDurableExecutionHistoryResponse.from_dict( 

589 history_dict 

590 ) 

591 page_count += 1 

592 events.extend(history_response.events) 

593 

594 next_marker = history_response.next_marker 

595 if not next_marker: 

596 break 

597 if next_marker in seen_markers: 

598 msg = ( 

599 "Execution history pagination returned a repeated marker: " 

600 f"{next_marker}" 

601 ) 

602 raise DurableFunctionsTestError(msg) 

603 seen_markers.add(next_marker) 

604 

605 logger.info( 

606 "Retrieved %d events from history across %d page(s)", 

607 len(events), 

608 page_count, 

609 ) 

610 

611 return GetDurableExecutionHistoryResponse(events=events) 

612 

613 

614class LambdaInvoker: 

615 def __init__(self, lambda_client: Any) -> None: 

616 self.lambda_client = adapt_lambda_client(lambda_client) 

617 # Maps execution_arn -> endpoint for that execution 

618 # Maps endpoint -> client to reuse clients across executions 

619 self._execution_endpoints: dict[str, str] = {} 

620 self._endpoint_clients: dict[str, Any] = {} 

621 self._current_endpoint: str = "" # Track current endpoint for new executions 

622 

623 def _get_client_for_execution( 

624 self, 

625 durable_execution_arn: str, 

626 lambda_endpoint: str | None = None, 

627 region_name: str | None = None, 

628 ) -> Any: 

629 """Get the appropriate client for this execution.""" 

630 # Use provided endpoint or fall back to cached endpoint for this execution 

631 if lambda_endpoint: 

632 if lambda_endpoint not in self._endpoint_clients: 

633 self._endpoint_clients[lambda_endpoint] = adapt_lambda_client( 

634 create_lambda_client(lambda_endpoint, region_name or "us-east-1") 

635 ) 

636 return self._endpoint_clients[lambda_endpoint] 

637 

638 # Fallback to cached endpoint 

639 if durable_execution_arn not in self._execution_endpoints: 639 ↛ 642line 639 didn't jump to line 642 because the condition on line 639 was always true

640 self._execution_endpoints[durable_execution_arn] = self._current_endpoint 

641 

642 endpoint = self._execution_endpoints[durable_execution_arn] 

643 

644 # If no endpoint configured, fall back to default client 

645 if not endpoint: 

646 return self.lambda_client 

647 

648 return self._endpoint_clients[endpoint] 

649 

650 async def invoke( 

651 self, 

652 function_name: str, 

653 input: DurableExecutionInvocationInput, 

654 endpoint_url: str | None = None, 

655 ) -> InvokeResponse: 

656 """Invoke AWS Lambda function and return durable execution result. 

657 

658 Args: 

659 function_name: Name of the Lambda function to invoke 

660 input: Durable execution invocation input 

661 endpoint_url: Lambda endpoint url 

662 

663 Returns: 

664 InvokeResponse: Response containing invocation output and request ID 

665 

666 Raises: 

667 ResourceNotFoundException: If function does not exist 

668 InvalidParameterValueException: If parameters are invalid 

669 DurableFunctionsTestError: For other invocation failures 

670 """ 

671 

672 # Parameter validation 

673 if not function_name or not function_name.strip(): 

674 msg = "Function name is required" 

675 raise InvalidParameterValueException(msg) 

676 

677 # Get the client for this execution 

678 client = self._get_client_for_execution( 

679 input.durable_execution_arn, endpoint_url 

680 ) 

681 

682 try: 

683 # Invoke AWS Lambda function using standard invoke method 

684 response = await client.invoke( 

685 FunctionName=function_name, 

686 InvocationType="RequestResponse", # Synchronous invocation 

687 Payload=json.dumps(input.to_dict()), 

688 ) 

689 

690 # Check HTTP status code 

691 status_code = response.get("StatusCode") 

692 if status_code not in (200, 202, 204): 

693 msg = f"Lambda invocation failed with status code: {status_code}" 

694 raise DurableFunctionsTestError(msg) 

695 

696 # Check for function errors 

697 if "FunctionError" in response: 

698 error_payload = await _read_payload(response["Payload"]) 

699 msg = f"Lambda invocation failed with status {status_code}: {error_payload}" 

700 raise DurableFunctionsTestError(msg) 

701 

702 # Parse response payload 

703 response_payload = await _read_payload(response["Payload"]) 

704 response_dict = json.loads(response_payload) 

705 

706 # Extract request ID from response headers (x-amzn-RequestId or x-amzn-request-id) 

707 headers = response.get("ResponseMetadata", {}).get("HTTPHeaders", {}) 

708 request_id = ( 

709 headers.get("x-amzn-RequestId") 

710 or headers.get("x-amzn-request-id") 

711 or headers.get("x-amzn-requestid") 

712 or f"local-{uuid4()}" 

713 ) 

714 

715 # Convert to DurableExecutionInvocationOutput 

716 output = DurableExecutionInvocationOutput.from_dict(response_dict) 

717 return InvokeResponse(invocation_output=output, request_id=request_id) 

718 

719 except Exception as e: 

720 error_code = _aws_error_code(e, client) 

721 if error_code == "ResourceNotFoundException": 

722 msg = f"Function not found: {function_name}" 

723 raise ResourceNotFoundException(msg) from e 

724 if error_code == "InvalidParameterValueException": 

725 msg = f"Invalid parameter: {e}" 

726 raise InvalidParameterValueException(msg) from e 

727 if error_code in { 

728 "TooManyRequestsException", 

729 "ServiceException", 

730 "ResourceConflictException", 

731 "InvalidRequestContentException", 

732 "RequestTooLargeException", 

733 "UnsupportedMediaTypeException", 

734 "InvalidRuntimeException", 

735 "InvalidZipFileException", 

736 "ResourceNotReadyException", 

737 "SnapStartTimeoutException", 

738 "SnapStartNotReadyException", 

739 "SnapStartException", 

740 "RecursiveInvocationException", 

741 }: 

742 msg = f"Lambda invocation failed: {e}" 

743 raise DurableFunctionsTestError(msg) from e 

744 if error_code in { 

745 "InvalidSecurityGroupIDException", 

746 "EC2ThrottledException", 

747 "EFSMountConnectivityException", 

748 "SubnetIPAddressLimitReachedException", 

749 "EC2UnexpectedException", 

750 "InvalidSubnetIDException", 

751 "EC2AccessDeniedException", 

752 "EFSIOException", 

753 "ENILimitReachedException", 

754 "EFSMountTimeoutException", 

755 "EFSMountFailureException", 

756 }: 

757 msg = f"Lambda infrastructure error: {e}" 

758 raise DurableFunctionsTestError(msg) from e 

759 if error_code in { 

760 "KMSAccessDeniedException", 

761 "KMSDisabledException", 

762 "KMSNotFoundException", 

763 "KMSInvalidStateException", 

764 }: 

765 msg = f"Lambda KMS error: {e}" 

766 raise DurableFunctionsTestError(msg) from e 

767 if error_code == "DurableExecutionAlreadyStartedException": 

768 msg = f"Durable execution already started: {e}" 

769 raise DurableFunctionsTestError(msg) from e 

770 msg = f"Unexpected error during Lambda invocation: {e}" 

771 raise DurableFunctionsTestError(msg) from e 

772 

773 

774def create_sync_lambda_client(endpoint_url: str | None, region_name: str) -> Any: 

775 """Create a sync Lambda client adapted for cloud runner calls.""" 

776 return ThreadedSyncCloudLambdaClient( 

777 create_default_sync_client( 

778 session=get_session(), 

779 endpoint_url=endpoint_url, 

780 region_name=region_name, 

781 config=_LAMBDA_CLIENT_CONFIG, 

782 ) 

783 ) 

784 

785 

786def create_async_lambda_client(endpoint_url: str | None, region_name: str) -> Any: 

787 """Create an async Lambda client adapted for cloud runner calls.""" 

788 return AsyncCloudLambdaClient( 

789 create_default_async_client( 

790 session=get_session(), 

791 endpoint_url=endpoint_url, 

792 region_name=region_name, 

793 config=_LAMBDA_CLIENT_CONFIG, 

794 ) 

795 ) 

796 

797 

798def create_lambda_client(endpoint_url: str | None, region_name: str) -> Any: 

799 """Create a Lambda client, preferring HTTPX when installed.""" 

800 if httpx_is_installed(): 

801 return create_async_lambda_client(endpoint_url, region_name) 

802 return create_sync_lambda_client(endpoint_url, region_name) 

803 

804 

805_LAMBDA_READ_TIMEOUT_SECONDS = 960 

806_LAMBDA_CLIENT_CONFIG = Config( 

807 parameter_validation=False, 

808 read_timeout=_LAMBDA_READ_TIMEOUT_SECONDS, 

809 retries={"max_attempts": 0}, 

810)