DuckDNS setup backoff (#25899)

This commit is contained in:
Johann Kellerman 2019-08-22 18:19:27 +02:00 committed by GitHub
parent 82b1b10c28
commit 2d432da14c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 153 additions and 31 deletions

View File

@ -1,13 +1,17 @@
"""Integrate with DuckDNS.""" """Integrate with DuckDNS."""
from datetime import timedelta
import logging import logging
from asyncio import iscoroutinefunction
from datetime import timedelta
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.event import async_track_time_interval from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import callback, CALLBACK_TYPE
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -42,25 +46,28 @@ async def async_setup(hass, config):
token = config[DOMAIN][CONF_ACCESS_TOKEN] token = config[DOMAIN][CONF_ACCESS_TOKEN]
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
result = await _update_duckdns(session, domain, token) async def update_domain_interval(_now):
if not result:
return False
async def update_domain_interval(now):
"""Update the DuckDNS entry.""" """Update the DuckDNS entry."""
await _update_duckdns(session, domain, token) return await _update_duckdns(session, domain, token)
intervals = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
async_track_time_interval_backoff(hass, update_domain_interval, intervals)
async def update_domain_service(call): async def update_domain_service(call):
"""Update the DuckDNS entry.""" """Update the DuckDNS entry."""
await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT]) await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT])
async_track_time_interval(hass, update_domain_interval, INTERVAL)
hass.services.async_register( hass.services.async_register(
DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA
) )
return result return True
_SENTINEL = object() _SENTINEL = object()
@ -89,3 +96,37 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False)
return False return False
return True return True
@callback
@bind_hass
def async_track_time_interval_backoff(hass, action, intervals) -> CALLBACK_TYPE:
"""Add a listener that fires repetitively at every timedelta interval."""
if not iscoroutinefunction:
_LOGGER.error("action needs to be a coroutine and return True/False")
return
if not isinstance(intervals, (list, tuple)):
intervals = (intervals,)
remove = None
failed = 0
async def interval_listener(now):
"""Handle elapsed intervals with backoff."""
nonlocal failed, remove
try:
failed += 1
if await action(now):
failed = 0
finally:
delay = intervals[failed] if failed < len(intervals) else intervals[-1]
remove = async_track_point_in_utc_time(hass, interval_listener, now + delay)
hass.async_run_job(interval_listener, dt_util.utcnow())
def remove_listener():
"""Remove interval listener."""
if remove:
remove() # pylint: disable=not-callable
return remove_listener

View File

