From d822446dca90ac43b376d640f94f2962d0128b6b Mon Sep 17 00:00:00 2001 From: Amit Finkelstein Date: Sat, 27 Jul 2024 22:48:20 +0000 Subject: [PATCH] Backoff parameter --- README.md | 16 +++-- custom_components/retry/__init__.py | 34 ++++++++--- custom_components/retry/const.py | 2 + custom_components/retry/services.yaml | 10 ++++ custom_components/retry/strings.json | 8 +++ custom_components/retry/translations/en.json | 8 +++ custom_components/retry/translations/he.json | 8 +++ tests/test_init.py | 61 ++++++++++++++++++-- 8 files changed, 131 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4fb06ce..67694c2 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,15 @@ target: entity_id: light.kitchen ``` -The service implements an exponential backoff mechanism. These are the delay times (in seconds) of the first 7 attempts: [0, 1, 2, 4, 8, 16, 32] (each delay is twice than the previous one). The following are the seconds offsets from the initial call [0, 1, 3, 7, 15, 31, 63]. +#### `backoff` parameter (optional) + +The amount of seconds to wait between retries. It's expressed in a special template format with square brackets `"[[ ... ]]"` instead of curly brackets `"{{ ... }}"`. This is needed to prevent from rendering the expression in advance. `attempt` is provided as a variable, holding a zero-based counter. `attempt` is zero for 1st expression evaluation, and increasing by one for subsequence evaluations. Note that there is no delay for the initial attempt, so the list of delays always starts with a zero. + +The default value is `"[[ 2 ** attempt ]]"` which is an exponential backoff. These are the delay times of the first 7 attempts: [0, 1, 2, 4, 8, 16, 32] (each delay is twice than the previous one). The following are the second offsets from the initial call [0, 1, 3, 7, 15, 31, 63]. + +A different strategy can be a constant wait time which can be expressed as a simple non-template string. For example, these are the delay times of the first 7 attempts when using `"10"`: [0, 10, 10, 10, 10, 10, 10]. The following are the second offsets from the initial call [0, 10, 20, 30, 40, 50, 60]. + +Another example is `"[[ 10 * 2 ** attempt ]]"` which is a slower exponential backoff. These are the delay times of the first 7 attempts: [0, 10, 20, 40, 80, 160, 320]. The following are the second offsets from the initial call [0, 10, 30, 70, 150, 310, 630]. #### `expected_state` parameter (optional) @@ -127,7 +135,7 @@ target: #### `on_error` parameter (optional) -A sequence of actions to execute if all retries fail. +A sequence of actions to execute if all retries fail. Here is an automation rule example with a self remediation logic: @@ -162,9 +170,9 @@ Note that each entity is running individually when the inner service call has a #### `retry_id` parameter (optional) -A service call cancels a previous running call with the same retry ID. This parameter can be used to set the retry ID explicitly but it should be rarely used, if at all. The default value of `retry_id` is the `entity_id` of the inner service call. For inner service calls with no `entity_id`, the default value of `retry_id` is the service name. +A service call cancels a previous running call with the same retry ID. This parameter can be used to set the retry ID explicitly but it should be rarely used, if at all. The default value of `retry_id` is the `entity_id` of the inner service call. For inner service calls with no `entity_id`, the default value of `retry_id` is the service name. -An example of the cancellation scenario might be when turning off a light while the turn on retry loop of the same light is still running due to failures or light's transition time. The turn on retry loop will be getting canceled by the turn off call since both share the same `retry_id` by default (the entity ID). +An example of the cancellation scenario might be when turning off a light while the turn on retry loop of the same light is still running due to failures or light's transition time. The turn on retry loop will be getting canceled by the turn off call since both share the same `retry_id` by default (the entity ID). Note that each entity is running individually when the inner service call has a list of entities. Therefore, they have a different default `retry_id`. However, an explicit `retry_id` is shared for all entities of the same retry call. Nevertheless, retry loops created by the same service call (`retry.call` or `retry.actions`) are not canceling each other even when they share the same `retry_id`. diff --git a/custom_components/retry/__init__.py b/custom_components/retry/__init__.py index f0b46ca..30bcaeb 100644 --- a/custom_components/retry/__init__.py +++ b/custom_components/retry/__init__.py @@ -1,4 +1,5 @@ """Retry integration.""" + from __future__ import annotations import asyncio @@ -46,6 +47,7 @@ from .const import ( ACTIONS_SERVICE, + ATTR_BACKOFF, ATTR_EXPECTED_STATE, ATTR_ON_ERROR, ATTR_RETRY_ID, @@ -60,7 +62,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -EXPONENTIAL_BACKOFF_BASE = 2 +DEFAULT_BACKOFF = "[[ 2 ** attempt ]]" DEFAULT_RETRIES = 7 DEFAULT_STATE_GRACE = 0.2 @@ -90,6 +92,14 @@ def _fix_template_tokens(value: str) -> str: return value +DEFAULT_BACKOFF_FIXED = _fix_template_tokens(DEFAULT_BACKOFF) + + +def _backoff_parameter(value: any | None) -> Template: + """Convert backoff parameter to template.""" + return cv.template(_fix_template_tokens(cv.string(value))) + + def _validation_parameter(value: any | None) -> Template: """Convert validation parameter to template.""" return cv.dynamic_template(_fix_template_tokens(cv.string(value))) @@ -97,6 +107,7 @@ def _validation_parameter(value: any | None) -> Template: SERVICE_SCHEMA_BASE_FIELDS = { vol.Required(ATTR_RETRIES, default=DEFAULT_RETRIES): cv.positive_int, + vol.Required(ATTR_BACKOFF, default=DEFAULT_BACKOFF): _backoff_parameter, vol.Optional(ATTR_EXPECTED_STATE): vol.All(cv.ensure_list, [_template_parameter]), vol.Optional(ATTR_VALIDATION): _validation_parameter, vol.Required(ATTR_STATE_GRACE, default=DEFAULT_STATE_GRACE): cv.positive_float, @@ -249,7 +260,6 @@ def __init__( self._entity_id = entity_id self._context = context self._attempt = 1 - self._delay = 1 self._retry_id = params.retry_data.get(ATTR_RETRY_ID) if ATTR_RETRY_ID not in params.retry_data: if self._entity_id: @@ -312,6 +322,10 @@ def _service_call_str(self) -> str: f"({', '.join([f'{key}={value}' for key, value in self._inner_data.items()])})" ) retry_params = [] + if ( + backoff := self._params.retry_data[ATTR_BACKOFF].template + ) != DEFAULT_BACKOFF_FIXED: + retry_params.append(f'{ATTR_BACKOFF}="{backoff}"') if ( expected_state := self._params.retry_data.get(ATTR_EXPECTED_STATE) ) is not None: @@ -438,8 +452,13 @@ async def async_retry(self, *_) -> None: context=Context(self._context.user_id, self._context.id), ) return - next_retry = dt_util.now() + datetime.timedelta(seconds=self._delay) - self._delay *= EXPONENTIAL_BACKOFF_BASE + next_retry = dt_util.now() + datetime.timedelta( + seconds=float( + self._params.retry_data[ATTR_BACKOFF].async_render( + variables={"attempt": self._attempt - 1} + ) + ) + ) self._attempt += 1 event.async_track_point_in_time(self._hass, self.async_retry, next_retry) @@ -510,9 +529,10 @@ async def async_actions(service_call: ServiceCall) -> None: for key in service_call.data if key in SERVICE_SCHEMA_BASE_FIELDS } - if ATTR_VALIDATION in retry_params: - # Revert it back to string so it won't get rendered in advance. - retry_params[ATTR_VALIDATION] = retry_params[ATTR_VALIDATION].template + for key in [ATTR_BACKOFF, ATTR_VALIDATION]: + if key in retry_params: + # Revert it back to string so it won't get rendered in advance. + retry_params[key] = retry_params[key].template _wrap_service_calls(hass, sequence, retry_params) await script.Script(hass, sequence, ACTIONS_SERVICE, DOMAIN).async_run( context=Context(service_call.context.user_id, service_call.context.id) diff --git a/custom_components/retry/const.py b/custom_components/retry/const.py index cd4ff52..e4c6b2e 100644 --- a/custom_components/retry/const.py +++ b/custom_components/retry/const.py @@ -1,4 +1,5 @@ """Constants for the retry integration.""" + import logging from typing import Final @@ -8,6 +9,7 @@ ACTIONS_SERVICE: Final = "actions" CALL_SERVICE: Final = "call" CONF_DISABLE_REPAIR = "disable_repair" +ATTR_BACKOFF: Final = "backoff" ATTR_EXPECTED_STATE: Final = "expected_state" ATTR_ON_ERROR: Final = "on_error" ATTR_RETRY_ID: Final = "retry_id" diff --git a/custom_components/retry/services.yaml b/custom_components/retry/services.yaml index 3db7ca1..4b1a353 100755 --- a/custom_components/retry/services.yaml +++ b/custom_components/retry/services.yaml @@ -14,6 +14,11 @@ call: max: 100 unit_of_measurement: retries mode: box + backoff: + advanced: true + example: "[[ 10 * 2 ** attempt ]]" + selector: + text: expected_state: advanced: true example: "on" @@ -67,6 +72,11 @@ actions: max: 100 unit_of_measurement: retries mode: box + backoff: + advanced: true + example: "[[ 10 * 2 ** attempt ]]" + selector: + text: expected_state: advanced: true example: "on" diff --git a/custom_components/retry/strings.json b/custom_components/retry/strings.json index 4849f6c..927eaf2 100644 --- a/custom_components/retry/strings.json +++ b/custom_components/retry/strings.json @@ -38,6 +38,10 @@ "name": "Retries", "description": "Max amount of calls (default is 7)." }, + "backoff": { + "name": "Backoff", + "description": "Special template with square brackets instead of curly brackets for the amount of seconds to wait between retries. Default is '[[ 2 ** attempt ]]'." + }, "expected_state": { "name": "Expected State", "description": "The expected state of the entities after the service call." @@ -72,6 +76,10 @@ "name": "Retries", "description": "Max amount of calls (default is 7)." }, + "backoff": { + "name": "Backoff", + "description": "Special template with square brackets instead of curly brackets for the amount of seconds to wait between retries. Default is '[[ 2 ** attempt ]]'." + }, "expected_state": { "name": "Expected State", "description": "The expected state of the entities after any service call." diff --git a/custom_components/retry/translations/en.json b/custom_components/retry/translations/en.json index 715ffbf..46b8df4 100644 --- a/custom_components/retry/translations/en.json +++ b/custom_components/retry/translations/en.json @@ -38,6 +38,10 @@ "name": "Retries", "description": "Max amount of calls (default is 7)." }, + "backoff": { + "name": "Backoff", + "description": "Special template with square brackets instead of curly brackets for the amount of seconds to wait between retries. Default is '[[ 2 ** attempt ]]'." + }, "expected_state": { "name": "Expected State", "description": "The expected state of the entities after the service call." @@ -72,6 +76,10 @@ "name": "Retries", "description": "Max amount of calls (default is 7)." }, + "backoff": { + "name": "Backoff", + "description": "Special template with square brackets instead of curly brackets for the amount of seconds to wait between retries. Default is '[[ 2 ** attempt ]]'." + }, "expected_state": { "name": "Expected State", "description": "The expected state of the entities after any service call." diff --git a/custom_components/retry/translations/he.json b/custom_components/retry/translations/he.json index aa053d7..4c0ab01 100644 --- a/custom_components/retry/translations/he.json +++ b/custom_components/retry/translations/he.json @@ -38,6 +38,10 @@ "name": "\u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea", "description": "\u05db\u05de\u05d5\u05ea\u0020\u05d4\u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea\u0020\u05d4\u05de\u05d9\u05e8\u05d1\u05d9\u05ea\u0020\u0028\u05d1\u05e8\u05d9\u05e8\u05ea\u0020\u05d4\u05de\u05d7\u05d3\u05dc\u0020\u05d4\u05d9\u05d0\u0020\u0037\u0029\u002e" }, + "backoff": { + "name": "\u05e0\u05e1\u05d9\u05d2\u05d4", + "description": "\u05ea\u05d1\u05e0\u05d9\u05ea\u0020\u05de\u05d9\u05d5\u05d7\u05d3\u05ea\u0020\u05e2\u05dd\u0020\u05e1\u05d5\u05d2\u05e8\u05d9\u05d9\u05dd\u0020\u05de\u05e8\u05d5\u05d1\u05e2\u05d9\u05dd\u0020\u05d1\u05de\u05e7\u05d5\u05dd\u0020\u05de\u05e1\u05d5\u05dc\u05e1\u05dc\u05d9\u05dd\u0020\u05dc\u05d4\u05d2\u05d3\u05e8\u05ea\u0020\u05de\u05e1\u05e4\u05e8\u0020\u05e9\u05e0\u05d9\u05d5\u05ea\u0020\u05d4\u05e9\u05d4\u05d9\u05d9\u05d4\u0020\u05d1\u05d9\u05df\u0020\u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea\u002e" + }, "expected_state": { "name": "\u05d4\u05de\u05e6\u05d1\u0020\u05d4\u05de\u05e6\u05d5\u05e4\u05d4", "description": "\u05d4\u05de\u05e6\u05d1\u0020\u05d4\u05de\u05e6\u05d5\u05e4\u05d4\u0020\u05e9\u05dc\u0020\u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea\u0020\u05dc\u05d0\u05d7\u05e8\u0020\u05e7\u05e8\u05d9\u05d0\u05ea\u0020\u05d4\u05e9\u05d9\u05e8\u05d5\u05ea\u002e" @@ -72,6 +76,10 @@ "name": "\u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea", "description": "\u05db\u05de\u05d5\u05ea\u0020\u05d4\u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea\u0020\u05d4\u05de\u05d9\u05e8\u05d1\u05d9\u05ea\u0020\u0028\u05d1\u05e8\u05d9\u05e8\u05ea\u0020\u05d4\u05de\u05d7\u05d3\u05dc\u0020\u05d4\u05d9\u05d0\u0020\u0037\u0029\u002e" }, + "backoff": { + "name": "\u05e0\u05e1\u05d9\u05d2\u05d4", + "description": "\u05ea\u05d1\u05e0\u05d9\u05ea\u0020\u05de\u05d9\u05d5\u05d7\u05d3\u05ea\u0020\u05e2\u05dd\u0020\u05e1\u05d5\u05d2\u05e8\u05d9\u05d9\u05dd\u0020\u05de\u05e8\u05d5\u05d1\u05e2\u05d9\u05dd\u0020\u05d1\u05de\u05e7\u05d5\u05dd\u0020\u05de\u05e1\u05d5\u05dc\u05e1\u05dc\u05d9\u05dd\u0020\u05dc\u05d4\u05d2\u05d3\u05e8\u05ea\u0020\u05de\u05e1\u05e4\u05e8\u0020\u05e9\u05e0\u05d9\u05d5\u05ea\u0020\u05d4\u05e9\u05d4\u05d9\u05d9\u05d4\u0020\u05d1\u05d9\u05df\u0020\u05e0\u05d9\u05e1\u05d9\u05d5\u05e0\u05d5\u05ea\u002e" + }, "expected_state": { "name": "\u05d4\u05de\u05e6\u05d1\u0020\u05d4\u05de\u05e6\u05d5\u05e4\u05d4", "description": "\u05d4\u05de\u05e6\u05d1\u0020\u05d4\u05de\u05e6\u05d5\u05e4\u05d4\u0020\u05e9\u05dc\u0020\u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea\u0020\u05dc\u05d0\u05d7\u05e8\u0020\u05db\u05dc\u0020\u05e7\u05e8\u05d9\u05d0\u05ea\u0020\u05e9\u05d9\u05e8\u05d5\u05ea\u002e" diff --git a/tests/test_init.py b/tests/test_init.py index 8518a1e..323ea07 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -53,6 +53,7 @@ from custom_components.retry.const import ( ACTIONS_SERVICE, + ATTR_BACKOFF, ATTR_EXPECTED_STATE, ATTR_ON_ERROR, ATTR_RETRY_ID, @@ -121,9 +122,11 @@ def async_service(service_call: ServiceCall): return calls -async def async_next_hour(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: - """Jump to the next hour and execute all pending timers.""" - freezer.move_to(dt_util.now() + datetime.timedelta(hours=1)) +async def async_next_seconds( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, seconds: float +) -> None: + """Jump to the next "seconds" and execute all pending timers.""" + freezer.move_to(dt_util.now() + datetime.timedelta(seconds=seconds)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -131,7 +134,7 @@ async def async_next_hour(hass: HomeAssistant, freezer: FrozenDateTimeFactory) - async def async_shutdown(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Make sure all pending retries were executed.""" for _ in range(10): - await async_next_hour(hass, freezer) + await async_next_seconds(hass, freezer, 3600) async def async_call( @@ -177,7 +180,7 @@ async def test_failure( for i in range(20): if i < retries: assert len(calls) == (i + 1) - await async_next_hour(hass, freezer) + await async_next_seconds(hass, freezer, 3600) assert len(calls) == retries assert ( f"[Failed]: attempt {retries}/{retries}: {DOMAIN}.{TEST_SERVICE}()" @@ -890,6 +893,54 @@ async def test_actions_propagating_args( assert len(calls) == 4 +@pytest.mark.parametrize( + ["backoff", "backoff_fixed", "delays"], + [ + (None, None, [1, 2, 4, 8, 16, 32]), + ("10", "10", [10] * 6), + ( + "[[ 10 * 2 ** attempt ]]", + "{{ 10 * 2 ** attempt }}", + [10, 20, 40, 80, 160, 320], + ), + ], + ids=["default - exponential backoff", "linear", "slow exponential backoff"], +) +@patch("custom_components.retry.asyncio.sleep") +async def test_actions_backoff( + _: AsyncMock, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + backoff: str | None, + backoff_fixed: str | None, + delays: list[str], +) -> None: + """Test action service backoff parameter.""" + calls = await async_setup(hass) + await hass.services.async_call( + DOMAIN, + ACTIONS_SERVICE, + { + **{CONF_SEQUENCE: BASIC_SEQUENCE_DATA}, + **({ATTR_BACKOFF: backoff} if backoff else {}), + }, + True, + ) + calls.pop() + for i, delay in enumerate(delays): + await async_next_seconds(hass, freezer, delay - 1) + assert len(calls) == i + await async_next_seconds(hass, freezer, 1) + assert len(calls) == i + 1 + await async_shutdown(hass, freezer) + if backoff: + assert ( + f'[Failed]: attempt 7/7: {DOMAIN}.{TEST_SERVICE}()[backoff="{backoff_fixed}"]' + in caplog.text + ) + + async def test_actions_propagating_successful_validation( hass: HomeAssistant, freezer: FrozenDateTimeFactory,