Skip to content

Commit 63a35f3

Browse files
tconley1428claude
andcommitted
Extend payload-based detail access pattern to TimeoutError and activity.Info
- Replace ApplicationError.details_with_type_hints with get_detail(index, type_hint) - Add TimeoutError.get_heartbeat_detail() with payload-based lazy loading - Add activity.Info.get_heartbeat_detail() with payload-based access - Make heartbeat_details properties that decode payloads on access - Store raw payloads instead of decoded details to support type hints - Update tests to use new get_detail method 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 64c18cc commit 63a35f3

6 files changed

Lines changed: 176 additions & 81 deletions

File tree

temporalio/activity.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
overload,
2727
)
2828

29+
import temporalio.api.common.v1
2930
import temporalio.bridge
3031
import temporalio.bridge.proto
3132
import temporalio.bridge.proto.activity_task
@@ -101,7 +102,8 @@ class Info:
101102
activity_type: str
102103
attempt: int
103104
current_attempt_scheduled_time: datetime
104-
heartbeat_details: Sequence[Any]
105+
_heartbeat_payloads: Sequence[temporalio.api.common.v1.Payload]
106+
_payload_converter: temporalio.converter.PayloadConverter
105107
heartbeat_timeout: timedelta | None
106108
is_local: bool
107109
schedule_to_close_timeout: timedelta | None
@@ -124,6 +126,34 @@ class Info:
124126

125127
# TODO(cretz): Consider putting identity on here for "worker_id" for logger?
126128