@ -1,28 +1,29 @@
"""Test the DuckDNS component.""" """Test the DuckDNS component."""
import asyncio
from datetime import timedelta from datetime import timedelta
import logging
import pytest import pytest
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components import duckdns from homeassistant.components import duckdns
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
from homeassistant.components.duckdns import async_track_time_interval_backoff
from tests.common import async_fire_time_changed from tests.common import async_fire_time_changed
DOMAIN = "bla" DOMAIN = "bla"
TOKEN = "abcdefgh" TOKEN = "abcdefgh"
_LOGGER = logging.getLogger(__name__)
INTERVAL = duckdns.INTERVAL
@bind_hass @bind_hass
@asyncio.coroutine async def async_set_txt(hass, txt):
def async_set_txt(hass, txt):
"""Set the txt record. Pass in None to remove it. """Set the txt record. Pass in None to remove it.
This is a legacy helper method. Do not use it for new tests. This is a legacy helper method. Do not use it for new tests.
""" """
yield from hass.services.async_call( await hass.services.async_call(
duckdns.DOMAIN, duckdns.SERVICE_SET_TXT, {duckdns.ATTR_TXT: txt}, blocking=True duckdns.DOMAIN, duckdns.SERVICE_SET_TXT, {duckdns.ATTR_TXT: txt}, blocking=True
) )
@ -41,40 +42,60 @@ def setup_duckdns(hass, aioclient_mock):
) )
@asyncio.coroutine async def test_setup(hass, aioclient_mock):
def test_setup(hass, aioclient_mock):
"""Test setup works if update passes.""" """Test setup works if update passes."""
aioclient_mock.get( aioclient_mock.get(
duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK" duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK"
) )
result = yield from async_setup_component( result = await async_setup_component(
hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}}
) )
await hass.async_block_till_done()
assert result assert result
assert aioclient_mock.call_count == 1 assert aioclient_mock.call_count == 1
async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
yield from hass.async_block_till_done() await hass.async_block_till_done()
assert aioclient_mock.call_count == 2 assert aioclient_mock.call_count == 2
@asyncio.coroutine async def test_setup_backoff(hass, aioclient_mock):
def test_setup_fails_if_update_fails(hass, aioclient_mock):
"""Test setup fails if first update fails.""" """Test setup fails if first update fails."""
aioclient_mock.get( aioclient_mock.get(
duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="KO" duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="KO"
) )
result = yield from async_setup_component( result = await async_setup_component(
hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}}
) )
assert not result assert result
await hass.async_block_till_done()
assert aioclient_mock.call_count == 1 assert aioclient_mock.call_count == 1
# Copy of the DuckDNS intervals from duckdns/__init__.py
intervals = (
INTERVAL,
timedelta(minutes=1),
timedelta(minutes=5),
timedelta(minutes=15),
timedelta(minutes=30),
)
tme = utcnow()
await hass.async_block_till_done()
@asyncio.coroutine _LOGGER.debug("Backoff...")
def test_service_set_txt(hass, aioclient_mock, setup_duckdns): for idx in range(1, len(intervals)):
tme += intervals[idx]
async_fire_time_changed(hass, tme)
await hass.async_block_till_done()
assert aioclient_mock.call_count == idx + 1
async def test_service_set_txt(hass, aioclient_mock, setup_duckdns):
"""Test set txt service call.""" """Test set txt service call."""
# Empty the fixture mock requests # Empty the fixture mock requests
aioclient_mock.clear_requests() aioclient_mock.clear_requests()
@ -86,12 +107,11 @@ def test_service_set_txt(hass, aioclient_mock, setup_duckdns):
) )
assert aioclient_mock.call_count == 0 assert aioclient_mock.call_count == 0
yield from async_set_txt(hass, "some-txt") await async_set_txt(hass, "some-txt")
assert aioclient_mock.call_count == 1 assert aioclient_mock.call_count == 1
@asyncio.coroutine async def test_service_clear_txt(hass, aioclient_mock, setup_duckdns):
def test_service_clear_txt(hass, aioclient_mock, setup_duckdns):
"""Test clear txt service call.""" """Test clear txt service call."""
# Empty the fixture mock requests # Empty the fixture mock requests
aioclient_mock.clear_requests() aioclient_mock.clear_requests()
@ -103,5 +123,66 @@ def test_service_clear_txt(hass, aioclient_mock, setup_duckdns):
) )
assert aioclient_mock.call_count == 0 assert aioclient_mock.call_count == 0
yield from async_set_txt(hass, None) await async_set_txt(hass, None)
assert aioclient_mock.call_count == 1 assert aioclient_mock.call_count == 1
async def test_async_track_time_interval_backoff(hass):
"""Test setup fails if first update fails."""
ret_val = False
call_count = 0
tme = None
async def _return(now):
nonlocal call_count, ret_val, tme
if tme is None:
tme = now
call_count += 1
return ret_val
intervals = (
INTERVAL,
INTERVAL * 2,
INTERVAL * 5,
INTERVAL * 9,
INTERVAL * 10,
INTERVAL * 11,
INTERVAL * 12,
)
async_track_time_interval_backoff(hass, _return, intervals)
await hass.async_block_till_done()
assert call_count == 1
_LOGGER.debug("Backoff...")
for idx in range(1, len(intervals)):
tme += intervals[idx]
async_fire_time_changed(hass, tme)
await hass.async_block_till_done()
assert call_count == idx + 1
_LOGGER.debug("Max backoff reached - intervals[-1]")
for _idx in range(1, 10):
tme += intervals[-1]
async_fire_time_changed(hass, tme)
await hass.async_block_till_done()
assert call_count == idx + 1 + _idx
_LOGGER.debug("Reset backoff")
call_count = 0
ret_val = True
tme += intervals[-1]
async_fire_time_changed(hass, tme)
await hass.async_block_till_done()
assert call_count == 1
_LOGGER.debug("No backoff - intervals[0]")
for _idx in range(2, 10):
tme += intervals[0]
async_fire_time_changed(hass, tme)
await hass.async_block_till_done()
assert call_count == _idx