Skip to content

Commit

Permalink
Backoff parameter
Browse files Browse the repository at this point in the history
  • Loading branch information
amitfin committed Jul 27, 2024
1 parent ed822b0 commit d822446
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 16 deletions.
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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`.

Expand Down
34 changes: 27 additions & 7 deletions custom_components/retry/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Retry integration."""

from __future__ import annotations

import asyncio
Expand Down Expand Up @@ -46,6 +47,7 @@

from .const import (
ACTIONS_SERVICE,
ATTR_BACKOFF,
ATTR_EXPECTED_STATE,
ATTR_ON_ERROR,
ATTR_RETRY_ID,
Expand All @@ -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

Expand Down Expand Up @@ -90,13 +92,22 @@ 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)))


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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions custom_components/retry/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Constants for the retry integration."""

import logging
from typing import Final

Expand All @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions custom_components/retry/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions custom_components/retry/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down
8 changes: 8 additions & 0 deletions custom_components/retry/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down
8 changes: 8 additions & 0 deletions custom_components/retry/translations/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
61 changes: 56 additions & 5 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@

from custom_components.retry.const import (
ACTIONS_SERVICE,
ATTR_BACKOFF,
ATTR_EXPECTED_STATE,
ATTR_ON_ERROR,
ATTR_RETRY_ID,
Expand Down Expand Up @@ -121,17 +122,19 @@ 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()


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(
Expand Down Expand Up @@ -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}()"
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit d822446

Please sign in to comment.