diff --git a/temporalio/converter.py b/temporalio/converter.py index 3849a47f4..a488c7e48 100644 --- a/temporalio/converter.py +++ b/temporalio/converter.py @@ -1118,9 +1118,12 @@ def from_failure( err: temporalio.exceptions.FailureError | nexusrpc.HandlerError if failure.HasField("application_failure_info"): app_info = failure.application_failure_info - err = temporalio.exceptions.ApplicationError( + err = temporalio.exceptions.ApplicationError._from_failure( failure.message or "Application error", - *payload_converter.from_payloads_wrapper(app_info.details), + app_info.details + if app_info.details and app_info.details.payloads + else None, + payload_converter, type=app_info.type or None, non_retryable=app_info.non_retryable, next_retry_delay=app_info.next_retry_delay.ToTimedelta(), diff --git a/temporalio/exceptions.py b/temporalio/exceptions.py index f8f8ca20c..3e04b084f 100644 --- a/temporalio/exceptions.py +++ b/temporalio/exceptions.py @@ -1,6 +1,7 @@ """Common Temporal exceptions.""" import asyncio +import typing from collections.abc import Sequence from datetime import timedelta from enum import IntEnum @@ -8,6 +9,10 @@ import temporalio.api.enums.v1 import temporalio.api.failure.v1 +from temporalio.api.common.v1.message_pb2 import Payloads + +if typing.TYPE_CHECKING: + from temporalio.converter import PayloadConverter class TemporalError(Exception): @@ -102,14 +107,55 @@ def __init__( exc_args=(message if not type else f"{type}: {message}",), ) self._details = details + self._payloads: Payloads | None = None self._type = type self._non_retryable = non_retryable self._next_retry_delay = next_retry_delay self._category = category + self._payload_converter: "PayloadConverter | None" = None + + @classmethod + def _from_failure( + cls, + message: str, + payloads: Payloads | None, + payload_converter: "PayloadConverter", + *, + type: str | None = None, + non_retryable: bool = False, + next_retry_delay: timedelta | None = None, + category: ApplicationErrorCategory = ApplicationErrorCategory.UNSPECIFIED, + ) -> "ApplicationError": + """Create an ApplicationError from failure payloads (internal use only).""" + # Create instance using regular constructor first + instance = cls( + message, + type=type, + non_retryable=non_retryable, + next_retry_delay=next_retry_delay, + category=category, + ) + # Override details and payload converter for lazy loading if payloads exist + if payloads is not None: + instance._payloads = payloads + instance._payload_converter = payload_converter + return instance @property def details(self) -> Sequence[Any]: """User-defined details on the error.""" + return self.details_with_type_hints() + + def details_with_type_hints( + self, type_hints: list[type] | None = None + ) -> Sequence[Any]: + """User-defined details on the error with type hints for deserialization.""" + if self._payload_converter and self._payloads is not None: + if not self._payloads or not self._payloads.payloads: + return [] + return self._payload_converter.from_payloads( + self._payloads.payloads, type_hints + ) return self._details @property diff --git a/tests/test_converter.py b/tests/test_converter.py index bb5b3c8bc..426f7607e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -684,3 +684,176 @@ def test_value_to_type_literal_key(): # Function executes without error value_to_type(hint_with_bug, value_to_convert, custom_converters) + + +@dataclass +class MyCustomDetail: + name: str + value: int + timestamp: datetime + + +async def test_application_error_details_with_type_hints(): + """Test ApplicationError details with type hints functionality.""" + + # Test data + detail_str = "error detail" + detail_int = 123 + detail_custom = MyCustomDetail("test", 42, datetime(2023, 1, 1, 12, 0, 0)) + + # Create an ApplicationError directly with various details + original_error = ApplicationError( + "Test error message", detail_str, detail_int, detail_custom, type="TestError" + ) + + # Convert to failure and back through the converter (simulating round-trip) + failure = Failure() + converter = DataConverter.default + await converter.encode_failure(original_error, failure) + decoded_error = await converter.decode_failure(failure) + + assert isinstance(decoded_error, ApplicationError) + assert decoded_error.message == "Test error message" + assert decoded_error.type == "TestError" + + # Test accessing details without type hints (default behavior) + details = decoded_error.details + assert len(details) == 3 + assert details[0] == detail_str + assert details[1] == detail_int + # Custom object becomes a dict when no type hint is provided + assert isinstance(details[2], dict) + assert details[2]["name"] == "test" + assert details[2]["value"] == 42 + assert details[2]["timestamp"] == "2023-01-01T12:00:00" + + # Test accessing details with type hints + typed_details = decoded_error.details_with_type_hints([str, int, MyCustomDetail]) + assert len(typed_details) == 3 + assert typed_details[0] == detail_str + assert typed_details[1] == detail_int + # Custom object is properly reconstructed with type hint + assert isinstance(typed_details[2], MyCustomDetail) + assert typed_details[2].name == "test" + assert typed_details[2].value == 42 + assert typed_details[2].timestamp == datetime(2023, 1, 1, 12, 0, 0) + + +async def test_application_error_details_empty(): + """Test ApplicationError with no details.""" + + error = ApplicationError("No details error", type="NoDetails") + + failure = Failure() + converter = DataConverter.default + await converter.encode_failure(error, failure) + decoded_error = await converter.decode_failure(failure) + + assert isinstance(decoded_error, ApplicationError) + assert len(decoded_error.details) == 0 + assert len(decoded_error.details_with_type_hints([])) == 0 + + +async def test_application_error_details_partial_type_hints(): + """Test ApplicationError details with partial type hints.""" + + detail1 = "string detail" + detail2 = 456 + detail3 = MyCustomDetail("partial", 99, datetime(2023, 6, 15, 9, 30, 0)) + + error = ApplicationError( + "Partial hints error", detail1, detail2, detail3, type="PartialHints" + ) + + failure = Failure() + converter = DataConverter.default + await converter.encode_failure(error, failure) + decoded_error = await converter.decode_failure(failure) + + # Provide type hints for only the first two details + assert isinstance(decoded_error, ApplicationError) + typed_details = decoded_error.details_with_type_hints([str, int]) + assert len(typed_details) == 3 + assert typed_details[0] == detail1 + assert typed_details[1] == detail2 + # Third detail has no type hint, so it remains as dict + assert isinstance(typed_details[2], dict) + assert typed_details[2]["name"] == "partial" + + +async def test_application_error_details_direct_creation(): + """Test ApplicationError created directly with payload converter.""" + + detail1 = "direct detail" + detail2 = MyCustomDetail("direct", 777, datetime(2023, 12, 25, 14, 15, 0)) + + # Create error with payload converter directly + converter = DataConverter.default.payload_converter + payloads_wrapper = converter.to_payloads_wrapper([detail1, detail2]) + + error = ApplicationError._from_failure( + "Direct creation error", + payloads_wrapper, + converter, + type="Direct", + ) + + # Test default details access + details = error.details + assert len(details) == 2 + assert details[0] == detail1 + assert isinstance(details[1], dict) # No type hint + + # Test with type hints + typed_details = error.details_with_type_hints([str, MyCustomDetail]) + assert len(typed_details) == 2 + assert typed_details[0] == detail1 + assert isinstance(typed_details[1], MyCustomDetail) + assert typed_details[1].name == "direct" + assert typed_details[1].value == 777 + + +async def test_application_error_details_none_payload_converter(): + """Test ApplicationError when no payload converter is set.""" + + detail1 = "no converter detail" + detail2 = 999 + + # Create error without payload converter + error = ApplicationError("No converter error", detail1, detail2, type="NoConverter") + + # Both methods should return the same result - the raw details tuple + details = error.details + typed_details = error.details_with_type_hints([str, int]) + + assert details == (detail1, detail2) + assert typed_details == (detail1, detail2) + + +def test_application_error_details_edge_cases(): + """Test edge cases for ApplicationError details.""" + + # Test with None payload converter and empty Payloads + from temporalio.api.common.v1 import Payloads + + empty_payloads = Payloads() + + error = ApplicationError._from_failure( + "Empty payloads", + empty_payloads, + DataConverter.default.payload_converter, + ) + + assert len(error.details) == 0 + assert len(error.details_with_type_hints([str])) == 0 + + # Test with non-Payloads details when payload_converter is set + error2 = ApplicationError( + "Non-payloads details", + "string", + 123, + ) + + # Should return the raw details since they're not Payloads + assert error2.details == ("string", 123) + assert error2.details_with_type_hints([str, int]) == ("string", 123)