From 888b401350fbff5de55b9d73a8b5fc9984f27376 Mon Sep 17 00:00:00 2001 From: Pooya Paridel Date: Sat, 24 Oct 2026 14:46:56 -0700 Subject: [PATCH 1/5] refactor(extras): drop redundant MicroVM checks Remove the empty image and role checks, the bool port check, the invoke context and handler callable checks, the unreachable endpoint guard, and the str() guard in add_request_id. Each removed case cannot occur, or it still fails visibly at the launch or at the call. Read the request ID with one get chain. --- .../README.md | 15 ++--- .../microvm/aws_errors.py | 25 ++++---- .../microvm/operation.py | 41 +++++-------- .../microvm/session.py | 33 ++++------ .../tests/microvm/aws_errors_test.py | 13 +++- .../tests/microvm/operation_test.py | 61 ++++++++++++------- .../tests/microvm/session_test.py | 29 +++++---- .../tests/microvm/validation_test.py | 7 --- 8 files changed, 115 insertions(+), 109 deletions(-) diff --git a/packages/aws-durable-execution-sdk-python-extras/README.md b/packages/aws-durable-execution-sdk-python-extras/README.md index 587668ab0..a5592f6ce 100644 --- a/packages/aws-durable-execution-sdk-python-extras/README.md +++ b/packages/aws-durable-execution-sdk-python-extras/README.md @@ -62,7 +62,7 @@ config = dataclasses.replace(CONFIG, request=MicrovmRequestConfig(path="/clone-b microvm(context, "build", job_input=job_input, config=config) ``` -A `path` sends every job over HTTP, whatever its size. The route must answer with a 2xx status at once and run the job in the background. `port` names a port other than 8080. `retry_window` sets how long the request step retries inside one attempt, 60 seconds by default. +A `path` sends every job over HTTP, whatever its size. The route must answer with a 2xx status at once and run the job in the background. `port` names a port other than 8080. Pass it as an `int`. `retry_window` sets how long the request step retries inside one attempt, 60 seconds by default. The same job can arrive again, also after it ended, so ignore a second request for a callback ID that you already ran. The worker package does this. A route served without it must do it itself. A job arrives again in these cases: @@ -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 first argument that is not a durable context, as in `vm.invoke("build", ...)`, raises Python's `AttributeError` at the same point. 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..2e2b9c497 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 ``str()`` of the copy lacks the request ID, as ``str()`` + of ``error`` does. 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..5f40f6376 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) @@ -1004,12 +998,7 @@ def _validate_request(name: str, request: MicrovmRequestConfig) -> int: ) raise ValidationError(msg) port = request.port - # bool is an int subclass, and True would pass as port 1. - if port is not None and ( - isinstance(port, bool) - or not isinstance(port, int) - or not 1 <= port <= _MAX_PORT - ): + if port is not None and (not isinstance(port, int) or not 1 <= port <= _MAX_PORT): msg = f'MicroVM "{name}": the request port must be an integer between 1 and {_MAX_PORT}' raise ValidationError(msg) if request.retry_window is None: 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..7585d9cc5 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,10 @@ def body(vm: MicrovmSession, ctx: DurableContext, event: Any) -> Any: ("call", "error", "message"), [ ( + # 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] - "TypeError", - "invoke requires the durable context", + "AttributeError", + "'str' object has no attribute 'run_in_child_context'", ), ( lambda vm, ctx: vm.invoke(ctx, "", job_input=1, options=job()), @@ -380,7 +381,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 +432,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 +479,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..241fb6c47 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", @@ -140,11 +138,6 @@ def config(**fields: Any) -> MicrovmConfig: {"request": MicrovmRequestConfig(port=65_536)}, "the request port must be an integer between 1 and 65535", ), - ( - "build", - {"request": MicrovmRequestConfig(port=True)}, - "the request port must be an integer between 1 and 65535", - ), ( "build", {"request": MicrovmRequestConfig(port=8080.0)}, # type: ignore[arg-type] From 7af442d5e9b1c2b303e2aa34a01f0fe5ce0914b4 Mon Sep 17 00:00:00 2001 From: Pooya Paridel Date: Sat, 24 Oct 2026 14:46:56 -0700 Subject: [PATCH 2/5] refactor(microvm-worker): drop redundant checks Remove loads_strict, because is_finite_number already rejects NaN and Infinity in every number field that the worker reads. Remove the branch in _Outcome.of for a returned awaitable. A returned coroutine now fails the job with ResultSerializationError. --- .../README.md | 6 +- .../payload.py | 28 ++------ .../worker.py | 30 ++------- .../tests/payload_test.py | 51 +++++++++----- .../tests/worker_test.py | 66 ++++++------------- 5 files changed, 75 insertions(+), 106 deletions(-) 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..b8e581eb6 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()`. @@ -173,6 +173,8 @@ The `runHookPayload` string holds: A document that does not match raises `InvalidRunHookPayloadError`. When the document named a non-empty callback ID, the error carries `callback_id` and `region`, so the worker can fail that callback at once. +The worker parses with Python's `json` module. It accepts the `NaN`, `Infinity`, and `-Infinity` tokens, which are not JSON. The number fields reject a value that is not finite. `input` can hold such a value, and the handler receives it as a `float`. The JavaScript worker answers HTTP 400 for these tokens. No SDK sends them. + ## Reporting `CallbackReporter.succeed` sends a JSON result of at most 256 KB. A larger result raises `ResultTooLargeError`, and a result that is not JSON-serializable raises `ResultSerializationError`. In both cases no call is made, so the caller can report a failure instead. A `float` that is `nan` or infinite is not JSON, so it raises `ResultSerializationError`. @@ -211,6 +213,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/payload.py b/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/payload.py index 18e047ced..1869d3fb5 100644 --- a/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/payload.py +++ b/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/payload.py @@ -64,7 +64,8 @@ class MicrovmJobDocument: callback_id: str """The callback that the worker completes with the job's outcome.""" input: Any - """The caller's input, as parsed from JSON.""" + """The caller's input, as parsed from JSON. It can hold a float that is + ``nan`` or infinite, because Python's ``json`` module parses one.""" heartbeat_timeout_seconds: float | None = None """The heartbeat timeout in seconds, when the caller set one.""" @@ -127,7 +128,7 @@ def from_json(cls, text: str) -> MicrovmRunHookPayload: contract, or its version is not supported. """ try: - payload = loads_strict(text) + payload = json.loads(text) except ValueError as error: # Keep the JSONDecodeError. Its line and column are the first # thing to check, because the payload comes from another SDK. @@ -270,25 +271,6 @@ def from_dict(cls, body: object) -> MicrovmJobRequest: ) -def loads_strict(text: str | bytes) -> Any: - """Parse JSON, and reject ``NaN``, ``Infinity``, and ``-Infinity``. - - Python's ``json`` module accepts those three tokens by default. They are - not JSON, and the durable function in another language never sends them. - A heartbeat timeout of ``Infinity`` would pass a check such as ``>= 1``. - So the worker rejects them when it parses. - - Raises: - ValueError: When the text is not valid JSON. - """ - - def reject(token: str) -> Any: - msg = f"{token} is not valid JSON" - raise ValueError(msg) - - return json.loads(text, parse_constant=reject) - - def _check_version( version: object, callback_id: str | None, region: str | None ) -> None: @@ -321,6 +303,10 @@ def _non_empty_string(value: object) -> str | None: def is_finite_number(value: object) -> bool: """Whether a value is a JSON number that is finite. + Python's ``json`` module parses the ``NaN``, ``Infinity``, and + ``-Infinity`` tokens, and it parses ``1e309`` as ``math.inf``. So every + number field that the worker reads goes through this check. + 1. A ``bool`` is an ``int`` in Python, and JSON ``true`` is not a number. So a ``bool`` is not a number here. 2. An ``int`` is always finite. ``math.isfinite`` would raise 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..024933644 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 @@ -48,7 +47,6 @@ MicrovmJobDocument, MicrovmJobRequest, RunHookRequest, - loads_strict, ) from aws_durable_execution_sdk_python_microvm_worker.self_suspend import ( SUSPEND_CALL_TIMEOUT_SECONDS, @@ -1853,30 +1851,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): @@ -1970,7 +1954,7 @@ def read_json(self, max_bytes: int) -> Any: """ body = self._read_body(max_bytes) try: - return loads_strict(body) + return json.loads(body) except ValueError as error: msg = "request body is not valid JSON" raise InvalidRunHookPayloadError(msg) from error diff --git a/packages/aws-durable-execution-sdk-python-microvm-worker/tests/payload_test.py b/packages/aws-durable-execution-sdk-python-microvm-worker/tests/payload_test.py index 2192fd220..9125048c3 100644 --- a/packages/aws-durable-execution-sdk-python-microvm-worker/tests/payload_test.py +++ b/packages/aws-durable-execution-sdk-python-microvm-worker/tests/payload_test.py @@ -16,7 +16,6 @@ MicrovmJobDocument, MicrovmJobRequest, RunHookRequest, - loads_strict, ) @@ -84,13 +83,12 @@ def test_run_hook_rejects_invalid_auto_suspend(idle): "job": JOB, "autoSuspendIdleSeconds": idle, } - # 1e309 is infinite as a float. json.dumps writes it as Infinity, which - # the strict parser rejects as invalid JSON before the range check. + # 1e309 is infinite as a float. json.dumps writes it as Infinity, and + # json.loads reads that token back as math.inf. The range check rejects it. with pytest.raises(InvalidRunHookPayloadError) as raised: RunHookRequest.from_dict(run_hook(payload)) - if idle != 1e309: - assert raised.value.callback_id == "cb-1" - assert raised.value.region == "us-east-1" + assert raised.value.callback_id == "cb-1" + assert raised.value.region == "us-east-1" @pytest.mark.parametrize( @@ -117,7 +115,6 @@ def test_run_hook_missing_id_error_names_the_job(): [ (3, "must be a string"), ("{", "not valid JSON"), - ("NaN", "not valid JSON"), ("[]", "must be an object"), ], ) @@ -235,17 +232,41 @@ def test_job_request_rejects(body, message, callback_id, region): # endregion job request -# region strict JSON +# region non-finite numbers -@pytest.mark.parametrize("text", ["NaN", "[Infinity]", '{"a": -Infinity}']) -def test_loads_strict_rejects_non_json_constants(text): - with pytest.raises(ValueError, match="not valid JSON"): - loads_strict(text) +@pytest.mark.parametrize("token", ["NaN", "Infinity", "-Infinity"]) +@pytest.mark.parametrize( + ("template", "message"), + [ + ('"version": {}', "not supported"), + ('"version": 1, "autoSuspendIdleSeconds": {}', "autoSuspendIdleSeconds"), + ( + '"version": 1, "job": {{"callbackId": "cb-1", ' + '"heartbeatTimeoutSeconds": {}}}', + "heartbeatTimeoutSeconds", + ), + ], +) +def test_run_hook_rejects_a_non_finite_number_field(token, template, message): + """json.loads parses the three tokens. The field checks reject them.""" + text = '{"region": "us-east-1", ' + template.format(token) + "}" + with pytest.raises(InvalidRunHookPayloadError, match=message): + RunHookRequest.from_dict({"microvmId": "mvm-1", "runHookPayload": text}) + + +def test_job_request_keeps_a_non_finite_number_in_the_input(): + """The worker does not read the input. So a NaN token reaches the + handler as a float. The JavaScript worker answers 400 for it.""" + body = json.loads( + '{"version": 1, "region": "us-east-1", "callbackId": "cb-1", ' + '"input": {"x": NaN, "y": Infinity}}' + ) + parsed = MicrovmJobRequest.from_dict(body) -def test_loads_strict_parses_json(): - assert loads_strict(b'{"a": [1, 2.5, null, true]}') == {"a": [1, 2.5, None, True]} + assert math.isnan(parsed.job.input["x"]) + assert parsed.job.input["y"] == math.inf def test_large_integer_heartbeat_is_accepted(): @@ -254,7 +275,7 @@ def test_large_integer_heartbeat_is_accepted(): assert parsed.job.heartbeat_timeout_seconds == math.inf -# endregion strict JSON +# endregion non-finite numbers # region dataclasses and callback IDs 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..fb3ffe545 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,16 @@ from __future__ import annotations -import contextlib import functools import http.client -import inspect import io import json +import math 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 @@ -587,7 +586,6 @@ def create_client(region: str) -> Any: ("raw", "message"), [ (b"{not json", "request body is not valid JSON"), - (b"NaN", "request body is not valid JSON"), (b"", "request body is not valid JSON"), ], ) @@ -1550,62 +1548,40 @@ 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" + 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() -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) +def test_fails_a_job_that_returns_a_non_finite_number_from_its_input( + start: Any, +) -> None: + """json.loads parses a NaN token in the input. A handler that returns it + fails at the report, because the result is not JSON.""" + worker, client = start(lambda job_input, context: job_input) - request(worker, RUN, run_body(run_payload())) + assert request(worker, MICROVM_JOB_PATH, job_body(input={"x": math.nan})) == ( + 202, + {}, + ) 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" @pytest.mark.parametrize( From 106db1e3b03a1c4b6f2fafbc4c6f8efe92f6051a Mon Sep 17 00:00:00 2001 From: Pooya Paridel Date: Sat, 24 Oct 2026 15:11:46 -0700 Subject: [PATCH 3/5] fix(extras): keep the bool port check Restore the check that rejects a bool request port. The check keeps Python's behavior the same as JavaScript, which rejects true. --- .../microvm/operation.py | 7 ++- .../tests/microvm/validation_test.py | 5 ++ .../payload.py | 28 +++++++--- .../tests/payload_test.py | 51 ++++++------------- 4 files changed, 47 insertions(+), 44 deletions(-) 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 5f40f6376..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 @@ -998,7 +998,12 @@ def _validate_request(name: str, request: MicrovmRequestConfig) -> int: ) raise ValidationError(msg) port = request.port - if port is not None and (not isinstance(port, int) or not 1 <= port <= _MAX_PORT): + # bool is an int subclass, and True would pass as port 1. + if port is not None and ( + isinstance(port, bool) + or not isinstance(port, int) + or not 1 <= port <= _MAX_PORT + ): msg = f'MicroVM "{name}": the request port must be an integer between 1 and {_MAX_PORT}' raise ValidationError(msg) if request.retry_window is None: 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 241fb6c47..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 @@ -138,6 +138,11 @@ def config(**fields: Any) -> MicrovmConfig: {"request": MicrovmRequestConfig(port=65_536)}, "the request port must be an integer between 1 and 65535", ), + ( + "build", + {"request": MicrovmRequestConfig(port=True)}, + "the request port must be an integer between 1 and 65535", + ), ( "build", {"request": MicrovmRequestConfig(port=8080.0)}, # type: ignore[arg-type] diff --git a/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/payload.py b/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/payload.py index 1869d3fb5..18e047ced 100644 --- a/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/payload.py +++ b/packages/aws-durable-execution-sdk-python-microvm-worker/src/aws_durable_execution_sdk_python_microvm_worker/payload.py @@ -64,8 +64,7 @@ class MicrovmJobDocument: callback_id: str """The callback that the worker completes with the job's outcome.""" input: Any - """The caller's input, as parsed from JSON. It can hold a float that is - ``nan`` or infinite, because Python's ``json`` module parses one.""" + """The caller's input, as parsed from JSON.""" heartbeat_timeout_seconds: float | None = None """The heartbeat timeout in seconds, when the caller set one.""" @@ -128,7 +127,7 @@ def from_json(cls, text: str) -> MicrovmRunHookPayload: contract, or its version is not supported. """ try: - payload = json.loads(text) + payload = loads_strict(text) except ValueError as error: # Keep the JSONDecodeError. Its line and column are the first # thing to check, because the payload comes from another SDK. @@ -271,6 +270,25 @@ def from_dict(cls, body: object) -> MicrovmJobRequest: ) +def loads_strict(text: str | bytes) -> Any: + """Parse JSON, and reject ``NaN``, ``Infinity``, and ``-Infinity``. + + Python's ``json`` module accepts those three tokens by default. They are + not JSON, and the durable function in another language never sends them. + A heartbeat timeout of ``Infinity`` would pass a check such as ``>= 1``. + So the worker rejects them when it parses. + + Raises: + ValueError: When the text is not valid JSON. + """ + + def reject(token: str) -> Any: + msg = f"{token} is not valid JSON" + raise ValueError(msg) + + return json.loads(text, parse_constant=reject) + + def _check_version( version: object, callback_id: str | None, region: str | None ) -> None: @@ -303,10 +321,6 @@ def _non_empty_string(value: object) -> str | None: def is_finite_number(value: object) -> bool: """Whether a value is a JSON number that is finite. - Python's ``json`` module parses the ``NaN``, ``Infinity``, and - ``-Infinity`` tokens, and it parses ``1e309`` as ``math.inf``. So every - number field that the worker reads goes through this check. - 1. A ``bool`` is an ``int`` in Python, and JSON ``true`` is not a number. So a ``bool`` is not a number here. 2. An ``int`` is always finite. ``math.isfinite`` would raise diff --git a/packages/aws-durable-execution-sdk-python-microvm-worker/tests/payload_test.py b/packages/aws-durable-execution-sdk-python-microvm-worker/tests/payload_test.py index 9125048c3..2192fd220 100644 --- a/packages/aws-durable-execution-sdk-python-microvm-worker/tests/payload_test.py +++ b/packages/aws-durable-execution-sdk-python-microvm-worker/tests/payload_test.py @@ -16,6 +16,7 @@ MicrovmJobDocument, MicrovmJobRequest, RunHookRequest, + loads_strict, ) @@ -83,12 +84,13 @@ def test_run_hook_rejects_invalid_auto_suspend(idle): "job": JOB, "autoSuspendIdleSeconds": idle, } - # 1e309 is infinite as a float. json.dumps writes it as Infinity, and - # json.loads reads that token back as math.inf. The range check rejects it. + # 1e309 is infinite as a float. json.dumps writes it as Infinity, which + # the strict parser rejects as invalid JSON before the range check. with pytest.raises(InvalidRunHookPayloadError) as raised: RunHookRequest.from_dict(run_hook(payload)) - assert raised.value.callback_id == "cb-1" - assert raised.value.region == "us-east-1" + if idle != 1e309: + assert raised.value.callback_id == "cb-1" + assert raised.value.region == "us-east-1" @pytest.mark.parametrize( @@ -115,6 +117,7 @@ def test_run_hook_missing_id_error_names_the_job(): [ (3, "must be a string"), ("{", "not valid JSON"), + ("NaN", "not valid JSON"), ("[]", "must be an object"), ], ) @@ -232,41 +235,17 @@ def test_job_request_rejects(body, message, callback_id, region): # endregion job request -# region non-finite numbers +# region strict JSON -@pytest.mark.parametrize("token", ["NaN", "Infinity", "-Infinity"]) -@pytest.mark.parametrize( - ("template", "message"), - [ - ('"version": {}', "not supported"), - ('"version": 1, "autoSuspendIdleSeconds": {}', "autoSuspendIdleSeconds"), - ( - '"version": 1, "job": {{"callbackId": "cb-1", ' - '"heartbeatTimeoutSeconds": {}}}', - "heartbeatTimeoutSeconds", - ), - ], -) -def test_run_hook_rejects_a_non_finite_number_field(token, template, message): - """json.loads parses the three tokens. The field checks reject them.""" - text = '{"region": "us-east-1", ' + template.format(token) + "}" - with pytest.raises(InvalidRunHookPayloadError, match=message): - RunHookRequest.from_dict({"microvmId": "mvm-1", "runHookPayload": text}) - - -def test_job_request_keeps_a_non_finite_number_in_the_input(): - """The worker does not read the input. So a NaN token reaches the - handler as a float. The JavaScript worker answers 400 for it.""" - body = json.loads( - '{"version": 1, "region": "us-east-1", "callbackId": "cb-1", ' - '"input": {"x": NaN, "y": Infinity}}' - ) +@pytest.mark.parametrize("text", ["NaN", "[Infinity]", '{"a": -Infinity}']) +def test_loads_strict_rejects_non_json_constants(text): + with pytest.raises(ValueError, match="not valid JSON"): + loads_strict(text) - parsed = MicrovmJobRequest.from_dict(body) - assert math.isnan(parsed.job.input["x"]) - assert parsed.job.input["y"] == math.inf +def test_loads_strict_parses_json(): + assert loads_strict(b'{"a": [1, 2.5, null, true]}') == {"a": [1, 2.5, None, True]} def test_large_integer_heartbeat_is_accepted(): @@ -275,7 +254,7 @@ def test_large_integer_heartbeat_is_accepted(): assert parsed.job.heartbeat_timeout_seconds == math.inf -# endregion non-finite numbers +# endregion strict JSON # region dataclasses and callback IDs From 05b2db9f3cb1be72fd3c18405b5eb0329cb64797 Mon Sep 17 00:00:00 2001 From: Pooya Paridel Date: Sat, 24 Oct 2026 15:11:46 -0700 Subject: [PATCH 4/5] fix(microvm-worker): keep the strict JSON parse Restore loads_strict, which rejects NaN and Infinity anywhere in the body. The check keeps Python's behavior the same as the JS worker. --- .../README.md | 2 -- .../worker.py | 3 ++- .../tests/worker_test.py | 20 +------------------ 3 files changed, 3 insertions(+), 22 deletions(-) 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 b8e581eb6..209b6ead6 100644 --- a/packages/aws-durable-execution-sdk-python-microvm-worker/README.md +++ b/packages/aws-durable-execution-sdk-python-microvm-worker/README.md @@ -173,8 +173,6 @@ The `runHookPayload` string holds: A document that does not match raises `InvalidRunHookPayloadError`. When the document named a non-empty callback ID, the error carries `callback_id` and `region`, so the worker can fail that callback at once. -The worker parses with Python's `json` module. It accepts the `NaN`, `Infinity`, and `-Infinity` tokens, which are not JSON. The number fields reject a value that is not finite. `input` can hold such a value, and the handler receives it as a `float`. The JavaScript worker answers HTTP 400 for these tokens. No SDK sends them. - ## Reporting `CallbackReporter.succeed` sends a JSON result of at most 256 KB. A larger result raises `ResultTooLargeError`, and a result that is not JSON-serializable raises `ResultSerializationError`. In both cases no call is made, so the caller can report a failure instead. A `float` that is `nan` or infinite is not JSON, so it raises `ResultSerializationError`. 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 024933644..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 @@ -47,6 +47,7 @@ MicrovmJobDocument, MicrovmJobRequest, RunHookRequest, + loads_strict, ) from aws_durable_execution_sdk_python_microvm_worker.self_suspend import ( SUSPEND_CALL_TIMEOUT_SECONDS, @@ -1954,7 +1955,7 @@ def read_json(self, max_bytes: int) -> Any: """ body = self._read_body(max_bytes) try: - return json.loads(body) + return loads_strict(body) except ValueError as error: msg = "request body is not valid JSON" raise InvalidRunHookPayloadError(msg) from error 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 fb3ffe545..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 @@ -9,7 +9,6 @@ import http.client import io import json -import math import socket import struct import threading @@ -586,6 +585,7 @@ def create_client(region: str) -> Any: ("raw", "message"), [ (b"{not json", "request body is not valid JSON"), + (b"NaN", "request body is not valid JSON"), (b"", "request body is not valid JSON"), ], ) @@ -1566,24 +1566,6 @@ def test_fails_a_job_whose_handler_returns_a_coroutine(start: Any) -> None: coroutine.close() -def test_fails_a_job_that_returns_a_non_finite_number_from_its_input( - start: Any, -) -> None: - """json.loads parses a NaN token in the input. A handler that returns it - fails at the report, because the result is not JSON.""" - worker, client = start(lambda job_input, context: job_input) - - assert request(worker, MICROVM_JOB_PATH, job_body(input={"x": math.nan})) == ( - 202, - {}, - ) - assert worker.idle(WAIT) - - [(name, kwargs)] = completions(client) - assert name == "failure" - assert kwargs["Error"]["ErrorType"] == "ResultSerializationError" - - @pytest.mark.parametrize( ("options", "error", "message"), [ From 07a1cc32677c63c50990be6a8daf8e731d8a5973 Mon Sep 17 00:00:00 2001 From: Pooya Paridel Date: Sat, 24 Oct 2026 16:31:59 -0700 Subject: [PATCH 5/5] docs(extras): name the vm.invoke argument errors This addresses the round 1 review of the input check removal. A call without the context raises TypeError, and a test now checks it. --- packages/aws-durable-execution-sdk-python-extras/README.md | 4 ++-- .../microvm/aws_errors.py | 4 ++-- .../tests/microvm/session_test.py | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/aws-durable-execution-sdk-python-extras/README.md b/packages/aws-durable-execution-sdk-python-extras/README.md index a5592f6ce..d064a14d3 100644 --- a/packages/aws-durable-execution-sdk-python-extras/README.md +++ b/packages/aws-durable-execution-sdk-python-extras/README.md @@ -62,7 +62,7 @@ config = dataclasses.replace(CONFIG, request=MicrovmRequestConfig(path="/clone-b microvm(context, "build", job_input=job_input, config=config) ``` -A `path` sends every job over HTTP, whatever its size. The route must answer with a 2xx status at once and run the job in the background. `port` names a port other than 8080. Pass it as an `int`. `retry_window` sets how long the request step retries inside one attempt, 60 seconds by default. +A `path` sends every job over HTTP, whatever its size. The route must answer with a 2xx status at once and run the job in the background. `port` names a port other than 8080. `retry_window` sets how long the request step retries inside one attempt, 60 seconds by default. The same job can arrive again, also after it ended, so ignore a second request for a callback ID that you already ran. The worker package does this. A route served without it must do it itself. A job arrives again in these cases: @@ -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. A first argument that is not a durable context, as in `vm.invoke("build", ...)`, raises Python's `AttributeError` at the same point. +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. 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 2e2b9c497..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 @@ -145,8 +145,8 @@ class of ``error``. A wrapper of another class would change the - ``error`` has no request ID. A class of ``error`` can build ``str()`` from something other than - ``args``. Then ``str()`` of the copy lacks the request ID, as ``str()`` - of ``error`` does. The step records the same message either way, and the + ``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): 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 7585d9cc5..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 @@ -328,6 +328,12 @@ def body(vm: MicrovmSession, ctx: DurableContext, event: Any) -> Any: @pytest.mark.parametrize( ("call", "error", "message"), [ + ( + # 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", + "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]