diff --git a/packages/aws-durable-execution-sdk-python-extras/README.md b/packages/aws-durable-execution-sdk-python-extras/README.md index 587668ab0..d064a14d3 100644 --- a/packages/aws-durable-execution-sdk-python-extras/README.md +++ b/packages/aws-durable-execution-sdk-python-extras/README.md @@ -98,7 +98,7 @@ Every MicroVM failure is a `MicrovmError`. Catch the base class for all of them, | Error | When | |---|---| -| `MicrovmLaunchError` | `RunMicrovm` failed after all retries. A MicroVM can still run the job. The list below the table says when. | +| `MicrovmLaunchError` | `RunMicrovm` failed after all retries, or the launch request was not valid, for example with an empty `image_identifier` or `execution_role_arn`. A MicroVM can still run the job. The list below the table says when. | | `MicrovmDeliveryError` | HTTP delivery failed after all retries, or the route answered a 4xx status such as 404, or a redirect. | | `MicrovmNotRunningError` | A `MicrovmDeliveryError`: the MicroVM is terminating, terminated, or removed. It is not retried. A session that checks the MicroVM state before each job reports it. Any job over HTTP also reports it when `CreateMicrovmAuthToken` reports the MicroVM removed. See [Suspending an idle MicroVM](#suspending-an-idle-microvm). | | `MicrovmJobFailedError` | The job handler in the MicroVM failed, or the MicroVM sent a result that is not valid JSON. The message includes the error type that the MicroVM reported. | @@ -123,7 +123,7 @@ except MicrovmError: ... # Any other MicroVM failure. ``` -The class is the same on the first run and on every replay, so code that branches on it stays deterministic. A `microvm` call inside your own child context, or inside a `map` or `parallel` item, reaches the code around that context as the SDK's `ChildContextError`. Its `__cause__` is the MicroVM error, so check `isinstance(error.__cause__, MicrovmTimeoutError)` there. An invalid name or config raises the SDK's `ValidationError`, and an input that is not JSON-serializable raises `TypeError`, before any durable operation starts. `Duration` raises the same `ValidationError` for a negative value. +The class is the same on the first run and on every replay, so code that branches on it stays deterministic. A `microvm` call inside your own child context, or inside a `map` or `parallel` item, reaches the code around that context as the SDK's `ChildContextError`. Its `__cause__` is the MicroVM error, so check `isinstance(error.__cause__, MicrovmTimeoutError)` there. An invalid name or config raises the SDK's `ValidationError`, and an input that is not JSON-serializable raises `TypeError`, before any durable operation starts. `Duration` raises the same `ValidationError` for a negative value. The operation does not check `image_identifier` and `execution_role_arn` before it starts. boto3 checks them against the API model before it sends `RunMicrovm`, and the service checks them again. So an invalid value fails the launch step at once with `MicrovmLaunchError`, after the callback step. A failed AWS call keeps its request ID in the error message, in the form ` request ID: .`, for example `RunMicrovm request ID: .`. A support case can quote it. This applies to `RunMicrovm`, `GetMicrovm`, `ResumeMicrovm`, `CreateMicrovmAuthToken`, and `TerminateMicrovm`. The SDK checkpoints the message of a failed step, and not its `__cause__`. So the request ID is in the message, the handler sees it on the first run and on replay, and the execution history keeps it. A failed `TerminateMicrovm` call does not fail the operation. So a `TerminateMicrovm` request ID appears only in the terminate log entry and in the terminate step record. A message has no request ID when the answer carries none, for example after a connection failure. A message also has no request ID when the error is not the answer of the Lambda MicroVMs call. For example, a `client` with role-assuming credentials calls STS `AssumeRole` inside each call, and an STS error carries the request ID of STS. The operation recognizes such an error by its `operation_name`. The step raises a copy of the botocore error, of the same class, with the same `response`, and with the request ID in its message. So `default_microvm_retry_strategy` decides as before. A custom `retry_strategy` receives the error that botocore raised, without the request ID. So a strategy that matches the message, such as `create_retry_strategy(RetryStrategyConfig(retryable_errors=[...]))`, decides on the message of the service. A `ResourceNotFoundException` from `GetMicrovm`, `ResumeMicrovm`, or `CreateMicrovmAuthToken` is the exception. It means that the MicroVM no longer exists. So the operation raises `MicrovmStateError` instead, and the strategy receives that error. Its `__cause__` is the botocore error. An error that the operation creates itself, such as `MicrovmStateError` or "RunMicrovm returned no microvmId", has the request ID in its message when the strategy receives it. A custom client can raise one error object for several calls. Each step message then names only its own call, and the object keeps its message. @@ -240,7 +240,7 @@ A failed `vm.invoke` raises its MicroVM error inside the handler. The handler ca 1. A MicroVM error that the handler raises itself reaches the caller with its own class, such as `MicrovmJobFailedError`. 2. Any other error reaches the caller as the SDK's `ChildContextError`, as from `run_in_child_context`. This includes a MicroVM error that leaves a child context that the handler created: that child context records `ChildContextError` as its error type. The caller then gets the MicroVM error's message, but not its class. Inside the handler, the `ChildContextError` of that child context has the MicroVM error as its `__cause__`. An SDK error that leaves the handler, such as the `StepError` of a failed step, also reaches the caller as `ChildContextError`, with the SDK error as its `__cause__`. This rule matches `run_in_child_context`. The JavaScript operation differs here: it passes an SDK error to the caller with its own class. -An invalid job raises `ValidationError` or `TypeError` from `vm.invoke`, before the job's durable operations start. The handler can catch it too. +An invalid job raises `ValidationError` or `TypeError` from `vm.invoke`, before the job's durable operations start. The handler can catch it too. A call without the context, `vm.invoke("build", job_input=..., options=...)`, raises Python's `TypeError`. A first argument that is not a durable context, as in `vm.invoke("build", "build", ...)`, raises `AttributeError`. The handler's return value is checkpointed as the session's result. So it must be serializable by the SDK, and it counts toward the checkpoint size limit. @@ -333,14 +333,15 @@ The MicroVM operations handle the network, the service, and the worker lifecycle ### Sessions -1. Pass `vm.invoke` the context that the session handler receives, or a context nested in it. A job created in another context runs, and the next replay fails the execution with `NonDeterministicExecutionError`. `vm.invoke` raises `TypeError` only for the context that `microvm_session` was called with. It does not detect any other wrong context. See [`microvm_session`](#microvm_session-run-several-jobs-in-one-microvm). -2. If you set `auto_suspend_idle_time`, meet three conditions: +1. Call `vm.invoke(ctx, name, job_input=..., options=...)` with the context that the session handler receives, or a context nested in it. A job created in another context runs, and the next replay fails the execution with `NonDeterministicExecutionError`. `vm.invoke` raises `TypeError` only for the context that `microvm_session` was called with. It does not detect any other wrong context. See [`microvm_session`](#microvm_session-run-several-jobs-in-one-microvm). +2. Pass a function as `handler`. `microvm_session` does not check it before it starts. A value that is not callable raises Python's `TypeError` when the session calls it, after the launch. The session then terminates the MicroVM, and the caller receives `ChildContextError`. So the mistake costs one MicroVM boot. mypy reports it before that. +3. If you set `auto_suspend_idle_time`, meet three conditions: - `auto_suspend_on_idle` is `True`. - The idle time is at least 10 seconds. - The idle time is at most the session `timeout`. A value that breaks any one condition raises `ValidationError` when `microvm_session` is called, before any durable operation starts. See [Suspending an idle MicroVM](#suspending-an-idle-microvm). -3. Change `auto_suspend_on_idle` or `idle_policy` only through a new version, as in [Deploys and versions](#deploys-and-versions). The launch records the resume mode, and a deploy changes it only for sessions that launch after it. Every job of a session that launched with suspending needs `lambda:GetMicrovm` and `lambda:ResumeMicrovm`, also after a deploy turns suspending off. So remove them from the function's role only as [Suspending an idle MicroVM](#suspending-an-idle-microvm) describes. +4. Change `auto_suspend_on_idle` or `idle_policy` only through a new version, as in [Deploys and versions](#deploys-and-versions). The launch records the resume mode, and a deploy changes it only for sessions that launch after it. Every job of a session that launched with suspending needs `lambda:GetMicrovm` and `lambda:ResumeMicrovm`, also after a deploy turns suspending off. So remove them from the function's role only as [Suspending an idle MicroVM](#suspending-an-idle-microvm) describes. ### Retry strategies diff --git a/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/aws_errors.py b/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/aws_errors.py index 73b0d7d9d..d18de11e6 100644 --- a/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/aws_errors.py +++ b/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/aws_errors.py @@ -13,7 +13,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable from typing import Literal from aws_durable_execution_sdk_python.retries import RetryDecision @@ -58,14 +58,14 @@ def request_id_sentence(operation: MicrovmApiOperation, response: object) -> str ``response`` is the output of a boto3 call, or the ``response`` of a ``ClientError``. Both carry the request ID in - ``ResponseMetadata.RequestId``. A custom client can return an output - without it. So the function returns ``""`` when ``response`` has no - request ID that is a non-empty string. + ``ResponseMetadata.RequestId``. A custom client or a fake can return an + output that is not a dict, or a dict without ``ResponseMetadata``. So the + function returns ``""`` when ``response`` has no request ID that is a + non-empty string. A ``ResponseMetadata`` value that is neither a dict + nor empty raises ``AttributeError``. boto3 always returns a dict there. """ - metadata = ( - response.get("ResponseMetadata") if isinstance(response, Mapping) else None - ) - request_id = metadata.get("RequestId") if isinstance(metadata, Mapping) else None + data = response if isinstance(response, dict) else {} + request_id = (data.get("ResponseMetadata") or {}).get("RequestId") if isinstance(request_id, str) and request_id: return f"{operation} request ID: {request_id}." return "" @@ -143,8 +143,11 @@ class of ``error``. A wrapper of another class would change the - ``error`` is not the answer of ``operation``. See :func:`is_microvms_service_error`. - ``error`` has no request ID. - - The class of ``error`` builds ``str()`` from something other than - ``args``. The copy would not show the request ID. + + A class of ``error`` can build ``str()`` from something other than + ``args``. Then neither ``str()`` of the copy nor ``str()`` of ``error`` + contains the request ID. The step records the same message either way, and the + strategy still receives ``error``. So the function returns the copy. """ if not is_microvms_service_error(operation, error): return error @@ -154,8 +157,6 @@ class of ``error``. A wrapper of another class would change the labelled = type(error).__new__(type(error)) labelled.__dict__.update(vars(error)) labelled.args = (message,) - if str(labelled) != message: - return error labelled.__cause__ = error labelled.__dict__["_original_error"] = error return labelled diff --git a/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/operation.py b/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/operation.py index 564ccf4e1..c4d3ea67f 100644 --- a/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/operation.py +++ b/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/operation.py @@ -13,7 +13,7 @@ import time from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import boto3 from botocore.config import Config @@ -258,7 +258,8 @@ def microvm( value. TypeError: When ``job_input`` is not JSON-serializable. No durable operation starts. - MicrovmLaunchError: When ``RunMicrovm`` fails after all retries. + MicrovmLaunchError: When ``RunMicrovm`` fails after all retries, or + rejects the config, for example an empty ``image_identifier``. MicrovmNotRunningError: When ``CreateMicrovmAuthToken`` reports that the MicroVM no longer exists. It is a ``MicrovmDeliveryError``. MicrovmDeliveryError: When the HTTP delivery fails after all retries, @@ -302,9 +303,14 @@ def step_retry_strategy( ) -def _validate_base(name: str, config: MicrovmBaseConfig, timeout: Duration) -> int: +def _validate_base(name: str, timeout: Duration) -> int: """Validate the settings that every MicroVM operation shares. + ``image_identifier`` and ``execution_role_arn`` are not checked here. + The boto3 client rejects an empty value before it sends ``RunMicrovm``, + and the service rejects any other invalid value. Either way, the launch + step fails, and the operation raises ``MicrovmLaunchError``. + Returns: The timeout in seconds. @@ -314,12 +320,6 @@ def _validate_base(name: str, config: MicrovmBaseConfig, timeout: Duration) -> i if not isinstance(name, str) or not name: msg = "a MicroVM operation requires a non-empty name" raise ValidationError(msg) - if not config.image_identifier: - msg = f'MicroVM "{name}": image_identifier is required' - raise ValidationError(msg) - if not config.execution_role_arn: - msg = f'MicroVM "{name}": execution_role_arn is required' - raise ValidationError(msg) return _validate_timeout(name, "timeout", timeout) @@ -500,20 +500,14 @@ def deliver( port = DEFAULT_MICROVM_PORT if request.port is None else request.port def attempt(step_context: StepContext) -> None: - # The launch records an endpoint for every HTTP delivery. So a - # missing endpoint is a defect, not a transient failure. The - # default retry strategy does not retry RuntimeError. - if launched.endpoint is None: - msg = ( - f"the launch recorded no endpoint for MicroVM " - f"{launched.microvm_id}, so the job cannot be sent over HTTP" - ) - raise RuntimeError(msg) client = self._client() # The state check before delivery and the request's retries share # one window. So the wait for a resume counts toward it. window_started_at = time.monotonic() - endpoint = launched.endpoint + # _launch_call raises when RunMicrovm returns no endpoint for an + # HTTP delivery. The launch step checkpoints only what + # _launch_call returned. So an HTTP launch always has an endpoint. + endpoint = cast("str", launched.endpoint) recheck: Callable[[float], str | None] | None = None if resume: require_time_for_call(remaining_seconds) @@ -727,7 +721,7 @@ def create( Region cannot be determined. TypeError: When ``job_input`` is not JSON-serializable. """ - timeout_seconds = _validate_base(name, config, config.timeout) + timeout_seconds = _validate_base(name, config.timeout) heartbeat_timeout_seconds = _validate_heartbeat(name, config.heartbeat_timeout) request = config.request or MicrovmRequestConfig() retry_window_seconds = _validate_request(name, request) diff --git a/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/session.py b/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/session.py index 43d15581a..4f3427224 100644 --- a/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/session.py +++ b/packages/aws-durable-execution-sdk-python-extras/src/aws_durable_execution_sdk_python_extras/microvm/session.py @@ -165,16 +165,15 @@ def pipeline(vm: MicrovmSession, ctx: DurableContext) -> Any: Raises: ValidationError: When ``name`` or ``config`` is not valid, or the Region cannot be determined. No durable operation starts. - TypeError: When ``handler`` is not callable. No durable operation - starts. - MicrovmLaunchError: When ``RunMicrovm`` fails after all retries. + MicrovmLaunchError: When ``RunMicrovm`` fails after all retries, or + rejects the config, for example an empty ``image_identifier``. MicrovmError: A MicroVM error that the handler did not catch. - ChildContextError: Any other error that the handler raised. + ChildContextError: Any other error that the handler raised. A + ``handler`` that is not callable raises Python's ``TypeError`` + after the launch, inside the child context. So it also reaches + the caller as ``ChildContextError``, after the terminate step. """ scope = _SessionScope.create(context, name, config) - if not callable(handler): - msg = f'MicroVM session "{name}": handler must be callable' - raise TypeError(msg) def run(child: DurableContext) -> _T: return scope.run_session(child, handler) @@ -245,9 +244,12 @@ def invoke( ``SendDurableExecutionCallbackSuccess``, parsed as JSON. Raises: - TypeError: When ``context`` is not a durable context, or is the - context that ``microvm_session`` was called with, or - ``job_input`` is not JSON-serializable. + TypeError: When ``context`` is the context that + ``microvm_session`` was called with, or ``job_input`` is not + JSON-serializable. + AttributeError: When ``context`` is not a durable context, for + example a job name passed as the first argument. Python + raises it at ``context.run_in_child_context``. ValidationError: When ``name`` or ``options`` is not valid. MicrovmNotRunningError: When the session's MicroVM has ended. The session reports it when it checks the MicroVM state before @@ -260,15 +262,6 @@ def invoke( MicrovmTimeoutError: When no result or heartbeat arrives in time. """ scope = self._scope - # A call in the shape vm.invoke("build", ...) passes a string here. - # This check names the mistake, instead of an AttributeError from - # inside. - if not callable(getattr(context, "run_in_child_context", None)): - msg = ( - f'MicroVM session "{scope.name}": invoke requires the durable ' - "context to create the job in, as its first argument" - ) - raise TypeError(msg) # The caller's own context is often in scope in the handler's # closure. A job created in it lands outside the session's child # context. The core SDK does not refuse that, and the next replay @@ -359,7 +352,7 @@ def create( ValidationError: When ``name`` or ``config`` is not valid, or the Region cannot be determined. """ - timeout_seconds = _validate_base(name, config, config.timeout) + timeout_seconds = _validate_base(name, config.timeout) auto_suspend_idle_seconds = _validate_auto_suspend( name, config, timeout_seconds ) diff --git a/packages/aws-durable-execution-sdk-python-extras/tests/microvm/aws_errors_test.py b/packages/aws-durable-execution-sdk-python-extras/tests/microvm/aws_errors_test.py index 8780e67b9..c600d7fb8 100644 --- a/packages/aws-durable-execution-sdk-python-extras/tests/microvm/aws_errors_test.py +++ b/packages/aws-durable-execution-sdk-python-extras/tests/microvm/aws_errors_test.py @@ -49,7 +49,7 @@ def response(request_id: Any = "req-1", code: str = "ThrottlingException") -> An ({"ResponseMetadata": {"RequestId": ""}}, ""), ({"ResponseMetadata": {"RequestId": 7}}, ""), ({"ResponseMetadata": {}}, ""), - ({"ResponseMetadata": "req-1"}, ""), + ({"ResponseMetadata": None}, ""), ({}, ""), (None, ""), ("req-1", ""), @@ -127,14 +127,21 @@ def test_returns_the_error_itself_when_it_gets_no_request_id( assert error.__cause__ is None -def test_returns_the_error_itself_when_its_class_builds_str_otherwise() -> None: +def test_copies_an_error_whose_class_builds_str_otherwise() -> None: + """The copy shows the same ``str()`` as the error. So the step records + the same message, and the strategy still receives the error.""" + class FixedMessageError(ClientError): def __str__(self) -> str: return "fixed" error = FixedMessageError(response(), "RunMicrovm") - assert add_request_id("RunMicrovm", error) is error + labelled = add_request_id("RunMicrovm", error) + + assert type(labelled) is FixedMessageError + assert str(labelled) == "fixed" + assert vars(labelled)["_original_error"] is error def test_copies_a_subclass_whose_init_takes_other_arguments() -> None: diff --git a/packages/aws-durable-execution-sdk-python-extras/tests/microvm/operation_test.py b/packages/aws-durable-execution-sdk-python-extras/tests/microvm/operation_test.py index 12d977801..ff302d639 100644 --- a/packages/aws-durable-execution-sdk-python-extras/tests/microvm/operation_test.py +++ b/packages/aws-durable-execution-sdk-python-extras/tests/microvm/operation_test.py @@ -589,29 +589,6 @@ def test_fails_a_launch_without_an_endpoint_for_http( assert "RunMicrovm returned no endpoint" in error["message"] -def test_fails_the_request_for_a_launch_checkpoint_without_an_endpoint( - run: Any, fake_client: Any, monkeypatch: pytest.MonkeyPatch -) -> None: - original = operation._MicrovmScope._launch_call # noqa: SLF001 - - def without_endpoint(self: Any, *args: Any) -> Any: - launched = original(self, *args) - return operation.LaunchResult( - microvm_id=launched.microvm_id, delivery=launched.delivery - ) - - monkeypatch.setattr(operation._MicrovmScope, "_launch_call", without_endpoint) # noqa: SLF001 - client = fake_client() - config = config_with(client, request=MicrovmRequestConfig(path="/job")) - - result = run(catching(config), {}) - - error = outcome(result) - assert error["error"] == "MicrovmDeliveryError" - assert "the launch recorded no endpoint for MicroVM mvm-1" in error["message"] - assert client.requests("terminate_microvm") == [{"microvmIdentifier": "mvm-1"}] - - def test_raises_the_same_class_after_the_handler_stops_catching( run: Any, fake_client: Any ) -> None: @@ -771,6 +748,44 @@ def test_http_requests_match_the_lambda_microvms_api_model( assert endpoint.requests[0]["headers"]["x-amz-microvm-token"] == "t" +@pytest.mark.parametrize( + ("field", "parameter"), + [ + ("image_identifier", "imageIdentifier"), + ("execution_role_arn", "executionRoleArn"), + ], +) +def test_fails_the_launch_for_an_empty_image_or_role( + run: Any, field: str, parameter: str +) -> None: + """The operation does not check the image and the role. botocore checks + their length against the API model before it sends RunMicrovm.""" + client = boto3.session.Session().client("lambda-microvms", region_name="us-west-2") + config = config_with(client, **{field: ""}) + with Stubber(client) as stub: + # The response is never used. Stubber needs one queued response to + # let the call reach botocore's parameter check. + stub.add_response( + "run_microvm", + { + "microvmId": "mvm-1", + "state": "PENDING", + "endpoint": "mvm-1.example", + "imageArn": "arn:aws:lambda:us-west-2:123456789012:microvm-image:img", + "imageVersion": "1", + "maximumDurationInSeconds": 600, + "startedAt": datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC), + }, + ) + + result = run(catching(config), {"n": 1}) + + client.close() + error = outcome(result) + assert error["error"] == "MicrovmLaunchError" + assert f"Invalid length for parameter {parameter}" in error["message"] + + def test_keeps_the_class_inside_the_callers_child_context( run: Any, fake_client: Any ) -> None: diff --git a/packages/aws-durable-execution-sdk-python-extras/tests/microvm/session_test.py b/packages/aws-durable-execution-sdk-python-extras/tests/microvm/session_test.py index 4af8af689..78cdfa883 100644 --- a/packages/aws-durable-execution-sdk-python-extras/tests/microvm/session_test.py +++ b/packages/aws-durable-execution-sdk-python-extras/tests/microvm/session_test.py @@ -329,9 +329,16 @@ def body(vm: MicrovmSession, ctx: DurableContext, event: Any) -> Any: ("call", "error", "message"), [ ( - lambda vm, ctx: vm.invoke("build", "build", job_input=1, options=job()), # type: ignore[arg-type] + # Python binds "build" to context, so name is missing. + lambda vm, ctx: vm.invoke("build", job_input=1, options=job()), # type: ignore[call-arg, arg-type] "TypeError", - "invoke requires the durable context", + "missing 1 required positional argument: 'name'", + ), + ( + # Python raises AttributeError at context.run_in_child_context. + lambda vm, ctx: vm.invoke("build", "build", job_input=1, options=job()), # type: ignore[arg-type] + "AttributeError", + "'str' object has no attribute 'run_in_child_context'", ), ( lambda vm, ctx: vm.invoke(ctx, "", job_input=1, options=job()), @@ -380,7 +387,7 @@ def test_rejects_an_invalid_job_inside_the_handler_where_it_can_be_caught( def body(vm: MicrovmSession, ctx: DurableContext, event: Any) -> Any: try: call(vm, ctx) - except (TypeError, ValidationError) as raised: + except (AttributeError, TypeError, ValidationError) as raised: return [type(raised).__name__, str(raised)] return None @@ -431,7 +438,6 @@ def test_fails_with_the_launch_error_and_skips_terminate( @pytest.mark.parametrize( ("fields", "message"), [ - ({"image_identifier": ""}, "image_identifier is required"), ({"timeout": Duration.from_hours(9)}, "timeout must be between 1 second"), ( {"auto_suspend_idle_time": Duration.from_seconds(30)}, @@ -479,23 +485,30 @@ def handler(event: Any, context: DurableContext) -> Any: assert client.calls == [] -def test_rejects_a_handler_that_is_not_callable(run: Any, fake_client: Any) -> None: +def test_fails_inside_the_session_for_a_handler_that_is_not_callable( + run: Any, fake_client: Any, fake_endpoint: Any +) -> None: + """Python raises TypeError when the session calls the handler. That call + runs inside the session's child context, after the launch. So the caller + gets ChildContextError, and the session terminates the MicroVM.""" + client = fake_client(endpoint=fake_endpoint().url) + @durable_execution def handler(event: Any, context: DurableContext) -> Any: try: microvm_session( context, "pipeline", - config=session_config(fake_client()), + config=session_config(client), handler="build", # type: ignore[arg-type] ) - except TypeError as error: - return str(error) + except ChildContextError as error: + return [error.error_type, error.message] return None - assert ( - outcome(run(handler)) == 'MicroVM session "pipeline": handler must be callable' - ) + assert outcome(run(handler)) == ["TypeError", "'str' object is not callable"] + assert len(client.requests("run_microvm")) == 1 + assert client.requests("terminate_microvm") == [{"microvmIdentifier": "mvm-1"}] def test_passes_an_idle_policy_through_and_resumes_before_each_job( diff --git a/packages/aws-durable-execution-sdk-python-extras/tests/microvm/validation_test.py b/packages/aws-durable-execution-sdk-python-extras/tests/microvm/validation_test.py index 2ce94ede1..2dbf3efa8 100644 --- a/packages/aws-durable-execution-sdk-python-extras/tests/microvm/validation_test.py +++ b/packages/aws-durable-execution-sdk-python-extras/tests/microvm/validation_test.py @@ -47,8 +47,6 @@ def config(**fields: Any) -> MicrovmConfig: ("name", "fields", "message"), [ ("", {}, "requires a non-empty name"), - ("build", {"image_identifier": ""}, "image_identifier is required"), - ("build", {"execution_role_arn": ""}, "execution_role_arn is required"), ("build", {"timeout": Duration(0)}, "timeout must be between 1 second"), ( "build", diff --git a/packages/aws-durable-execution-sdk-python-microvm-worker/README.md b/packages/aws-durable-execution-sdk-python-microvm-worker/README.md index df64053bd..209b6ead6 100644 --- a/packages/aws-durable-execution-sdk-python-microvm-worker/README.md +++ b/packages/aws-durable-execution-sdk-python-microvm-worker/README.md @@ -35,7 +35,7 @@ if __name__ == "__main__": worker.wait() ``` -The worker needs `handler`, `routes`, or both. A handler is a plain function. An `async def` function, or an object whose `__call__` is `async def`, raises `TypeError`. A handler that returns an awaitable fails its job with a `TypeError`, because the worker cannot run it. The handler's return value becomes the durable function's result, and must be JSON-serializable. An exception fails the durable operation with the exception's class name and message. `context.cancelled` is set when the durable function stops waiting, for example after a heartbeat timeout, and when Lambda calls the `terminate` hook. Python cannot stop a running thread, so the handler should check the event and stop its work. +The worker needs `handler`, `routes`, or both. A handler is a plain function. An `async def` function, or an object whose `__call__` is `async def`, raises `TypeError`. A plain function that returns a coroutine fails its job with `ResultSerializationError`, because a coroutine is not JSON-serializable. Python then warns that the coroutine was never awaited. The handler's return value becomes the durable function's result, and must be JSON-serializable. An exception fails the durable operation with the exception's class name and message. `context.cancelled` is set when the durable function stops waiting, for example after a heartbeat timeout, and when Lambda calls the `terminate` hook. Python cannot stop a running thread, so the handler should check the event and stop its work. `start_microvm_worker` serves in a daemon thread and returns. So the entry point ends with `worker.wait()`, which blocks until `worker.close()`. @@ -211,6 +211,6 @@ The worker needs boto3 1.43.35 or later, the first release with the `lambda-micr These rules apply to the `handler` and the route handlers that you pass to `start_microvm_worker`. Each rule states what happens if a handler breaks it. -1. Write each handler as a plain function. To run async code, call `asyncio.run` inside the handler. The worker runs each job in a thread of its own, and that thread has no running event loop. So `asyncio.run` can start one there. An `async def` handler makes `start_microvm_worker` raise `TypeError`, and a handler that returns an awaitable fails each of its jobs. See [Usage](#usage). +1. Write each handler as a plain function. To run async code, call `asyncio.run` inside the handler. The worker runs each job in a thread of its own, and that thread has no running event loop. So `asyncio.run` can start one there. An `async def` handler makes `start_microvm_worker` raise `TypeError`, and a handler that returns a coroutine fails each of its jobs with `ResultSerializationError`. See [Usage](#usage). 2. Return a JSON-serializable value of at most 256 KB. The worker reports any other value as a failure of the job. So the durable function raises `MicrovmJobFailedError`. Store a larger result elsewhere, for example in S3, and return its key. See [Reporting](#reporting). 3. Pass a `logger` that does not raise. The worker drops each line whose logger raises, and it continues. So that line is lost, and nothing reports the loss. See [Logging](#logging). diff --git a/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/worker.py b/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/worker.py index e6d5d0e3b..f07615675 100644 --- a/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/worker.py +++ b/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/worker.py @@ -11,7 +11,6 @@ from __future__ import annotations -import contextlib import inspect import json import os @@ -1853,30 +1852,16 @@ def of(cls, run: Callable[[], Any]) -> _Outcome: raises ``SystemExit``, which would otherwise end the job thread without a report. - A handler can still return an awaitable, for example a plain function - that calls an async function and returns its coroutine. The worker - cannot run it. So the outcome is a ``TypeError`` that names the - cause, and a coroutine is closed, so that Python does not warn that - it was never awaited. + A plain function can return a coroutine, for example by calling an + async function without ``await``. The coroutine is kept as the + value. It is not JSON-serializable, so the report fails the job with + ``ResultSerializationError``. Python then warns that the coroutine was + never awaited. """ try: - value = run() + return cls(value=run()) except BaseException as error: # noqa: BLE001 return cls(error=error) - if inspect.isawaitable(value): - if inspect.iscoroutine(value): - # close() raises when the coroutine has started and ignores - # GeneratorExit, for example with an await in a finally - # block. The TypeError below must still be reported. - with contextlib.suppress(Exception): - value.close() - return cls( - error=TypeError( - "the job handler returned an awaitable. A job handler must " - "be a plain function that returns its result." - ) - ) - return cls(value=value) class _RequestHandler(BaseHTTPRequestHandler): diff --git a/packages/aws-durable-execution-sdk-python-microvm-worker/tests/worker_test.py b/packages/aws-durable-execution-sdk-python-microvm-worker/tests/worker_test.py index beeb115a6..1d97b0380 100644 --- a/packages/aws-durable-execution-sdk-python-microvm-worker/tests/worker_test.py +++ b/packages/aws-durable-execution-sdk-python-microvm-worker/tests/worker_test.py @@ -5,17 +5,15 @@ from __future__ import annotations -import contextlib import functools import http.client -import inspect import io import json import socket import struct import threading import time -from collections.abc import Callable, Generator, Iterator +from collections.abc import Callable, Iterator from http.server import ThreadingHTTPServer from typing import Any @@ -1550,62 +1548,22 @@ async def __call__(self, job_input: Any, context: MicrovmJobContext) -> None: return None # pragma: no cover -class _Awaitable: - def __await__(self) -> Generator[None, None, None]: - yield None - - -def test_fails_a_job_whose_handler_returns_a_coroutine_and_closes_it( - start: Any, -) -> None: +def test_fails_a_job_whose_handler_returns_a_coroutine(start: Any) -> None: + """The coroutine is the job's result. It is not JSON-serializable, so the + report fails the job at once.""" coroutine = _async_handler(None, None) # type: ignore[arg-type] worker, client = start(lambda job_input, context: coroutine) request(worker, RUN, run_body(run_payload())) assert worker.idle(WAIT) - # A closed coroutine does not warn that it was never awaited. - assert inspect.getcoroutinestate(coroutine) == inspect.CORO_CLOSED - [(name, kwargs)] = completions(client) - assert name == "failure" - assert kwargs["Error"]["ErrorType"] == "TypeError" - assert "returned an awaitable" in kwargs["Error"]["ErrorMessage"] - - -def test_fails_a_job_whose_handler_returns_another_awaitable(start: Any) -> None: - worker, client = start(lambda job_input, context: _Awaitable()) - - request(worker, RUN, run_body(run_payload())) - assert worker.idle(WAIT) - - assert completions(client)[0][1]["Error"]["ErrorType"] == "TypeError" - - -async def _ignores_generator_exit() -> None: - try: - await _Awaitable() - finally: - await _Awaitable() - - -def test_fails_a_job_whose_started_coroutine_cannot_be_closed(start: Any) -> None: - # The coroutine has started and awaits in a finally block. So close() - # raises RuntimeError: coroutine ignored GeneratorExit. The job must still - # report its TypeError. - coroutine = _ignores_generator_exit() - coroutine.send(None) - worker, client = start(lambda job_input, context: coroutine) - - request(worker, RUN, run_body(run_payload())) - assert worker.idle(WAIT) - [(name, kwargs)] = completions(client) assert name == "failure" - assert kwargs["Error"]["ErrorType"] == "TypeError" - # Run the finally block to its end, so the coroutine closes before the - # garbage collector meets it. - with contextlib.suppress(GeneratorExit, StopIteration): - coroutine.send(None) + assert kwargs["Error"]["ErrorType"] == "ResultSerializationError" + assert "not JSON serializable" in kwargs["Error"]["ErrorMessage"] + # Without close(), Python warns that the coroutine was never awaited when + # the garbage collector frees it. + coroutine.close() @pytest.mark.parametrize(