129+
@property
130+
def heartbeat_details(self) -> Sequence[Any]:
131+
"""Heartbeat details for the activity."""
132+
return self._payload_converter.from_payloads(self._heartbeat_payloads, None)
133+
134+
def get_heartbeat_detail(self, index: int, type_hint: type | None = None) -> Any:
135+
"""Get a heartbeat detail by index with optional type hint.
136+
137+
Args:
138+
index: Zero-based index of the heartbeat detail to retrieve.
139+
type_hint: Optional type hint for deserialization.
140+
141+
Returns:
142+
The heartbeat detail at the specified index.
143+
144+
Raises:
145+
IndexError: If the index is out of range.
146+
"""
147+
if index < 0 or index >= len(self._heartbeat_payloads):
148+
raise IndexError(
149+
f"Heartbeat detail index {index} out of range (0-{len(self._heartbeat_payloads)-1})"
150+
)
151+
# Convert single payload at the specified index
152+
payload = self._heartbeat_payloads[index]
153+
type_hints = [type_hint] if type_hint is not None else None
154+
converted = self._payload_converter.from_payloads([payload], type_hints)
155+
return converted[0] if converted else None
156+
127157
def _logger_details(self) -> Mapping[str, Any]:
128158
return {
129159
"activity_id": self.activity_id,

temporalio/converter.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,14 +1133,15 @@ def from_failure(
11331133
)
11341134
elif failure.HasField("timeout_failure_info"):
11351135
timeout_info = failure.timeout_failure_info
1136-
err = temporalio.exceptions.TimeoutError(
1136+
err = temporalio.exceptions.TimeoutError._from_failure(
11371137
failure.message or "Timeout",
1138-
type=temporalio.exceptions.TimeoutType(int(timeout_info.timeout_type))
1138+
timeout_type=temporalio.exceptions.TimeoutType(
1139+
int(timeout_info.timeout_type)
1140+
)
11391141
if timeout_info.timeout_type
11401142
else None,
1141-
last_heartbeat_details=payload_converter.from_payloads_wrapper(
1142-
timeout_info.last_heartbeat_details
1143-
),
1143+
heartbeat_payloads=timeout_info.last_heartbeat_details,
1144+
payload_converter=payload_converter,
11441145
)
11451146
elif failure.HasField("canceled_failure_info"):
11461147
cancel_info = failure.canceled_failure_info

temporalio/exceptions.py

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Common Temporal exceptions."""
22

33
import asyncio
4+
import builtins
45
import typing
56
from collections.abc import Sequence
67
from datetime import timedelta
@@ -144,20 +145,46 @@ def _from_failure(
144145
@property
145146
def details(self) -> Sequence[Any]:
146147
"""User-defined details on the error."""
147-
return self.details_with_type_hints()
148-
149-
def details_with_type_hints(
150-
self, type_hints: list[type] | None = None
151-
) -> Sequence[Any]:
152-
"""User-defined details on the error with type hints for deserialization."""
153148
if self._payload_converter and self._payloads is not None:
154149
if not self._payloads or not self._payloads.payloads:
155150
return []
156-
return self._payload_converter.from_payloads(
157-
self._payloads.payloads, type_hints
158-
)
151+
return self._payload_converter.from_payloads(self._payloads.payloads, None)
159152
return self._details
160153

154+
def get_detail(self, index: int, type_hint: type | None = None) -> Any:
155+
"""Get a detail by index with optional type hint.
156+
157+
Args:
158+
index: Zero-based index of the detail to retrieve.
159+
type_hint: Optional type hint for deserialization.
160+
161+
Returns:
162+
The detail at the specified index.
163+
164+
Raises:
165+
IndexError: If the index is out of range.
166+
"""
167+
if (
168+
self._payload_converter
169+
and self._payloads is not None
170+
and self._payloads.payloads
171+
):
172+
if index < 0 or index >= len(self._payloads.payloads):
173+
raise IndexError(
174+
f"Detail index {index} out of range (0-{len(self._payloads.payloads)-1})"
175+
)
176+
# Convert single payload at the specified index
177+
payload = self._payloads.payloads[index]
178+
type_hints = [type_hint] if type_hint is not None else None
179+
converted = self._payload_converter.from_payloads([payload], type_hints)
180+
return converted[0] if converted else None
181+
else:
182+
if index < 0 or index >= len(self._details):
183+
raise IndexError(
184+
f"Detail index {index} out of range (0-{len(self._details)-1})"
185+
)
186+
return self._details[index]
187+
161188
@property
162189
def type(self) -> str | None:
163190
"""General error type."""
@@ -245,17 +272,82 @@ def __init__(
245272
super().__init__(message)
246273
self._type = type
247274
self._last_heartbeat_details = last_heartbeat_details
275+
self._heartbeat_payloads: Payloads | None = None
276+
self._payload_converter: "PayloadConverter | None" = None
248277

249278
@property
250279
def type(self) -> TimeoutType | None:
251280
"""Type of timeout error."""
252281
return self._type
253282

283+
@classmethod
284+
def _from_failure(
285+
cls,
286+
message: str,
287+
timeout_type: TimeoutType | None,
288+
heartbeat_payloads: Payloads | None,
289+
payload_converter: "PayloadConverter",
290+
) -> "TimeoutError":
291+
"""Create a TimeoutError from failure payloads (internal use only)."""
292+
# Create instance using regular constructor first
293+
instance = cls(
294+
message,
295+
type=timeout_type,
296+
last_heartbeat_details=[], # Will be overridden if payloads exist
297+
)
298+
# Override payloads and payload converter for lazy loading if payloads exist
299+
if heartbeat_payloads is not None:
300+
instance._heartbeat_payloads = heartbeat_payloads
301+
instance._payload_converter = payload_converter
302+
return instance
303+
254304
@property
255305
def last_heartbeat_details(self) -> Sequence[Any]:
256306
"""Last heartbeat details if this is for an activity heartbeat."""
307+
if self._payload_converter and self._heartbeat_payloads is not None:
308+
if not self._heartbeat_payloads.payloads:
309+
return []
310+
return self._payload_converter.from_payloads(
311+
self._heartbeat_payloads.payloads, None
312+
)
257313
return self._last_heartbeat_details
258314

315+
def get_heartbeat_detail(
316+
self, index: int, type_hint: builtins.type | None = None
317+
) -> Any:
318+
"""Get a heartbeat detail by index with optional type hint.
319+
320+
Args:
321+
index: Zero-based index of the heartbeat detail to retrieve.
322+
type_hint: Optional type hint for deserialization.
323+
324+
Returns:
325+
The heartbeat detail at the specified index.
326+
327+
Raises:
328+
IndexError: If the index is out of range.
329+
"""
330+
if (
331+
self._payload_converter
332+
and self._heartbeat_payloads is not None
333+
and self._heartbeat_payloads.payloads
334+
):
335+
if index < 0 or index >= len(self._heartbeat_payloads.payloads):
336+
raise IndexError(
337+
f"Heartbeat detail index {index} out of range (0-{len(self._heartbeat_payloads.payloads)-1})"
338+
)
339+
# Convert single payload at the specified index
340+
payload = self._heartbeat_payloads.payloads[index]
341+
type_hints = [type_hint] if type_hint is not None else None
342+
converted = self._payload_converter.from_payloads([payload], type_hints)
343+
return converted[0] if converted else None
344+
else:
345+
if index < 0 or index >= len(self._last_heartbeat_details):
346+
raise IndexError(
347+
f"Heartbeat detail index {index} out of range (0-{len(self._last_heartbeat_details)-1})"
348+
)
349+
return self._last_heartbeat_details[index]
350+
259351

260352
class ServerError(FailureError):
261353
"""Error originating in the Temporal server."""

temporalio/testing/_activity.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
activity_type="unknown",
2929
attempt=1,
3030
current_attempt_scheduled_time=_utc_zero,
31-
heartbeat_details=[],
31+
_heartbeat_payloads=[],
32+
_payload_converter=temporalio.converter.DataConverter.default.payload_converter,
3233
heartbeat_timeout=None,
3334
is_local=False,
3435
schedule_to_close_timeout=timedelta(seconds=1),

temporalio/worker/_activity.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -527,19 +527,6 @@ async def _execute_activity(
527527
if not activity_def.name:
528528
args = [args]
529529

530-
# Convert heartbeat details
531-
# TODO(cretz): Allow some way to configure heartbeat type hinting?
532-
try:
533-
heartbeat_details = (
534-
[]
535-
if not start.heartbeat_details
536-
else await data_converter.decode(start.heartbeat_details)
537-
)
538-
except Exception as err:
539-
raise temporalio.exceptions.ApplicationError(
540-
"Failed decoding heartbeat details", non_retryable=True
541-
) from err
542-
543530
# Build info
544531
info = temporalio.activity.Info(
545532
activity_id=start.activity_id,
@@ -548,7 +535,8 @@ async def _execute_activity(
548535
current_attempt_scheduled_time=_proto_to_datetime(
549536
start.current_attempt_scheduled_time
550537
),
551-
heartbeat_details=heartbeat_details,
538+
_heartbeat_payloads=list(start.heartbeat_details),
539+
_payload_converter=data_converter.payload_converter,
552540
heartbeat_timeout=_proto_to_non_zero_timedelta(start.heartbeat_timeout)
553541
if start.HasField("heartbeat_timeout")
554542
else None,

tests/test_converter.py

Lines changed: 34 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -693,8 +693,8 @@ class MyCustomDetail:
693693
timestamp: datetime
694694

695695

696-
async def test_application_error_details_with_type_hints():
697-
"""Test ApplicationError details with type hints functionality."""
696+
async def test_application_error_get_detail():
697+
"""Test ApplicationError get_detail functionality."""
698698

699699
# Test data
700700
detail_str = "error detail"
@@ -727,16 +727,22 @@ async def test_application_error_details_with_type_hints():
727727
assert details[2]["value"] == 42
728728
assert details[2]["timestamp"] == "2023-01-01T12:00:00"
729729

730-
# Test accessing details with type hints
731-
typed_details = decoded_error.details_with_type_hints([str, int, MyCustomDetail])
732-
assert len(typed_details) == 3
733-
assert typed_details[0] == detail_str
734-
assert typed_details[1] == detail_int
730+
# Test accessing individual details with type hints
731+
assert decoded_error.get_detail(0, str) == detail_str
732+
assert decoded_error.get_detail(1, int) == detail_int
735733
# Custom object is properly reconstructed with type hint
736-
assert isinstance(typed_details[2], MyCustomDetail)
737-
assert typed_details[2].name == "test"
738-
assert typed_details[2].value == 42
739-
assert typed_details[2].timestamp == datetime(2023, 1, 1, 12, 0, 0)
734+
custom_detail = decoded_error.get_detail(2, MyCustomDetail)
735+
assert isinstance(custom_detail, MyCustomDetail)
736+
assert custom_detail.name == "test"
737+
assert custom_detail.value == 42
738+
assert custom_detail.timestamp == datetime(2023, 1, 1, 12, 0, 0)
739+
740+
# Test accessing details without type hints using get_detail
741+
assert decoded_error.get_detail(0) == detail_str
742+
assert decoded_error.get_detail(1) == detail_int
743+
dict_detail = decoded_error.get_detail(2)
744+
assert isinstance(dict_detail, dict)
745+
assert dict_detail["name"] == "test"
740746

741747

742748
async def test_application_error_details_empty():
@@ -751,34 +757,9 @@ async def test_application_error_details_empty():
751757

752758
assert isinstance(decoded_error, ApplicationError)
753759
assert len(decoded_error.details) == 0
754-
assert len(decoded_error.details_with_type_hints([])) == 0
755-
756-
757-
async def test_application_error_details_partial_type_hints():
758-
"""Test ApplicationError details with partial type hints."""
759-
760-
detail1 = "string detail"
761-
detail2 = 456
762-
detail3 = MyCustomDetail("partial", 99, datetime(2023, 6, 15, 9, 30, 0))
763-
764-
error = ApplicationError(
765-
"Partial hints error", detail1, detail2, detail3, type="PartialHints"
766-
)
767-
768-
failure = Failure()
769-
converter = DataConverter.default
770-
await converter.encode_failure(error, failure)
771-
decoded_error = await converter.decode_failure(failure)
772-
773-
# Provide type hints for only the first two details
774-
assert isinstance(decoded_error, ApplicationError)
775-
typed_details = decoded_error.details_with_type_hints([str, int])
776-
assert len(typed_details) == 3
777-
assert typed_details[0] == detail1
778-
assert typed_details[1] == detail2
779-
# Third detail has no type hint, so it remains as dict
780-
assert isinstance(typed_details[2], dict)
781-
assert typed_details[2]["name"] == "partial"
760+
# Test get_detail with out of range index
761+
with pytest.raises(IndexError):
762+
decoded_error.get_detail(0)
782763

783764

784765
async def test_application_error_details_direct_creation():
@@ -804,13 +785,12 @@ async def test_application_error_details_direct_creation():
804785
assert details[0] == detail1
805786
assert isinstance(details[1], dict) # No type hint
806787

807-
# Test with type hints
808-
typed_details = error.details_with_type_hints([str, MyCustomDetail])
809-
assert len(typed_details) == 2
810-
assert typed_details[0] == detail1
811-
assert isinstance(typed_details[1], MyCustomDetail)
812-
assert typed_details[1].name == "direct"
813-
assert typed_details[1].value == 777
788+
# Test get_detail method
789+
assert error.get_detail(0, str) == detail1
790+
custom_detail = error.get_detail(1, MyCustomDetail)
791+
assert isinstance(custom_detail, MyCustomDetail)
792+
assert custom_detail.name == "direct"
793+
assert custom_detail.value == 777
814794

815795

816796
async def test_application_error_details_none_payload_converter():
@@ -824,10 +804,11 @@ async def test_application_error_details_none_payload_converter():
824804

825805
# Both methods should return the same result - the raw details tuple
826806
details = error.details
827-
typed_details = error.details_with_type_hints([str, int])
828-
829807
assert details == (detail1, detail2)
830-
assert typed_details == (detail1, detail2)
808+
809+
# Test get_detail method
810+
assert error.get_detail(0) == detail1
811+
assert error.get_detail(1) == detail2
831812

832813

833814
def test_application_error_details_edge_cases():
@@ -845,7 +826,8 @@ def test_application_error_details_edge_cases():
845826
)
846827

847828
assert len(error.details) == 0
848-
assert len(error.details_with_type_hints([str])) == 0
829+
with pytest.raises(IndexError):
830+
error.get_detail(0)
849831

850832
# Test with non-Payloads details when payload_converter is set
851833
error2 = ApplicationError(
@@ -856,4 +838,5 @@ def test_application_error_details_edge_cases():
856838

857839
# Should return the raw details since they're not Payloads
858840
assert error2.details == ("string", 123)
859-
assert error2.details_with_type_hints([str, int]) == ("string", 123)
841+
assert error2.get_detail(0) == "string"
842+
assert error2.get_detail(1) == 123

0 commit comments

Comments
 (0)