From 3eb01f31327c631617af964e3c5a3c04d485e9d5 Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Thu, 12 Mar 2026 20:34:30 +0530 Subject: [PATCH 1/4] feat(actor): add reminder failure policy Signed-off-by: Ninad Kale --- dapr/actor/__init__.py | 2 + dapr/actor/runtime/_failure_policy.py | 107 ++++++++++++++++++++++++++ dapr/actor/runtime/_reminder_data.py | 15 ++++ dapr/actor/runtime/actor.py | 8 +- dapr/actor/runtime/mock_actor.py | 8 +- tests/actor/test_failure_policy.py | 87 +++++++++++++++++++++ tests/actor/test_reminder_data.py | 58 ++++++++++++++ 7 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 dapr/actor/runtime/_failure_policy.py create mode 100644 tests/actor/test_failure_policy.py diff --git a/dapr/actor/__init__.py b/dapr/actor/__init__.py index bf21f488c..90014c7d5 100644 --- a/dapr/actor/__init__.py +++ b/dapr/actor/__init__.py @@ -16,6 +16,7 @@ from dapr.actor.actor_interface import ActorInterface, actormethod from dapr.actor.client.proxy import ActorProxy, ActorProxyFactory from dapr.actor.id import ActorId +from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime.actor import Actor from dapr.actor.runtime.remindable import Remindable from dapr.actor.runtime.runtime import ActorRuntime @@ -26,6 +27,7 @@ 'ActorProxyFactory', 'ActorId', 'Actor', + 'ActorReminderFailurePolicy', 'ActorRuntime', 'Remindable', 'actormethod', diff --git a/dapr/actor/runtime/_failure_policy.py b/dapr/actor/runtime/_failure_policy.py new file mode 100644 index 000000000..f96d634e4 --- /dev/null +++ b/dapr/actor/runtime/_failure_policy.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2024 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from datetime import timedelta +from typing import Any, Dict, Optional + + +class ActorReminderFailurePolicy: + """Defines what happens when an actor reminder fails to trigger. + + Use :meth:`drop_policy` to discard failed ticks without retrying, or + :meth:`constant_policy` to retry at a fixed interval. + + Attributes: + drop: whether this is a drop (no-retry) policy. + interval: the retry interval for a constant policy. + max_retries: the maximum number of retries for a constant policy. + """ + + def __init__( + self, + *, + drop: bool = False, + interval: Optional[timedelta] = None, + max_retries: Optional[int] = None, + ): + """Creates a new :class:`ActorReminderFailurePolicy` instance. + + Args: + drop (bool): if True, creates a drop policy that discards the reminder + tick on failure without retrying. Cannot be combined with interval + or max_retries. + interval (datetime.timedelta): the retry interval for a constant policy. + max_retries (int): the maximum number of retries for a constant policy. + If not set, retries indefinitely. + + Raises: + ValueError: if drop is combined with interval or max_retries, or if + neither drop=True nor at least one of interval/max_retries is provided. + """ + if drop and (interval is not None or max_retries is not None): + raise ValueError('drop policy cannot be combined with interval or max_retries') + if not drop and interval is None and max_retries is None: + raise ValueError( + 'specify either drop=True or at least one of interval or max_retries' + ) + self._drop = drop + self._interval = interval + self._max_retries = max_retries + + @classmethod + def drop_policy(cls) -> 'ActorReminderFailurePolicy': + """Returns a policy that drops the reminder tick on failure (no retry).""" + return cls(drop=True) + + @classmethod + def constant_policy( + cls, + interval: Optional[timedelta] = None, + max_retries: Optional[int] = None, + ) -> 'ActorReminderFailurePolicy': + """Returns a policy that retries at a constant interval on failure. + + Args: + interval (datetime.timedelta): the time between retry attempts. + max_retries (int): the maximum number of retry attempts. If not set, + retries indefinitely. + """ + return cls(interval=interval, max_retries=max_retries) + + @property + def drop(self) -> bool: + """Returns True if this is a drop policy.""" + return self._drop + + @property + def interval(self) -> Optional[timedelta]: + """Returns the retry interval for a constant policy.""" + return self._interval + + @property + def max_retries(self) -> Optional[int]: + """Returns the maximum retries for a constant policy.""" + return self._max_retries + + def as_dict(self) -> Dict[str, Any]: + """Gets :class:`ActorReminderFailurePolicy` as a dict object.""" + if self._drop: + return {'drop': {}} + d: Dict[str, Any] = {} + if self._interval is not None: + d['interval'] = self._interval + if self._max_retries is not None: + d['maxRetries'] = self._max_retries + return {'constant': d} diff --git a/dapr/actor/runtime/_reminder_data.py b/dapr/actor/runtime/_reminder_data.py index 5453b8162..ec83041af 100644 --- a/dapr/actor/runtime/_reminder_data.py +++ b/dapr/actor/runtime/_reminder_data.py @@ -17,6 +17,8 @@ from datetime import timedelta from typing import Any, Dict, Optional +from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy + class ActorReminderData: """The class that holds actor reminder data. @@ -28,6 +30,7 @@ class ActorReminderData: for the first time. period: the time interval between reminder invocations after the first invocation. + failure_policy: the optional policy for handling reminder failures. """ def __init__( @@ -37,6 +40,7 @@ def __init__( due_time: timedelta, period: Optional[timedelta] = None, ttl: Optional[timedelta] = None, + failure_policy: Optional[ActorReminderFailurePolicy] = None, ): """Creates new :class:`ActorReminderData` instance. @@ -49,11 +53,14 @@ def __init__( period (datetime.timedelta): the time interval between reminder invocations after the first invocation. ttl (Optional[datetime.timedelta]): the time interval before the reminder stops firing. + failure_policy (Optional[ActorReminderFailurePolicy]): the policy for handling + reminder failures. If not set, the Dapr runtime default applies (3 retries). """ self._reminder_name = reminder_name self._due_time = due_time self._period = period self._ttl = ttl + self._failure_policy = failure_policy if not isinstance(state, bytes): raise ValueError(f'only bytes are allowed for state: {type(state)}') @@ -85,6 +92,11 @@ def ttl(self) -> Optional[timedelta]: """Gets ttl of Actor Reminder.""" return self._ttl + @property + def failure_policy(self) -> Optional[ActorReminderFailurePolicy]: + """Gets the failure policy of Actor Reminder.""" + return self._failure_policy + def as_dict(self) -> Dict[str, Any]: """Gets :class:`ActorReminderData` as a dict object.""" encoded_state = None @@ -100,6 +112,9 @@ def as_dict(self) -> Dict[str, Any]: if self._ttl is not None: reminderDict.update({'ttl': self._ttl}) + if self._failure_policy is not None: + reminderDict.update({'failurePolicy': self._failure_policy.as_dict()}) + return reminderDict @classmethod diff --git a/dapr/actor/runtime/actor.py b/dapr/actor/runtime/actor.py index fab02fc70..711362d62 100644 --- a/dapr/actor/runtime/actor.py +++ b/dapr/actor/runtime/actor.py @@ -18,6 +18,7 @@ from typing import Any, Optional from dapr.actor.id import ActorId +from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime._method_context import ActorMethodContext from dapr.actor.runtime._reminder_data import ActorReminderData from dapr.actor.runtime._timer_data import TIMER_CALLBACK, ActorTimerData @@ -113,6 +114,7 @@ async def register_reminder( due_time: timedelta, period: Optional[timedelta] = None, ttl: Optional[timedelta] = None, + failure_policy: Optional[ActorReminderFailurePolicy] = None, ) -> None: """Registers actor reminder. @@ -131,9 +133,11 @@ async def register_reminder( for the first time. period (datetime.timedelta): the time interval between reminder invocations after the first invocation. - ttl (datetime.timedelta): the time interval before the reminder stops firing + ttl (datetime.timedelta): the time interval before the reminder stops firing. + failure_policy (ActorReminderFailurePolicy): the optional policy for handling reminder + failures. If not set, the Dapr runtime default applies (3 retries per tick). """ - reminder = ActorReminderData(name, state, due_time, period, ttl) + reminder = ActorReminderData(name, state, due_time, period, ttl, failure_policy) req_body = self._runtime_ctx.message_serializer.serialize(reminder.as_dict()) await self._runtime_ctx.dapr_client.register_reminder( self._runtime_ctx.actor_type_info.type_name, self.id.id, name, req_body diff --git a/dapr/actor/runtime/mock_actor.py b/dapr/actor/runtime/mock_actor.py index 82170672e..1cad64662 100644 --- a/dapr/actor/runtime/mock_actor.py +++ b/dapr/actor/runtime/mock_actor.py @@ -17,6 +17,7 @@ from typing import Any, Optional, TypeVar from dapr.actor.id import ActorId +from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime._reminder_data import ActorReminderData from dapr.actor.runtime._timer_data import TIMER_CALLBACK, ActorTimerData from dapr.actor.runtime.actor import Actor @@ -88,6 +89,7 @@ async def register_reminder( due_time: timedelta, period: Optional[timedelta] = None, ttl: Optional[timedelta] = None, + failure_policy: Optional[ActorReminderFailurePolicy] = None, ) -> None: """Adds actor reminder to self._state_manager._mock_reminders. @@ -98,9 +100,11 @@ async def register_reminder( for the first time. period (datetime.timedelta): the time interval between reminder invocations after the first invocation. - ttl (datetime.timedelta): the time interval before the reminder stops firing + ttl (datetime.timedelta): the time interval before the reminder stops firing. + failure_policy (ActorReminderFailurePolicy): the optional policy for handling reminder + failures. If not set, the Dapr runtime default applies (3 retries per tick). """ - reminder = ActorReminderData(name, state, due_time, period, ttl) + reminder = ActorReminderData(name, state, due_time, period, ttl, failure_policy) self._state_manager._mock_reminders[name] = reminder # type: ignore async def unregister_reminder(self, name: str) -> None: diff --git a/tests/actor/test_failure_policy.py b/tests/actor/test_failure_policy.py new file mode 100644 index 000000000..65713c922 --- /dev/null +++ b/tests/actor/test_failure_policy.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2024 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import unittest +from datetime import timedelta + +from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy + + +class ActorReminderFailurePolicyTests(unittest.TestCase): + # --- drop_policy --- + + def test_drop_policy_factory(self): + p = ActorReminderFailurePolicy.drop_policy() + self.assertTrue(p.drop) + self.assertIsNone(p.interval) + self.assertIsNone(p.max_retries) + + def test_drop_policy_as_dict(self): + p = ActorReminderFailurePolicy.drop_policy() + self.assertEqual({'drop': {}}, p.as_dict()) + + # --- constant_policy --- + + def test_constant_policy_interval_and_max_retries(self): + p = ActorReminderFailurePolicy.constant_policy( + interval=timedelta(seconds=5), max_retries=3 + ) + self.assertFalse(p.drop) + self.assertEqual(timedelta(seconds=5), p.interval) + self.assertEqual(3, p.max_retries) + + def test_constant_policy_as_dict_full(self): + p = ActorReminderFailurePolicy.constant_policy( + interval=timedelta(seconds=5), max_retries=3 + ) + self.assertEqual({'constant': {'interval': timedelta(seconds=5), 'maxRetries': 3}}, p.as_dict()) + + def test_constant_policy_interval_only(self): + p = ActorReminderFailurePolicy.constant_policy(interval=timedelta(seconds=10)) + self.assertEqual({'constant': {'interval': timedelta(seconds=10)}}, p.as_dict()) + + def test_constant_policy_max_retries_only(self): + p = ActorReminderFailurePolicy.constant_policy(max_retries=5) + self.assertEqual({'constant': {'maxRetries': 5}}, p.as_dict()) + + # --- validation errors --- + + def test_drop_with_interval_raises(self): + with self.assertRaises(ValueError): + ActorReminderFailurePolicy(drop=True, interval=timedelta(seconds=1)) + + def test_drop_with_max_retries_raises(self): + with self.assertRaises(ValueError): + ActorReminderFailurePolicy(drop=True, max_retries=3) + + def test_drop_with_both_raises(self): + with self.assertRaises(ValueError): + ActorReminderFailurePolicy(drop=True, interval=timedelta(seconds=1), max_retries=3) + + def test_no_policy_specified_raises(self): + with self.assertRaises(ValueError): + ActorReminderFailurePolicy() + + # --- direct constructor --- + + def test_direct_drop_constructor(self): + p = ActorReminderFailurePolicy(drop=True) + self.assertEqual({'drop': {}}, p.as_dict()) + + def test_direct_constant_constructor(self): + p = ActorReminderFailurePolicy(interval=timedelta(seconds=2), max_retries=1) + self.assertEqual( + {'constant': {'interval': timedelta(seconds=2), 'maxRetries': 1}}, p.as_dict() + ) diff --git a/tests/actor/test_reminder_data.py b/tests/actor/test_reminder_data.py index e142217c9..3f5ac7144 100644 --- a/tests/actor/test_reminder_data.py +++ b/tests/actor/test_reminder_data.py @@ -16,6 +16,7 @@ import unittest from datetime import timedelta +from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime._reminder_data import ActorReminderData @@ -80,3 +81,60 @@ def test_from_dict(self): self.assertEqual(timedelta(seconds=2), reminder.period) self.assertEqual(timedelta(seconds=3), reminder.ttl) self.assertEqual(b'reminder_state', reminder.state) + + def test_no_failure_policy(self): + reminder = ActorReminderData( + 'test_reminder', + b'reminder_state', + timedelta(seconds=1), + timedelta(seconds=2), + ) + result = reminder.as_dict() + self.assertNotIn('failurePolicy', result) + self.assertIsNone(reminder.failure_policy) + + def test_drop_failure_policy_as_dict(self): + policy = ActorReminderFailurePolicy.drop_policy() + reminder = ActorReminderData( + 'test_reminder', + b'reminder_state', + timedelta(seconds=1), + timedelta(seconds=2), + failure_policy=policy, + ) + result = reminder.as_dict() + self.assertIn('failurePolicy', result) + self.assertEqual({'drop': {}}, result['failurePolicy']) + + def test_constant_failure_policy_as_dict(self): + policy = ActorReminderFailurePolicy.constant_policy( + interval=timedelta(seconds=5), max_retries=3 + ) + reminder = ActorReminderData( + 'test_reminder', + b'reminder_state', + timedelta(seconds=1), + timedelta(seconds=2), + failure_policy=policy, + ) + result = reminder.as_dict() + self.assertIn('failurePolicy', result) + self.assertEqual( + {'constant': {'interval': timedelta(seconds=5), 'maxRetries': 3}}, + result['failurePolicy'], + ) + + def test_failure_policy_alongside_ttl(self): + policy = ActorReminderFailurePolicy.drop_policy() + reminder = ActorReminderData( + 'test_reminder', + b'reminder_state', + timedelta(seconds=1), + timedelta(seconds=2), + ttl=timedelta(seconds=60), + failure_policy=policy, + ) + result = reminder.as_dict() + self.assertIn('ttl', result) + self.assertIn('failurePolicy', result) + self.assertEqual({'drop': {}}, result['failurePolicy']) From 3ccaa9eade7dc48091f23743a356eb043e179fee Mon Sep 17 00:00:00 2001 From: Ninad Kale <145228622+1Ninad@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:38:49 +0530 Subject: [PATCH 2/4] 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> --- dapr/actor/runtime/_reminder_data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dapr/actor/runtime/_reminder_data.py b/dapr/actor/runtime/_reminder_data.py index ec83041af..d8d9deb9e 100644 --- a/dapr/actor/runtime/_reminder_data.py +++ b/dapr/actor/runtime/_reminder_data.py @@ -36,7 +36,7 @@ class ActorReminderData: def __init__( self, reminder_name: str, - state: Optional[bytes], + state: bytes, due_time: timedelta, period: Optional[timedelta] = None, ttl: Optional[timedelta] = None, @@ -46,7 +46,7 @@ def __init__( Args: reminder_name (str): the name of Actor reminder. - state (bytes, str): the state data passed to + state (bytes): the state data passed to receive_reminder callback. due_time (datetime.timedelta): the amount of time to delay before invoking the reminder for the first time. From 63edfc9d5c1354cc572a1019e7becb663e36b894 Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Fri, 13 Mar 2026 16:17:16 +0530 Subject: [PATCH 3/4] fix: copilot feedback Signed-off-by: Ninad Kale --- dapr/actor/runtime/_failure_policy.py | 6 ++---- dapr/actor/runtime/_reminder_data.py | 9 ++++----- dapr/actor/runtime/actor.py | 13 +++++++----- dapr/actor/runtime/mock_actor.py | 13 +++++++----- tests/actor/test_actor.py | 29 +++++++++++++++++++++++++++ tests/actor/test_failure_policy.py | 12 +++++------ 6 files changed, 56 insertions(+), 26 deletions(-) diff --git a/dapr/actor/runtime/_failure_policy.py b/dapr/actor/runtime/_failure_policy.py index f96d634e4..3b4576ac4 100644 --- a/dapr/actor/runtime/_failure_policy.py +++ b/dapr/actor/runtime/_failure_policy.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ -Copyright 2024 The Dapr Authors +Copyright 2026 The Dapr Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -53,9 +53,7 @@ def __init__( if drop and (interval is not None or max_retries is not None): raise ValueError('drop policy cannot be combined with interval or max_retries') if not drop and interval is None and max_retries is None: - raise ValueError( - 'specify either drop=True or at least one of interval or max_retries' - ) + raise ValueError('specify either drop=True or at least one of interval or max_retries') self._drop = drop self._interval = interval self._max_retries = max_retries diff --git a/dapr/actor/runtime/_reminder_data.py b/dapr/actor/runtime/_reminder_data.py index d8d9deb9e..8f375b1c3 100644 --- a/dapr/actor/runtime/_reminder_data.py +++ b/dapr/actor/runtime/_reminder_data.py @@ -99,9 +99,7 @@ def failure_policy(self) -> Optional[ActorReminderFailurePolicy]: def as_dict(self) -> Dict[str, Any]: """Gets :class:`ActorReminderData` as a dict object.""" - encoded_state = None - if self._state is not None: - encoded_state = base64.b64encode(self._state) + encoded_state = base64.b64encode(self._state) reminderDict: Dict[str, Any] = { 'reminderName': self._reminder_name, 'dueTime': self._due_time, @@ -121,8 +119,9 @@ def as_dict(self) -> Dict[str, Any]: def from_dict(cls, reminder_name: str, obj: Dict[str, Any]) -> 'ActorReminderData': """Creates :class:`ActorReminderData` object from dict object.""" b64encoded_state = obj.get('data') - state_bytes = None - if b64encoded_state is not None and len(b64encoded_state) > 0: + if b64encoded_state is None or len(b64encoded_state) == 0: + state_bytes = b'' + else: state_bytes = base64.b64decode(b64encoded_state) if 'ttl' in obj: return ActorReminderData( diff --git a/dapr/actor/runtime/actor.py b/dapr/actor/runtime/actor.py index 711362d62..3a917e56f 100644 --- a/dapr/actor/runtime/actor.py +++ b/dapr/actor/runtime/actor.py @@ -131,11 +131,14 @@ async def register_reminder( state (bytes): the user state passed to the reminder invocation. due_time (datetime.timedelta): the amount of time to delay before invoking the reminder for the first time. - period (datetime.timedelta): the time interval between reminder invocations after - the first invocation. - ttl (datetime.timedelta): the time interval before the reminder stops firing. - failure_policy (ActorReminderFailurePolicy): the optional policy for handling reminder - failures. If not set, the Dapr runtime default applies (3 retries per tick). + period (Optional[datetime.timedelta]): the optional time interval between reminder + invocations after the first invocation. If not set, the reminder is triggered + only once at ``due_time``. + ttl (Optional[datetime.timedelta]): the optional time interval before the reminder + stops firing. If not set, the Dapr runtime default behavior applies. + failure_policy (Optional[ActorReminderFailurePolicy]): the optional policy for + handling reminder failures. If not set, the Dapr runtime default applies + (3 retries per tick). """ reminder = ActorReminderData(name, state, due_time, period, ttl, failure_policy) req_body = self._runtime_ctx.message_serializer.serialize(reminder.as_dict()) diff --git a/dapr/actor/runtime/mock_actor.py b/dapr/actor/runtime/mock_actor.py index 1cad64662..c26a60444 100644 --- a/dapr/actor/runtime/mock_actor.py +++ b/dapr/actor/runtime/mock_actor.py @@ -98,11 +98,14 @@ async def register_reminder( state (bytes): the user state passed to the reminder invocation. due_time (datetime.timedelta): the amount of time to delay before invoking the reminder for the first time. - period (datetime.timedelta): the time interval between reminder invocations after - the first invocation. - ttl (datetime.timedelta): the time interval before the reminder stops firing. - failure_policy (ActorReminderFailurePolicy): the optional policy for handling reminder - failures. If not set, the Dapr runtime default applies (3 retries per tick). + period (Optional[datetime.timedelta]): the optional time interval between reminder + invocations after the first invocation. If None, the reminder uses the Dapr + runtime behavior for one-off or non-periodic reminders. + ttl (Optional[datetime.timedelta]): the optional time interval before the reminder + stops firing. If None, no explicit TTL is set. + failure_policy (Optional[ActorReminderFailurePolicy]): the optional policy for + handling reminder failures. If not set, the Dapr runtime default applies + (3 retries per tick). """ reminder = ActorReminderData(name, state, due_time, period, ttl, failure_policy) self._state_manager._mock_reminders[name] = reminder # type: ignore diff --git a/tests/actor/test_actor.py b/tests/actor/test_actor.py index 7a7bee2d2..3a60bea7f 100644 --- a/tests/actor/test_actor.py +++ b/tests/actor/test_actor.py @@ -18,6 +18,7 @@ from unittest import mock from dapr.actor.id import ActorId +from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime._type_information import ActorTypeInformation from dapr.actor.runtime.config import ActorRuntimeConfig from dapr.actor.runtime.context import ActorRuntimeContext @@ -151,6 +152,34 @@ def test_register_reminder(self): 'FakeSimpleReminderActor', 'test_id', 'test_reminder' ) + @mock.patch( + 'tests.actor.fake_client.FakeDaprActorClient.register_reminder', + new=_async_mock(return_value=b'"ok"'), + ) + def test_register_reminder_with_failure_policy(self): + test_actor_id = ActorId('test_id') + test_type_info = ActorTypeInformation.create(FakeSimpleReminderActor) + test_client = FakeDaprActorClient + ctx = ActorRuntimeContext(test_type_info, self._serializer, self._serializer, test_client) + test_actor = FakeSimpleReminderActor(ctx, test_actor_id) + + _run( + test_actor.register_reminder( + 'test_reminder', + b'reminder_message', + timedelta(seconds=1), + timedelta(seconds=1), + failure_policy=ActorReminderFailurePolicy.drop_policy(), + ) + ) + test_client.register_reminder.mock.assert_called_once() + test_client.register_reminder.mock.assert_called_with( + 'FakeSimpleReminderActor', + 'test_id', + 'test_reminder', + b'{"reminderName":"test_reminder","dueTime":"0h0m1s0ms0\\u03bcs","period":"0h0m1s0ms0\\u03bcs","data":"cmVtaW5kZXJfbWVzc2FnZQ==","failurePolicy":{"drop":{}}}', # noqa E501 + ) + @mock.patch( 'tests.actor.fake_client.FakeDaprActorClient.register_timer', new=_async_mock(return_value=b'"ok"'), diff --git a/tests/actor/test_failure_policy.py b/tests/actor/test_failure_policy.py index 65713c922..a162b15d2 100644 --- a/tests/actor/test_failure_policy.py +++ b/tests/actor/test_failure_policy.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ -Copyright 2024 The Dapr Authors +Copyright 2026 The Dapr Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at @@ -35,18 +35,16 @@ def test_drop_policy_as_dict(self): # --- constant_policy --- def test_constant_policy_interval_and_max_retries(self): - p = ActorReminderFailurePolicy.constant_policy( - interval=timedelta(seconds=5), max_retries=3 - ) + p = ActorReminderFailurePolicy.constant_policy(interval=timedelta(seconds=5), max_retries=3) self.assertFalse(p.drop) self.assertEqual(timedelta(seconds=5), p.interval) self.assertEqual(3, p.max_retries) def test_constant_policy_as_dict_full(self): - p = ActorReminderFailurePolicy.constant_policy( - interval=timedelta(seconds=5), max_retries=3 + p = ActorReminderFailurePolicy.constant_policy(interval=timedelta(seconds=5), max_retries=3) + self.assertEqual( + {'constant': {'interval': timedelta(seconds=5), 'maxRetries': 3}}, p.as_dict() ) - self.assertEqual({'constant': {'interval': timedelta(seconds=5), 'maxRetries': 3}}, p.as_dict()) def test_constant_policy_interval_only(self): p = ActorReminderFailurePolicy.constant_policy(interval=timedelta(seconds=10)) From 011799fdee12dd214765baa1ae141c6f0834746b Mon Sep 17 00:00:00 2001 From: Ninad Kale Date: Fri, 13 Mar 2026 19:42:21 +0530 Subject: [PATCH 4/4] fix: copilot feedback Signed-off-by: Ninad Kale --- dapr/actor/__init__.py | 2 +- dapr/actor/runtime/_failure_policy.py | 92 +--------------------- dapr/actor/runtime/_reminder_data.py | 2 +- dapr/actor/runtime/actor.py | 6 +- dapr/actor/runtime/failure_policy.py | 105 ++++++++++++++++++++++++++ dapr/actor/runtime/mock_actor.py | 2 +- tests/actor/test_actor.py | 33 +++++++- tests/actor/test_failure_policy.py | 2 +- tests/actor/test_reminder_data.py | 2 +- 9 files changed, 148 insertions(+), 98 deletions(-) create mode 100644 dapr/actor/runtime/failure_policy.py diff --git a/dapr/actor/__init__.py b/dapr/actor/__init__.py index 90014c7d5..7c1e1c1c2 100644 --- a/dapr/actor/__init__.py +++ b/dapr/actor/__init__.py @@ -16,8 +16,8 @@ from dapr.actor.actor_interface import ActorInterface, actormethod from dapr.actor.client.proxy import ActorProxy, ActorProxyFactory from dapr.actor.id import ActorId -from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime.actor import Actor +from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime.remindable import Remindable from dapr.actor.runtime.runtime import ActorRuntime diff --git a/dapr/actor/runtime/_failure_policy.py b/dapr/actor/runtime/_failure_policy.py index 3b4576ac4..e650ca469 100644 --- a/dapr/actor/runtime/_failure_policy.py +++ b/dapr/actor/runtime/_failure_policy.py @@ -13,93 +13,7 @@ limitations under the License. """ -from datetime import timedelta -from typing import Any, Dict, Optional +# Backward-compatible shim — import from the public module instead. +from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy - -class ActorReminderFailurePolicy: - """Defines what happens when an actor reminder fails to trigger. - - Use :meth:`drop_policy` to discard failed ticks without retrying, or - :meth:`constant_policy` to retry at a fixed interval. - - Attributes: - drop: whether this is a drop (no-retry) policy. - interval: the retry interval for a constant policy. - max_retries: the maximum number of retries for a constant policy. - """ - - def __init__( - self, - *, - drop: bool = False, - interval: Optional[timedelta] = None, - max_retries: Optional[int] = None, - ): - """Creates a new :class:`ActorReminderFailurePolicy` instance. - - Args: - drop (bool): if True, creates a drop policy that discards the reminder - tick on failure without retrying. Cannot be combined with interval - or max_retries. - interval (datetime.timedelta): the retry interval for a constant policy. - max_retries (int): the maximum number of retries for a constant policy. - If not set, retries indefinitely. - - Raises: - ValueError: if drop is combined with interval or max_retries, or if - neither drop=True nor at least one of interval/max_retries is provided. - """ - if drop and (interval is not None or max_retries is not None): - raise ValueError('drop policy cannot be combined with interval or max_retries') - if not drop and interval is None and max_retries is None: - raise ValueError('specify either drop=True or at least one of interval or max_retries') - self._drop = drop - self._interval = interval - self._max_retries = max_retries - - @classmethod - def drop_policy(cls) -> 'ActorReminderFailurePolicy': - """Returns a policy that drops the reminder tick on failure (no retry).""" - return cls(drop=True) - - @classmethod - def constant_policy( - cls, - interval: Optional[timedelta] = None, - max_retries: Optional[int] = None, - ) -> 'ActorReminderFailurePolicy': - """Returns a policy that retries at a constant interval on failure. - - Args: - interval (datetime.timedelta): the time between retry attempts. - max_retries (int): the maximum number of retry attempts. If not set, - retries indefinitely. - """ - return cls(interval=interval, max_retries=max_retries) - - @property - def drop(self) -> bool: - """Returns True if this is a drop policy.""" - return self._drop - - @property - def interval(self) -> Optional[timedelta]: - """Returns the retry interval for a constant policy.""" - return self._interval - - @property - def max_retries(self) -> Optional[int]: - """Returns the maximum retries for a constant policy.""" - return self._max_retries - - def as_dict(self) -> Dict[str, Any]: - """Gets :class:`ActorReminderFailurePolicy` as a dict object.""" - if self._drop: - return {'drop': {}} - d: Dict[str, Any] = {} - if self._interval is not None: - d['interval'] = self._interval - if self._max_retries is not None: - d['maxRetries'] = self._max_retries - return {'constant': d} +__all__ = ['ActorReminderFailurePolicy'] diff --git a/dapr/actor/runtime/_reminder_data.py b/dapr/actor/runtime/_reminder_data.py index 8f375b1c3..2eec70e5a 100644 --- a/dapr/actor/runtime/_reminder_data.py +++ b/dapr/actor/runtime/_reminder_data.py @@ -17,7 +17,7 @@ from datetime import timedelta from typing import Any, Dict, Optional -from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy +from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy class ActorReminderData: diff --git a/dapr/actor/runtime/actor.py b/dapr/actor/runtime/actor.py index 3a917e56f..a666b06e1 100644 --- a/dapr/actor/runtime/actor.py +++ b/dapr/actor/runtime/actor.py @@ -18,11 +18,11 @@ from typing import Any, Optional from dapr.actor.id import ActorId -from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime._method_context import ActorMethodContext from dapr.actor.runtime._reminder_data import ActorReminderData from dapr.actor.runtime._timer_data import TIMER_CALLBACK, ActorTimerData from dapr.actor.runtime.context import ActorRuntimeContext +from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime.state_manager import ActorStateManager @@ -132,8 +132,8 @@ async def register_reminder( due_time (datetime.timedelta): the amount of time to delay before invoking the reminder for the first time. period (Optional[datetime.timedelta]): the optional time interval between reminder - invocations after the first invocation. If not set, the reminder is triggered - only once at ``due_time``. + invocations after the first invocation. If not set, the Dapr runtime behavior + for one-off or non-periodic reminders applies. ttl (Optional[datetime.timedelta]): the optional time interval before the reminder stops firing. If not set, the Dapr runtime default behavior applies. failure_policy (Optional[ActorReminderFailurePolicy]): the optional policy for diff --git a/dapr/actor/runtime/failure_policy.py b/dapr/actor/runtime/failure_policy.py new file mode 100644 index 000000000..3b4576ac4 --- /dev/null +++ b/dapr/actor/runtime/failure_policy.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from datetime import timedelta +from typing import Any, Dict, Optional + + +class ActorReminderFailurePolicy: + """Defines what happens when an actor reminder fails to trigger. + + Use :meth:`drop_policy` to discard failed ticks without retrying, or + :meth:`constant_policy` to retry at a fixed interval. + + Attributes: + drop: whether this is a drop (no-retry) policy. + interval: the retry interval for a constant policy. + max_retries: the maximum number of retries for a constant policy. + """ + + def __init__( + self, + *, + drop: bool = False, + interval: Optional[timedelta] = None, + max_retries: Optional[int] = None, + ): + """Creates a new :class:`ActorReminderFailurePolicy` instance. + + Args: + drop (bool): if True, creates a drop policy that discards the reminder + tick on failure without retrying. Cannot be combined with interval + or max_retries. + interval (datetime.timedelta): the retry interval for a constant policy. + max_retries (int): the maximum number of retries for a constant policy. + If not set, retries indefinitely. + + Raises: + ValueError: if drop is combined with interval or max_retries, or if + neither drop=True nor at least one of interval/max_retries is provided. + """ + if drop and (interval is not None or max_retries is not None): + raise ValueError('drop policy cannot be combined with interval or max_retries') + if not drop and interval is None and max_retries is None: + raise ValueError('specify either drop=True or at least one of interval or max_retries') + self._drop = drop + self._interval = interval + self._max_retries = max_retries + + @classmethod + def drop_policy(cls) -> 'ActorReminderFailurePolicy': + """Returns a policy that drops the reminder tick on failure (no retry).""" + return cls(drop=True) + + @classmethod + def constant_policy( + cls, + interval: Optional[timedelta] = None, + max_retries: Optional[int] = None, + ) -> 'ActorReminderFailurePolicy': + """Returns a policy that retries at a constant interval on failure. + + Args: + interval (datetime.timedelta): the time between retry attempts. + max_retries (int): the maximum number of retry attempts. If not set, + retries indefinitely. + """ + return cls(interval=interval, max_retries=max_retries) + + @property + def drop(self) -> bool: + """Returns True if this is a drop policy.""" + return self._drop + + @property + def interval(self) -> Optional[timedelta]: + """Returns the retry interval for a constant policy.""" + return self._interval + + @property + def max_retries(self) -> Optional[int]: + """Returns the maximum retries for a constant policy.""" + return self._max_retries + + def as_dict(self) -> Dict[str, Any]: + """Gets :class:`ActorReminderFailurePolicy` as a dict object.""" + if self._drop: + return {'drop': {}} + d: Dict[str, Any] = {} + if self._interval is not None: + d['interval'] = self._interval + if self._max_retries is not None: + d['maxRetries'] = self._max_retries + return {'constant': d} diff --git a/dapr/actor/runtime/mock_actor.py b/dapr/actor/runtime/mock_actor.py index c26a60444..28c48b858 100644 --- a/dapr/actor/runtime/mock_actor.py +++ b/dapr/actor/runtime/mock_actor.py @@ -17,10 +17,10 @@ from typing import Any, Optional, TypeVar from dapr.actor.id import ActorId -from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime._reminder_data import ActorReminderData from dapr.actor.runtime._timer_data import TIMER_CALLBACK, ActorTimerData from dapr.actor.runtime.actor import Actor +from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime.mock_state_manager import MockStateManager diff --git a/tests/actor/test_actor.py b/tests/actor/test_actor.py index 3a60bea7f..1110fd146 100644 --- a/tests/actor/test_actor.py +++ b/tests/actor/test_actor.py @@ -18,10 +18,10 @@ from unittest import mock from dapr.actor.id import ActorId -from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime._type_information import ActorTypeInformation from dapr.actor.runtime.config import ActorRuntimeConfig from dapr.actor.runtime.context import ActorRuntimeContext +from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime.runtime import ActorRuntime from dapr.conf import settings from dapr.serializers import DefaultJSONSerializer @@ -180,6 +180,37 @@ def test_register_reminder_with_failure_policy(self): b'{"reminderName":"test_reminder","dueTime":"0h0m1s0ms0\\u03bcs","period":"0h0m1s0ms0\\u03bcs","data":"cmVtaW5kZXJfbWVzc2FnZQ==","failurePolicy":{"drop":{}}}', # noqa E501 ) + @mock.patch( + 'tests.actor.fake_client.FakeDaprActorClient.register_reminder', + new=_async_mock(return_value=b'"ok"'), + ) + def test_register_reminder_with_constant_failure_policy(self): + test_actor_id = ActorId('test_id') + test_type_info = ActorTypeInformation.create(FakeSimpleReminderActor) + test_client = FakeDaprActorClient + ctx = ActorRuntimeContext(test_type_info, self._serializer, self._serializer, test_client) + test_actor = FakeSimpleReminderActor(ctx, test_actor_id) + + _run( + test_actor.register_reminder( + 'test_reminder', + b'reminder_message', + timedelta(seconds=1), + timedelta(seconds=1), + failure_policy=ActorReminderFailurePolicy.constant_policy( + interval=timedelta(seconds=2), + max_retries=5, + ), + ) + ) + test_client.register_reminder.mock.assert_called_once() + test_client.register_reminder.mock.assert_called_with( + 'FakeSimpleReminderActor', + 'test_id', + 'test_reminder', + b'{"reminderName":"test_reminder","dueTime":"0h0m1s0ms0\\u03bcs","period":"0h0m1s0ms0\\u03bcs","data":"cmVtaW5kZXJfbWVzc2FnZQ==","failurePolicy":{"constant":{"interval":"0h0m2s0ms0\\u03bcs","maxRetries":5}}}', # noqa E501 + ) + @mock.patch( 'tests.actor.fake_client.FakeDaprActorClient.register_timer', new=_async_mock(return_value=b'"ok"'), diff --git a/tests/actor/test_failure_policy.py b/tests/actor/test_failure_policy.py index a162b15d2..12db1c4b8 100644 --- a/tests/actor/test_failure_policy.py +++ b/tests/actor/test_failure_policy.py @@ -16,7 +16,7 @@ import unittest from datetime import timedelta -from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy +from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy class ActorReminderFailurePolicyTests(unittest.TestCase): diff --git a/tests/actor/test_reminder_data.py b/tests/actor/test_reminder_data.py index 3f5ac7144..9da42795b 100644 --- a/tests/actor/test_reminder_data.py +++ b/tests/actor/test_reminder_data.py @@ -16,8 +16,8 @@ import unittest from datetime import timedelta -from dapr.actor.runtime._failure_policy import ActorReminderFailurePolicy from dapr.actor.runtime._reminder_data import ActorReminderData +from dapr.actor.runtime.failure_policy import ActorReminderFailurePolicy class ActorReminderTests(unittest.TestCase):