From bf4985346075237e584f7d67d8a83a3ac110abe4 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 15 Jan 2026 13:33:52 -0800 Subject: [PATCH 1/2] Add method for acquiring application error details with a type hint --- temporalio/converter.py | 5 +- temporalio/exceptions.py | 41 +++++++++ tests/test_converter.py | 184 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 2 deletions(-) diff --git a/temporalio/converter.py b/temporalio/converter.py index 3849a47f4..df5c9df03 100644 --- a/temporalio/converter.py +++ b/temporalio/converter.py @@ -1118,9 +1118,10 @@ 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..e1f1900c5 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): @@ -106,10 +111,46 @@ def __init__( self._non_retryable = non_retryable self._next_retry_delay = next_retry_delay self._category = category + self._payload_converter = 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._details = 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 isinstance(self._details, Payloads): + if not self._details or not self._details.payloads: + return [] + return self._payload_converter.from_payloads(self._details.payloads, type_hints) return self._details @property diff --git a/tests/test_converter.py b/tests/test_converter.py index bb5b3c8bc..c4c05eb5e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -684,3 +684,187 @@ 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 + 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) From 7fc243cdfd8f9056345c5cdeca3625482fd9f297 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Thu, 15 Jan 2026 13:44:16 -0800 Subject: [PATCH 2/2] Linting --- temporalio/converter.py | 4 ++- temporalio/exceptions.py | 17 +++++---- tests/test_converter.py | 77 +++++++++++++++++----------------------- 3 files changed, 47 insertions(+), 51 deletions(-) diff --git a/temporalio/converter.py b/temporalio/converter.py index df5c9df03..a488c7e48 100644 --- a/temporalio/converter.py +++ b/temporalio/converter.py @@ -1120,7 +1120,9 @@ def from_failure( app_info = failure.application_failure_info err = temporalio.exceptions.ApplicationError._from_failure( failure.message or "Application error", - app_info.details if app_info.details and app_info.details.payloads else None, + 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, diff --git a/temporalio/exceptions.py b/temporalio/exceptions.py index e1f1900c5..3e04b084f 100644 --- a/temporalio/exceptions.py +++ b/temporalio/exceptions.py @@ -107,11 +107,12 @@ 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 = None + self._payload_converter: "PayloadConverter | None" = None @classmethod def _from_failure( @@ -136,7 +137,7 @@ def _from_failure( ) # Override details and payload converter for lazy loading if payloads exist if payloads is not None: - instance._details = payloads + instance._payloads = payloads instance._payload_converter = payload_converter return instance @@ -145,12 +146,16 @@ 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]: + 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 isinstance(self._details, Payloads): - if not self._details or not self._details.payloads: + 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._details.payloads, type_hints) + 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 c4c05eb5e..426f7607e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -695,31 +695,27 @@ class MyCustomDetail: 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" + "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 @@ -730,7 +726,7 @@ async def test_application_error_details_with_type_hints(): 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 @@ -745,14 +741,14 @@ async def test_application_error_details_with_type_hints(): 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 @@ -760,25 +756,22 @@ async def test_application_error_details_empty(): 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" + "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 @@ -790,27 +783,27 @@ async def test_application_error_details_partial_type_hints(): 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 @@ -822,49 +815,45 @@ async def test_application_error_details_direct_creation(): 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" - ) - + + # 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)