Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions temporalio/client/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion temporalio/nexus/_operation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions temporalio/worker/_nexus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions tests/nexus/test_handler_error_conversion.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions tests/test_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading