Skip to content

Commit 2047d6b

Browse files
1NinadCopilotacroca
authored
feat(actor): Add reminder failure policy (#953)
* feat(actor): add reminder failure policy Signed-off-by: Ninad Kale <ninadkale200@gmail.com> * Potential fix for pull request finding Copilot's suggestion 1 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Ninad Kale <145228622+1Ninad@users.noreply.github.com> * fix: copilot feedback Signed-off-by: Ninad Kale <ninadkale200@gmail.com> * fix: copilot feedback Signed-off-by: Ninad Kale <ninadkale200@gmail.com> --------- Signed-off-by: Ninad Kale <ninadkale200@gmail.com> Signed-off-by: Ninad Kale <145228622+1Ninad@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Albert Callarisa <albert@diagrid.io>
1 parent 74501f7 commit 2047d6b

9 files changed

Lines changed: 372 additions & 15 deletions

File tree

dapr/actor/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from dapr.actor.client.proxy import ActorProxy, ActorProxyFactory
1818
from dapr.actor.id import ActorId
1919
from dapr.actor.runtime.actor import Actor
20+
from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy
2021
from dapr.actor.runtime.remindable import Remindable
2122
from dapr.actor.runtime.runtime import ActorRuntime
2223

@@ -26,6 +27,7 @@
2627
'ActorProxyFactory',
2728
'ActorId',
2829
'Actor',
30+
'ActorReminderFailurePolicy',
2931
'ActorRuntime',
3032
'Remindable',
3133
'actormethod',
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
Copyright 2026 The Dapr Authors
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
"""
15+
16+
# Backward-compatible shim — import from the public module instead.
17+
from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy
18+
19+
__all__ = ['ActorReminderFailurePolicy']

dapr/actor/runtime/_reminder_data.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from datetime import timedelta
1818
from typing import Any, Dict, Optional
1919

20+
from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy
21+
2022

2123
class ActorReminderData:
2224
"""The class that holds actor reminder data.
@@ -28,32 +30,37 @@ class ActorReminderData:
2830
for the first time.
2931
period: the time interval between reminder invocations after
3032
the first invocation.
33+
failure_policy: the optional policy for handling reminder failures.
3134
"""
3235

3336
def __init__(
3437
self,
3538
reminder_name: str,
36-
state: Optional[bytes],
39+
state: bytes,
3740
due_time: timedelta,
3841
period: Optional[timedelta] = None,
3942
ttl: Optional[timedelta] = None,
43+
failure_policy: Optional[ActorReminderFailurePolicy] = None,
4044
):
4145
"""Creates new :class:`ActorReminderData` instance.
4246
4347
Args:
4448
reminder_name (str): the name of Actor reminder.
45-
state (bytes, str): the state data passed to
49+
state (bytes): the state data passed to
4650
receive_reminder callback.
4751
due_time (datetime.timedelta): the amount of time to delay before
4852
invoking the reminder for the first time.
4953
period (datetime.timedelta): the time interval between reminder
5054
invocations after the first invocation.
5155
ttl (Optional[datetime.timedelta]): the time interval before the reminder stops firing.
56+
failure_policy (Optional[ActorReminderFailurePolicy]): the policy for handling
57+
reminder failures. If not set, the Dapr runtime default applies (3 retries).
5258
"""
5359
self._reminder_name = reminder_name
5460
self._due_time = due_time
5561
self._period = period
5662
self._ttl = ttl
63+
self._failure_policy = failure_policy
5764

5865
if not isinstance(state, bytes):
5966
raise ValueError(f'only bytes are allowed for state: {type(state)}')
@@ -85,11 +92,14 @@ def ttl(self) -> Optional[timedelta]:
8592
"""Gets ttl of Actor Reminder."""
8693
return self._ttl
8794

95+
@property
96+
def failure_policy(self) -> Optional[ActorReminderFailurePolicy]:
97+
"""Gets the failure policy of Actor Reminder."""
98+
return self._failure_policy
99+
88100
def as_dict(self) -> Dict[str, Any]:
89101
"""Gets :class:`ActorReminderData` as a dict object."""
90-
encoded_state = None
91-
if self._state is not None:
92-
encoded_state = base64.b64encode(self._state)
102+
encoded_state = base64.b64encode(self._state)
93103
reminderDict: Dict[str, Any] = {
94104
'reminderName': self._reminder_name,
95105
'dueTime': self._due_time,
@@ -100,14 +110,18 @@ def as_dict(self) -> Dict[str, Any]:
100110
if self._ttl is not None:
101111
reminderDict.update({'ttl': self._ttl})
102112

113+
if self._failure_policy is not None:
114+
reminderDict.update({'failurePolicy': self._failure_policy.as_dict()})
115+
103116
return reminderDict
104117

105118
@classmethod
106119
def from_dict(cls, reminder_name: str, obj: Dict[str, Any]) -> 'ActorReminderData':
107120
"""Creates :class:`ActorReminderData` object from dict object."""
108121
b64encoded_state = obj.get('data')
109-
state_bytes = None
110-
if b64encoded_state is not None and len(b64encoded_state) > 0:
122+
if b64encoded_state is None or len(b64encoded_state) == 0:
123+
state_bytes = b''
124+
else:
111125
state_bytes = base64.b64decode(b64encoded_state)
112126
if 'ttl' in obj:
113127
return ActorReminderData(

dapr/actor/runtime/actor.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from dapr.actor.runtime._reminder_data import ActorReminderData
2323
from dapr.actor.runtime._timer_data import TIMER_CALLBACK, ActorTimerData
2424
from dapr.actor.runtime.context import ActorRuntimeContext
25+
from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy
2526
from dapr.actor.runtime.state_manager import ActorStateManager
2627

2728

@@ -113,6 +114,7 @@ async def register_reminder(
113114
due_time: timedelta,
114115
period: Optional[timedelta] = None,
115116
ttl: Optional[timedelta] = None,
117+
failure_policy: Optional[ActorReminderFailurePolicy] = None,
116118
) -> None:
117119
"""Registers actor reminder.
118120
@@ -129,11 +131,16 @@ async def register_reminder(
129131
state (bytes): the user state passed to the reminder invocation.
130132
due_time (datetime.timedelta): the amount of time to delay before invoking the reminder
131133
for the first time.
132-
period (datetime.timedelta): the time interval between reminder invocations after
133-
the first invocation.
134-
ttl (datetime.timedelta): the time interval before the reminder stops firing
134+
period (Optional[datetime.timedelta]): the optional time interval between reminder
135+
invocations after the first invocation. If not set, the Dapr runtime behavior
136+
for one-off or non-periodic reminders applies.
137+
ttl (Optional[datetime.timedelta]): the optional time interval before the reminder
138+
stops firing. If not set, the Dapr runtime default behavior applies.
139+
failure_policy (Optional[ActorReminderFailurePolicy]): the optional policy for
140+
handling reminder failures. If not set, the Dapr runtime default applies
141+
(3 retries per tick).
135142
"""
136-
reminder = ActorReminderData(name, state, due_time, period, ttl)
143+
reminder = ActorReminderData(name, state, due_time, period, ttl, failure_policy)
137144
req_body = self._runtime_ctx.message_serializer.serialize(reminder.as_dict())
138145
await self._runtime_ctx.dapr_client.register_reminder(
139146
self._runtime_ctx.actor_type_info.type_name, self.id.id, name, req_body
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
Copyright 2026 The Dapr Authors
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
"""
15+
16+
from datetime import timedelta
17+
from typing import Any, Dict, Optional
18+
19+
20+
class ActorReminderFailurePolicy:
21+
"""Defines what happens when an actor reminder fails to trigger.
22+
23+
Use :meth:`drop_policy` to discard failed ticks without retrying, or
24+
:meth:`constant_policy` to retry at a fixed interval.
25+
26+
Attributes:
27+
drop: whether this is a drop (no-retry) policy.
28+
interval: the retry interval for a constant policy.
29+
max_retries: the maximum number of retries for a constant policy.
30+
"""
31+
32+
def __init__(
33+
self,
34+
*,
35+
drop: bool = False,
36+
interval: Optional[timedelta] = None,
37+
max_retries: Optional[int] = None,
38+
):
39+
"""Creates a new :class:`ActorReminderFailurePolicy` instance.
40+
41+
Args:
42+
drop (bool): if True, creates a drop policy that discards the reminder
43+
tick on failure without retrying. Cannot be combined with interval
44+
or max_retries.
45+
interval (datetime.timedelta): the retry interval for a constant policy.
46+
max_retries (int): the maximum number of retries for a constant policy.
47+
If not set, retries indefinitely.
48+
49+
Raises:
50+
ValueError: if drop is combined with interval or max_retries, or if
51+
neither drop=True nor at least one of interval/max_retries is provided.
52+
"""
53+
if drop and (interval is not None or max_retries is not None):
54+
raise ValueError('drop policy cannot be combined with interval or max_retries')
55+
if not drop and interval is None and max_retries is None:
56+
raise ValueError('specify either drop=True or at least one of interval or max_retries')
57+
self._drop = drop
58+
self._interval = interval
59+
self._max_retries = max_retries
60+
61+
@classmethod
62+
def drop_policy(cls) -> 'ActorReminderFailurePolicy':
63+
"""Returns a policy that drops the reminder tick on failure (no retry)."""
64+
return cls(drop=True)
65+
66+
@classmethod
67+
def constant_policy(
68+
cls,
69+
interval: Optional[timedelta] = None,
70+
max_retries: Optional[int] = None,
71+
) -> 'ActorReminderFailurePolicy':
72+
"""Returns a policy that retries at a constant interval on failure.
73+
74+
Args:
75+
interval (datetime.timedelta): the time between retry attempts.
76+
max_retries (int): the maximum number of retry attempts. If not set,
77+
retries indefinitely.
78+
"""
79+
return cls(interval=interval, max_retries=max_retries)
80+
81+
@property
82+
def drop(self) -> bool:
83+
"""Returns True if this is a drop policy."""
84+
return self._drop
85+
86+
@property
87+
def interval(self) -> Optional[timedelta]:
88+
"""Returns the retry interval for a constant policy."""
89+
return self._interval
90+
91+
@property
92+
def max_retries(self) -> Optional[int]:
93+
"""Returns the maximum retries for a constant policy."""
94+
return self._max_retries
95+
96+
def as_dict(self) -> Dict[str, Any]:
97+
"""Gets :class:`ActorReminderFailurePolicy` as a dict object."""
98+
if self._drop:
99+
return {'drop': {}}
100+
d: Dict[str, Any] = {}
101+
if self._interval is not None:
102+
d['interval'] = self._interval
103+
if self._max_retries is not None:
104+
d['maxRetries'] = self._max_retries
105+
return {'constant': d}

dapr/actor/runtime/mock_actor.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from dapr.actor.runtime._reminder_data import ActorReminderData
2121
from dapr.actor.runtime._timer_data import TIMER_CALLBACK, ActorTimerData
2222
from dapr.actor.runtime.actor import Actor
23+
from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy
2324
from dapr.actor.runtime.mock_state_manager import MockStateManager
2425

2526

@@ -88,6 +89,7 @@ async def register_reminder(
8889
due_time: timedelta,
8990
period: Optional[timedelta] = None,
9091
ttl: Optional[timedelta] = None,
92+
failure_policy: Optional[ActorReminderFailurePolicy] = None,
9193
) -> None:
9294
"""Adds actor reminder to self._state_manager._mock_reminders.
9395
@@ -96,11 +98,16 @@ async def register_reminder(
9698
state (bytes): the user state passed to the reminder invocation.
9799
due_time (datetime.timedelta): the amount of time to delay before invoking the reminder
98100
for the first time.
99-
period (datetime.timedelta): the time interval between reminder invocations after
100-
the first invocation.
101-
ttl (datetime.timedelta): the time interval before the reminder stops firing
101+
period (Optional[datetime.timedelta]): the optional time interval between reminder
102+
invocations after the first invocation. If None, the reminder uses the Dapr
103+
runtime behavior for one-off or non-periodic reminders.
104+
ttl (Optional[datetime.timedelta]): the optional time interval before the reminder
105+
stops firing. If None, no explicit TTL is set.
106+
failure_policy (Optional[ActorReminderFailurePolicy]): the optional policy for
107+
handling reminder failures. If not set, the Dapr runtime default applies
108+
(3 retries per tick).
102109
"""
103-
reminder = ActorReminderData(name, state, due_time, period, ttl)
110+
reminder = ActorReminderData(name, state, due_time, period, ttl, failure_policy)
104111
self._state_manager._mock_reminders[name] = reminder # type: ignore
105112

106113
async def unregister_reminder(self, name: str) -> None:

tests/actor/test_actor.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from dapr.actor.runtime._type_information import ActorTypeInformation
2222
from dapr.actor.runtime.config import ActorRuntimeConfig
2323
from dapr.actor.runtime.context import ActorRuntimeContext
24+
from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy
2425
from dapr.actor.runtime.runtime import ActorRuntime
2526
from dapr.conf import settings
2627
from dapr.serializers import DefaultJSONSerializer
@@ -151,6 +152,65 @@ def test_register_reminder(self):
151152
'FakeSimpleReminderActor', 'test_id', 'test_reminder'
152153
)
153154

155+
@mock.patch(
156+
'tests.actor.fake_client.FakeDaprActorClient.register_reminder',
157+
new=_async_mock(return_value=b'"ok"'),
158+
)
159+
def test_register_reminder_with_failure_policy(self):
160+
test_actor_id = ActorId('test_id')
161+
test_type_info = ActorTypeInformation.create(FakeSimpleReminderActor)
162+
test_client = FakeDaprActorClient
163+
ctx = ActorRuntimeContext(test_type_info, self._serializer, self._serializer, test_client)
164+
test_actor = FakeSimpleReminderActor(ctx, test_actor_id)
165+
166+
_run(
167+
test_actor.register_reminder(
168+
'test_reminder',
169+
b'reminder_message',
170+
timedelta(seconds=1),
171+
timedelta(seconds=1),
172+
failure_policy=ActorReminderFailurePolicy.drop_policy(),
173+
)
174+
)
175+
test_client.register_reminder.mock.assert_called_once()
176+
test_client.register_reminder.mock.assert_called_with(
177+
'FakeSimpleReminderActor',
178+
'test_id',
179+
'test_reminder',
180+
b'{"reminderName":"test_reminder","dueTime":"0h0m1s0ms0\\u03bcs","period":"0h0m1s0ms0\\u03bcs","data":"cmVtaW5kZXJfbWVzc2FnZQ==","failurePolicy":{"drop":{}}}', # noqa E501
181+
)
182+
183+
@mock.patch(
184+
'tests.actor.fake_client.FakeDaprActorClient.register_reminder',
185+
new=_async_mock(return_value=b'"ok"'),
186+
)
187+
def test_register_reminder_with_constant_failure_policy(self):
188+
test_actor_id = ActorId('test_id')
189+
test_type_info = ActorTypeInformation.create(FakeSimpleReminderActor)
190+
test_client = FakeDaprActorClient
191+
ctx = ActorRuntimeContext(test_type_info, self._serializer, self._serializer, test_client)
192+
test_actor = FakeSimpleReminderActor(ctx, test_actor_id)
193+
194+
_run(
195+
test_actor.register_reminder(
196+
'test_reminder',
197+
b'reminder_message',
198+
timedelta(seconds=1),
199+
timedelta(seconds=1),
200+
failure_policy=ActorReminderFailurePolicy.constant_policy(
201+
interval=timedelta(seconds=2),
202+
max_retries=5,
203+
),
204+
)
205+
)
206+
test_client.register_reminder.mock.assert_called_once()
207+
test_client.register_reminder.mock.assert_called_with(
208+
'FakeSimpleReminderActor',
209+
'test_id',
210+
'test_reminder',
211+
b'{"reminderName":"test_reminder","dueTime":"0h0m1s0ms0\\u03bcs","period":"0h0m1s0ms0\\u03bcs","data":"cmVtaW5kZXJfbWVzc2FnZQ==","failurePolicy":{"constant":{"interval":"0h0m2s0ms0\\u03bcs","maxRetries":5}}}', # noqa E501
212+
)
213+
154214
@mock.patch(
155215
'tests.actor.fake_client.FakeDaprActorClient.register_timer',
156216
new=_async_mock(return_value=b'"ok"'),

0 commit comments

Comments
 (0)