diff --git a/src/aws_durable_execution_sdk_python_testing/invoker.py b/src/aws_durable_execution_sdk_python_testing/invoker.py index 5c5441c..af386a7 100644 --- a/src/aws_durable_execution_sdk_python_testing/invoker.py +++ b/src/aws_durable_execution_sdk_python_testing/invoker.py @@ -9,6 +9,7 @@ DurableExecutionInvocationInputWithClient, DurableExecutionInvocationOutput, InitialExecutionState, + InvocationStatus, ) from aws_durable_execution_sdk_python_testing.exceptions import ( @@ -16,7 +17,6 @@ ) from aws_durable_execution_sdk_python_testing.model import LambdaContext - if TYPE_CHECKING: from collections.abc import Callable @@ -143,17 +143,107 @@ def invoke( function_name: str, input: DurableExecutionInvocationInput, ) -> DurableExecutionInvocationOutput: - # TODO: wrap ResourceNotFoundException from lambda in ResourceNotFoundException from this lib - response = self.lambda_client.invoke( - FunctionName=function_name, - InvocationType="RequestResponse", # Synchronous invocation - Payload=json.dumps(input.to_dict(), default=str), + """Invoke AWS Lambda function and return durable execution result. + + Args: + function_name: Name of the Lambda function to invoke + input: Durable execution invocation input + + Returns: + DurableExecutionInvocationOutput: Result of the function execution + + Raises: + ResourceNotFoundException: If function does not exist + InvalidParameterValueException: If parameters are invalid + DurableFunctionsTestError: For other invocation failures + """ + from aws_durable_execution_sdk_python_testing.exceptions import ( + ResourceNotFoundException, + InvalidParameterValueException, ) - # very simplified placeholder lol - if response["StatusCode"] == 200: # noqa: PLR2004 - json_response = json.loads(response["Payload"].read().decode("utf-8")) - return DurableExecutionInvocationOutput.from_dict(json_response) + # Parameter validation + if not function_name or not function_name.strip(): + msg = "Function name is required" + raise InvalidParameterValueException(msg) + + try: + # Invoke AWS Lambda function using standard invoke method + response = self.lambda_client.invoke( + FunctionName=function_name, + InvocationType="RequestResponse", # Synchronous invocation + Payload=json.dumps(input.to_dict(), default=str), + ) - msg: str = f"Lambda invocation failed with status code: {response['StatusCode']}, {response['Payload']=}" - raise DurableFunctionsTestError(msg) + # Check HTTP status code + status_code = response.get("StatusCode") + if status_code not in (200, 202, 204): + msg = f"Lambda invocation failed with status code: {status_code}" + raise DurableFunctionsTestError(msg) + + # Check for function errors + if "FunctionError" in response: + error_payload = response["Payload"].read().decode("utf-8") + msg = f"Lambda invocation failed with status {status_code}: {error_payload}" + raise DurableFunctionsTestError(msg) + + # Parse response payload + response_payload = response["Payload"].read().decode("utf-8") + response_dict = json.loads(response_payload) + + # Convert to DurableExecutionInvocationOutput + return DurableExecutionInvocationOutput.from_dict(response_dict) + + except self.lambda_client.exceptions.ResourceNotFoundException as e: + msg = f"Function not found: {function_name}" + raise ResourceNotFoundException(msg) from e + except self.lambda_client.exceptions.InvalidParameterValueException as e: + msg = f"Invalid parameter: {e}" + raise InvalidParameterValueException(msg) from e + except ( + self.lambda_client.exceptions.TooManyRequestsException, + self.lambda_client.exceptions.ServiceException, + self.lambda_client.exceptions.ResourceConflictException, + self.lambda_client.exceptions.InvalidRequestContentException, + self.lambda_client.exceptions.RequestTooLargeException, + self.lambda_client.exceptions.UnsupportedMediaTypeException, + self.lambda_client.exceptions.InvalidRuntimeException, + self.lambda_client.exceptions.InvalidZipFileException, + self.lambda_client.exceptions.ResourceNotReadyException, + self.lambda_client.exceptions.SnapStartTimeoutException, + self.lambda_client.exceptions.SnapStartNotReadyException, + self.lambda_client.exceptions.SnapStartException, + self.lambda_client.exceptions.RecursiveInvocationException, + ) as e: + msg = f"Lambda invocation failed: {e}" + raise DurableFunctionsTestError(msg) from e + except ( + self.lambda_client.exceptions.InvalidSecurityGroupIDException, + self.lambda_client.exceptions.EC2ThrottledException, + self.lambda_client.exceptions.EFSMountConnectivityException, + self.lambda_client.exceptions.SubnetIPAddressLimitReachedException, + self.lambda_client.exceptions.EC2UnexpectedException, + self.lambda_client.exceptions.InvalidSubnetIDException, + self.lambda_client.exceptions.EC2AccessDeniedException, + self.lambda_client.exceptions.EFSIOException, + self.lambda_client.exceptions.ENILimitReachedException, + self.lambda_client.exceptions.EFSMountTimeoutException, + self.lambda_client.exceptions.EFSMountFailureException, + ) as e: + msg = f"Lambda infrastructure error: {e}" + raise DurableFunctionsTestError(msg) from e + except ( + self.lambda_client.exceptions.KMSAccessDeniedException, + self.lambda_client.exceptions.KMSDisabledException, + self.lambda_client.exceptions.KMSNotFoundException, + self.lambda_client.exceptions.KMSInvalidStateException, + ) as e: + msg = f"Lambda KMS error: {e}" + raise DurableFunctionsTestError(msg) from e + except Exception as e: + # Handle any remaining exceptions, including custom ones like DurableExecutionAlreadyStartedException + if "DurableExecutionAlreadyStartedException" in str(type(e)): + msg = f"Durable execution already started: {e}" + raise DurableFunctionsTestError(msg) from e + msg = f"Unexpected error during Lambda invocation: {e}" + raise DurableFunctionsTestError(msg) from e diff --git a/tests/invoker_test.py b/tests/invoker_test.py index 9ab6216..33c02a8 100644 --- a/tests/invoker_test.py +++ b/tests/invoker_test.py @@ -192,7 +192,11 @@ def test_lambda_invoker_invoke_success(): def test_lambda_invoker_invoke_failure(): """Test lambda invocation failure.""" - lambda_client = Mock() + from aws_durable_execution_sdk_python_testing.exceptions import ( + DurableFunctionsTestError, + ) + + lambda_client, _ = _create_mock_lambda_client_with_exceptions() # Mock failed response mock_payload = Mock() @@ -211,7 +215,8 @@ def test_lambda_invoker_invoke_failure(): ) with pytest.raises( - Exception, match="Lambda invocation failed with status code: 500" + DurableFunctionsTestError, + match="Lambda invocation failed with status code: 500", ): invoker.invoke("test-function", input_data) @@ -266,3 +271,363 @@ def test_lambda_invoker_create_invocation_input_with_operations(): assert isinstance(invocation_input, DurableExecutionInvocationInput) assert len(invocation_input.initial_execution_state.operations) > 0 assert invocation_input.initial_execution_state.next_marker == "" + + +def test_lambda_invoker_invoke_empty_function_name(): + """Test lambda invocation with empty function name.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + InvalidParameterValueException, + ) + + lambda_client = Mock() + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises( + InvalidParameterValueException, match="Function name is required" + ): + invoker.invoke("", input_data) + + +def test_lambda_invoker_invoke_whitespace_function_name(): + """Test lambda invocation with whitespace-only function name.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + InvalidParameterValueException, + ) + + lambda_client = Mock() + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises( + InvalidParameterValueException, match="Function name is required" + ): + invoker.invoke(" ", input_data) + + +def test_lambda_invoker_invoke_status_202(): + """Test lambda invocation with status code 202.""" + lambda_client = Mock() + + mock_payload = Mock() + mock_payload.read.return_value = json.dumps( + {"Status": "SUCCEEDED", "Result": "async-result"} + ).encode("utf-8") + + lambda_client.invoke.return_value = { + "StatusCode": 202, + "Payload": mock_payload, + } + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + result = invoker.invoke("test-function", input_data) + assert isinstance(result, DurableExecutionInvocationOutput) + + +def test_lambda_invoker_invoke_function_error(): + """Test lambda invocation with function error.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + DurableFunctionsTestError, + ) + + lambda_client, _ = _create_mock_lambda_client_with_exceptions() + + mock_payload = Mock() + mock_payload.read.return_value = b'{"errorMessage": "Function failed"}' + + lambda_client.invoke.return_value = { + "StatusCode": 200, + "FunctionError": "Unhandled", + "Payload": mock_payload, + } + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises( + DurableFunctionsTestError, match="Lambda invocation failed with status 200" + ): + invoker.invoke("test-function", input_data) + + +def _create_mock_lambda_client_with_exceptions(): + """Helper to create mock lambda client with all exception types.""" + lambda_client = Mock() + + class MockException(Exception): + pass + + exceptions_mock = Mock() + for exc_name in [ + "ResourceNotFoundException", + "InvalidParameterValueException", + "TooManyRequestsException", + "ServiceException", + "ResourceConflictException", + "InvalidRequestContentException", + "RequestTooLargeException", + "UnsupportedMediaTypeException", + "InvalidRuntimeException", + "InvalidZipFileException", + "ResourceNotReadyException", + "SnapStartTimeoutException", + "SnapStartNotReadyException", + "SnapStartException", + "RecursiveInvocationException", + "InvalidSecurityGroupIDException", + "EC2ThrottledException", + "EFSMountConnectivityException", + "SubnetIPAddressLimitReachedException", + "EC2UnexpectedException", + "InvalidSubnetIDException", + "EC2AccessDeniedException", + "EFSIOException", + "ENILimitReachedException", + "EFSMountTimeoutException", + "EFSMountFailureException", + "KMSAccessDeniedException", + "KMSDisabledException", + "KMSNotFoundException", + "KMSInvalidStateException", + ]: + setattr(exceptions_mock, exc_name, MockException) + + lambda_client.exceptions = exceptions_mock + return lambda_client, MockException + + +def test_lambda_invoker_invoke_resource_not_found(): + """Test lambda invocation with ResourceNotFoundException.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + ResourceNotFoundException, + ) + + lambda_client, _ = _create_mock_lambda_client_with_exceptions() + + # Create specific exception for ResourceNotFoundException + class MockResourceNotFoundException(Exception): + pass + + lambda_client.exceptions.ResourceNotFoundException = MockResourceNotFoundException + + lambda_client.invoke.side_effect = MockResourceNotFoundException( + "Function not found" + ) + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises( + ResourceNotFoundException, match="Function not found: test-function" + ): + invoker.invoke("test-function", input_data) + + +def test_lambda_invoker_invoke_invalid_parameter(): + """Test lambda invocation with InvalidParameterValueException.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + InvalidParameterValueException, + ) + + lambda_client, MockException = _create_mock_lambda_client_with_exceptions() + + # Override specific exception for this test + class MockInvalidParameterValueException(Exception): + pass + + lambda_client.exceptions.InvalidParameterValueException = ( + MockInvalidParameterValueException + ) + + lambda_client.invoke.side_effect = MockInvalidParameterValueException( + "Invalid param" + ) + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises(InvalidParameterValueException, match="Invalid parameter"): + invoker.invoke("test-function", input_data) + + +def test_lambda_invoker_invoke_service_exception(): + """Test lambda invocation with ServiceException.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + DurableFunctionsTestError, + ) + + lambda_client, _ = _create_mock_lambda_client_with_exceptions() + + # Create specific exception for ServiceException + class MockServiceException(Exception): + pass + + lambda_client.exceptions.ServiceException = MockServiceException + + lambda_client.invoke.side_effect = MockServiceException("Service error") + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises(DurableFunctionsTestError, match="Lambda invocation failed"): + invoker.invoke("test-function", input_data) + + +def test_lambda_invoker_invoke_ec2_exception(): + """Test lambda invocation with EC2 exception.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + DurableFunctionsTestError, + ) + + lambda_client, _ = _create_mock_lambda_client_with_exceptions() + + # Create specific exception for EC2AccessDeniedException + class MockEC2Exception(Exception): + pass + + lambda_client.exceptions.EC2AccessDeniedException = MockEC2Exception + + lambda_client.invoke.side_effect = MockEC2Exception("Access denied") + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises(DurableFunctionsTestError, match="Lambda infrastructure error"): + invoker.invoke("test-function", input_data) + + +def test_lambda_invoker_invoke_kms_exception(): + """Test lambda invocation with KMS exception.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + DurableFunctionsTestError, + ) + + lambda_client, _ = _create_mock_lambda_client_with_exceptions() + + # Create specific exception for KMSAccessDeniedException + class MockKMSException(Exception): + pass + + lambda_client.exceptions.KMSAccessDeniedException = MockKMSException + + lambda_client.invoke.side_effect = MockKMSException("KMS access denied") + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises(DurableFunctionsTestError, match="Lambda KMS error"): + invoker.invoke("test-function", input_data) + + +def test_lambda_invoker_invoke_durable_execution_already_started(): + """Test lambda invocation with DurableExecutionAlreadyStartedException.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + DurableFunctionsTestError, + ) + + lambda_client, _ = _create_mock_lambda_client_with_exceptions() + + class MockDurableExecutionAlreadyStartedException(Exception): + pass + + MockDurableExecutionAlreadyStartedException.__name__ = ( + "DurableExecutionAlreadyStartedException" + ) + + lambda_client.invoke.side_effect = MockDurableExecutionAlreadyStartedException( + "Already started" + ) + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises( + DurableFunctionsTestError, match="Durable execution already started" + ): + invoker.invoke("test-function", input_data) + + +def test_lambda_invoker_invoke_unexpected_exception(): + """Test lambda invocation with unexpected exception.""" + from aws_durable_execution_sdk_python_testing.exceptions import ( + DurableFunctionsTestError, + ) + + lambda_client, _ = _create_mock_lambda_client_with_exceptions() + lambda_client.invoke.side_effect = RuntimeError("Unexpected error") + + invoker = LambdaInvoker(lambda_client) + + input_data = DurableExecutionInvocationInput( + durable_execution_arn="test-arn", + checkpoint_token="test-token", + initial_execution_state=InitialExecutionState(operations=[], next_marker=""), + is_local_runner=False, + ) + + with pytest.raises( + DurableFunctionsTestError, match="Unexpected error during Lambda invocation" + ): + invoker.invoke("test-function", input_data)