diff --git a/CHANGELOG.md b/CHANGELOG.md index be3444177..bf78bd257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,12 @@ to include examples, links to docs, or any other relevant information. ### Fixed +- Starting an activity from a Nexus operation handler without a + `start_to_close_timeout`/`schedule_to_close_timeout`, or with a negative + `start_delay`, now fails immediately with a non-retryable `BAD_REQUEST` + instead of retrying as an `INTERNAL` error until `schedule_to_close_timeout` + elapses. + ### Security ## [1.32.0] - 2026-08-24 diff --git a/temporalio/client/_impl.py b/temporalio/client/_impl.py index 78471baf7..34816e161 100644 --- a/temporalio/client/_impl.py +++ b/temporalio/client/_impl.py @@ -136,6 +136,14 @@ from ._client import Client +class _StartActivityInputError(temporalio.exceptions.TemporalError, ValueError): + """A caller-fixable problem with a :py:meth:`Client.start_activity` call. + + Raised for preconditions on ``StartActivityInput`` that are checked before + any RPC is issued. + """ + + class _ClientImpl(OutboundInterceptor): # pyright: ignore[reportUnusedClass] def __init__(self, client: Client) -> None: # type: ignore # We are intentionally not calling the base class's __init__ here @@ -526,11 +534,11 @@ async def terminate_workflow(self, input: TerminateWorkflowInput) -> None: async def start_activity(self, input: StartActivityInput) -> ActivityHandle[Any]: """Start an activity and return a handle to it.""" if not (input.start_to_close_timeout or input.schedule_to_close_timeout): - raise ValueError( + raise _StartActivityInputError( "Activity must have start_to_close_timeout or schedule_to_close_timeout" ) if input.start_delay is not None and input.start_delay < timedelta(0): - raise ValueError("start_delay must be non-negative") + raise _StartActivityInputError("start_delay must be non-negative") req = await self._build_start_activity_execution_request(input) resp: temporalio.api.workflowservice.v1.StartActivityExecutionResponse diff --git a/temporalio/nexus/_operation_context.py b/temporalio/nexus/_operation_context.py index c44088f21..896f9c4aa 100644 --- a/temporalio/nexus/_operation_context.py +++ b/temporalio/nexus/_operation_context.py @@ -168,7 +168,7 @@ def _try_temporal_context() -> ( return start_ctx or cancel_ctx -def _try_start_operation_context() -> _TemporalStartOperationContext | None: # pyright: ignore[reportUnusedFunction] +def _try_start_operation_context() -> _TemporalStartOperationContext | None: """Return the active Nexus start-operation context, if any.""" return _temporal_start_operation_context.get(None) diff --git a/temporalio/worker/_nexus.py b/temporalio/worker/_nexus.py index 90ba40382..16707244e 100644 --- a/temporalio/worker/_nexus.py +++ b/temporalio/worker/_nexus.py @@ -28,6 +28,7 @@ import temporalio.bridge.proto.nexus import temporalio.bridge.worker import temporalio.client +import temporalio.client._impl import temporalio.common import temporalio.converter import temporalio.nexus @@ -611,6 +612,12 @@ def _exception_to_handler_error(err: BaseException) -> nexusrpc.HandlerError: # https://github.com/temporalio/sdk-typescript/blob/nexus/packages/worker/src/nexus.ts if isinstance(err, nexusrpc.HandlerError): return err + elif isinstance(err, temporalio.client._impl._StartActivityInputError): + handler_err = nexusrpc.HandlerError( + str(err), + type=nexusrpc.HandlerErrorType.BAD_REQUEST, + retryable_override=False, + ) elif isinstance(err, ApplicationError): handler_err = nexusrpc.HandlerError( message="Handler failed with non-retryable application error", diff --git a/tests/nexus/test_handler_error_conversion.py b/tests/nexus/test_handler_error_conversion.py new file mode 100644 index 000000000..9bc7545c7 --- /dev/null +++ b/tests/nexus/test_handler_error_conversion.py @@ -0,0 +1,26 @@ +import nexusrpc +import pytest + +import temporalio.client._impl +import temporalio.worker._nexus + + +@pytest.mark.parametrize( + "message", + [ + "Activity must have start_to_close_timeout or schedule_to_close_timeout", + "start_delay must be non-negative", + ], +) +def test_start_activity_input_error_maps_to_nexus_bad_request(message: str): + """Pins the classification `_exception_to_handler_error` relies on to give + these two `start_activity` preconditions non-retryable BAD_REQUEST + semantics inside a Nexus operation handler, instead of falling through to + the generic, retryable INTERNAL branch. + """ + handler_error = temporalio.worker._nexus._exception_to_handler_error( + temporalio.client._impl._StartActivityInputError(message) + ) + assert handler_error.type == nexusrpc.HandlerErrorType.BAD_REQUEST + assert handler_error.retryable is False + assert handler_error.message == message diff --git a/tests/test_activity.py b/tests/test_activity.py index 3a63f51ac..aa6e28e8e 100644 --- a/tests/test_activity.py +++ b/tests/test_activity.py @@ -457,6 +457,19 @@ async def test_start_activity_rejects_negative_start_delay(client: Client): ) +async def test_start_activity_requires_a_timeout(client: Client): + with pytest.raises( + ValueError, + match="Activity must have start_to_close_timeout or schedule_to_close_timeout", + ): + await client.start_activity( + increment, + args=(1,), + id=str(uuid.uuid4()), + task_queue=str(uuid.uuid4()), + ) + + async def test_get_result(client: Client, env: WorkflowEnvironment): if env.supports_time_skipping: pytest.skip(