feat: add retry policy

This commit is contained in:
2026-02-20 02:44:32 +02:00
parent dec025b45a
commit 9ec566f095
11 changed files with 196 additions and 63 deletions

View File

@@ -158,7 +158,9 @@ Reporting to web service related settings. In most cases there is fallback to we
* ``enabled`` - enable reporting to web service, boolean, optional, default ``yes`` for backward compatibility. * ``enabled`` - enable reporting to web service, boolean, optional, default ``yes`` for backward compatibility.
* ``address`` - remote web service address with protocol, string, optional. In case of websocket, the ``http+unix`` scheme and URL encoded address (e.g. ``%2Fvar%2Flib%2Fahriman`` for ``/var/lib/ahriman``) must be used, e.g. ``http+unix://%2Fvar%2Flib%2Fahriman%2Fsocket``. In case if none set, it will be guessed from ``web`` section. * ``address`` - remote web service address with protocol, string, optional. In case of websocket, the ``http+unix`` scheme and URL encoded address (e.g. ``%2Fvar%2Flib%2Fahriman`` for ``/var/lib/ahriman``) must be used, e.g. ``http+unix://%2Fvar%2Flib%2Fahriman%2Fsocket``. In case if none set, it will be guessed from ``web`` section.
* ``max_retries`` - maximum amount of retries of HTTP requests, integer, optional, default ``0``.
* ``password`` - password to authorize in web service in order to update service status, string, required in case if authorization enabled. * ``password`` - password to authorize in web service in order to update service status, string, required in case if authorization enabled.
* ``retry_backoff`` - retry exponential backoff, float, optional, default ``0.0``.
* ``suppress_http_log_errors`` - suppress HTTP log errors, boolean, optional, default ``no``. If set to ``yes``, any HTTP log errors (e.g. if web server is not available, but HTTP logging is enabled) will be suppressed. * ``suppress_http_log_errors`` - suppress HTTP log errors, boolean, optional, default ``no``. If set to ``yes``, any HTTP log errors (e.g. if web server is not available, but HTTP logging is enabled) will be suppressed.
* ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``. * ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``.
* ``username`` - username to authorize in web service in order to update service status, string, required in case if authorization enabled. * ``username`` - username to authorize in web service in order to update service status, string, required in case if authorization enabled.
@@ -367,6 +369,8 @@ Section name must be either ``telegram`` (plus optional architecture name, e.g.
* ``chat_id`` - telegram chat id, either string with ``@`` or integer value, required. * ``chat_id`` - telegram chat id, either string with ``@`` or integer value, required.
* ``homepage`` - link to homepage, string, optional. * ``homepage`` - link to homepage, string, optional.
* ``link_path`` - prefix for HTML links, string, required. * ``link_path`` - prefix for HTML links, string, required.
* ``max_retries`` - maximum amount of retries of HTTP requests, integer, optional, default ``0``.
* ``retry_backoff`` - retry exponential backoff, float, optional, default ``0.0``.
* ``rss_url`` - link to RSS feed, string, optional. * ``rss_url`` - link to RSS feed, string, optional.
* ``template`` - Jinja2 template name, string, required. * ``template`` - Jinja2 template name, string, required.
* ``template_type`` - ``parse_mode`` to be passed to telegram API, one of ``MarkdownV2``, ``HTML``, ``Markdown``, string, optional, default ``HTML``. * ``template_type`` - ``parse_mode`` to be passed to telegram API, one of ``MarkdownV2``, ``HTML``, ``Markdown``, string, optional, default ``HTML``.
@@ -392,6 +396,7 @@ Type will be read from several sources:
This feature requires GitHub key creation (see below). Section name must be either ``github`` (plus optional architecture name, e.g. ``github:x86_64``) or random name with ``type`` set. This feature requires GitHub key creation (see below). Section name must be either ``github`` (plus optional architecture name, e.g. ``github:x86_64``) or random name with ``type`` set.
* ``type`` - type of the upload, string, optional, must be set to ``github`` if exists. * ``type`` - type of the upload, string, optional, must be set to ``github`` if exists.
* ``max_retries`` - maximum amount of retries of HTTP requests, integer, optional, default ``0``.
* ``owner`` - GitHub repository owner, string, required. * ``owner`` - GitHub repository owner, string, required.
* ``password`` - created GitHub API key. In order to create it do the following: * ``password`` - created GitHub API key. In order to create it do the following:
@@ -401,6 +406,7 @@ This feature requires GitHub key creation (see below). Section name must be eith
#. Generate new token. Required scope is ``public_repo`` (or ``repo`` for private repository support). #. Generate new token. Required scope is ``public_repo`` (or ``repo`` for private repository support).
* ``repository`` - GitHub repository name, string, required. Repository must be created before any action and must have active branch (e.g. with readme). * ``repository`` - GitHub repository name, string, required. Repository must be created before any action and must have active branch (e.g. with readme).
* ``retry_backoff`` - retry exponential backoff, float, optional, default ``0.0``.
* ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``. * ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``.
* ``use_full_release_name`` - if set to ``yes``, the release will contain both repository name and architecture, and only architecture otherwise, boolean, optional, default ``no`` (legacy behavior). * ``use_full_release_name`` - if set to ``yes``, the release will contain both repository name and architecture, and only architecture otherwise, boolean, optional, default ``no`` (legacy behavior).
* ``username`` - GitHub authorization user, string, required. Basically the same as ``owner``. * ``username`` - GitHub authorization user, string, required. Basically the same as ``owner``.
@@ -411,6 +417,8 @@ This feature requires GitHub key creation (see below). Section name must be eith
Section name must be either ``remote-service`` (plus optional architecture name, e.g. ``remote-service:x86_64``) or random name with ``type`` set. Section name must be either ``remote-service`` (plus optional architecture name, e.g. ``remote-service:x86_64``) or random name with ``type`` set.
* ``type`` - type of the report, string, optional, must be set to ``remote-service`` if exists. * ``type`` - type of the report, string, optional, must be set to ``remote-service`` if exists.
* ``max_retries`` - maximum amount of retries of HTTP requests, integer, optional, default ``0``.
* ``retry_backoff`` - retry exponential backoff, float, optional, default ``0.0``.
* ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``. * ``timeout`` - HTTP request timeout in seconds, integer, optional, default is ``30``.
``rsync`` type ``rsync`` type

View File

@@ -73,8 +73,12 @@ enabled = yes
; In case if unix sockets are used, it might point to the valid socket with encoded path, e.g.: ; In case if unix sockets are used, it might point to the valid socket with encoded path, e.g.:
; address = http+unix://%2Fvar%2Flib%2Fahriman%2Fsocket ; address = http+unix://%2Fvar%2Flib%2Fahriman%2Fsocket
;address = http://${web:host}:${web:port} ;address = http://${web:host}:${web:port}
; Maximum amount of retries of HTTP requests.
;max_retries = 0
; Optional password for authentication (if enabled). ; Optional password for authentication (if enabled).
;password = ;password =
; Retry exponential backoff.
;retry_backoff = 0.0
; Do not log HTTP errors if occurs. ; Do not log HTTP errors if occurs.
suppress_http_log_errors = yes suppress_http_log_errors = yes
; HTTP request timeout in seconds. ; HTTP request timeout in seconds.
@@ -216,6 +220,10 @@ templates[] = ${prefix}/share/ahriman/templates
;homepage= ;homepage=
; Prefix for packages links. Link to a package will be formed as link_path / filename. ; Prefix for packages links. Link to a package will be formed as link_path / filename.
;link_path = ;link_path =
; Maximum amount of retries of HTTP requests.
;max_retries = 0
; Retry exponential backoff.
;retry_backoff = 0.0
; Optional link to the RSS feed. ; Optional link to the RSS feed.
;rss_url = ;rss_url =
; Template name to be used. ; Template name to be used.
@@ -236,12 +244,16 @@ target =
[github] [github]
; Trigger type name. ; Trigger type name.
;type = github ;type = github
; Maximum amount of retries of HTTP requests.
;max_retries = 0
; GitHub repository owner username. ; GitHub repository owner username.
;owner = ;owner =
; GitHub API key. public_repo (repo) scope is required. ; GitHub API key. public_repo (repo) scope is required.
;password = ;password =
; GitHub repository name. ; GitHub repository name.
;repository = ;repository =
; Retry exponential backoff.
;retry_backoff = 0.0
; HTTP request timeout in seconds. ; HTTP request timeout in seconds.
;timeout = 30 ;timeout = 30
; Include repository name to release name (recommended). ; Include repository name to release name (recommended).
@@ -253,6 +265,10 @@ target =
[remote-service] [remote-service]
; Trigger type name. ; Trigger type name.
;type = remote-service ;type = remote-service
; Maximum amount of retries of HTTP requests.
;max_retries = 0
; Retry exponential backoff.
;retry_backoff = 0.0
; HTTP request timeout in seconds. ; HTTP request timeout in seconds.
;timeout = 30 ;timeout = 30

View File

@@ -296,10 +296,20 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"empty": False, "empty": False,
"is_url": [], "is_url": [],
}, },
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"password": { "password": {
"type": "string", "type": "string",
"empty": False, "empty": False,
}, },
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"suppress_http_log_errors": { "suppress_http_log_errors": {
"type": "boolean", "type": "boolean",
"coerce": "boolean", "coerce": "boolean",

View File

@@ -76,6 +76,19 @@ class Validator(RootValidator):
converted: bool = self.configuration._convert_to_boolean(value) # type: ignore[attr-defined] converted: bool = self.configuration._convert_to_boolean(value) # type: ignore[attr-defined]
return converted return converted
def _normalize_coerce_float(self, value: str) -> float:
"""
extract float from string value
Args:
value(str): converting value
Returns:
float: value converted to float according to configuration rules
"""
del self
return float(value)
def _normalize_coerce_integer(self, value: str) -> int: def _normalize_coerce_integer(self, value: str) -> int:
""" """
extract integer from string value extract integer from string value

View File

@@ -20,10 +20,9 @@
import contextlib import contextlib
import requests import requests
from functools import cached_property from requests.adapters import BaseAdapter
from urllib.parse import urlparse from urllib.parse import urlparse
from ahriman import __version__
from ahriman.core.http.sync_http_client import SyncHttpClient from ahriman.core.http.sync_http_client import SyncHttpClient
@@ -37,32 +36,36 @@ class SyncAhrimanClient(SyncHttpClient):
address: str address: str
@cached_property def _login_url(self) -> str:
def session(self) -> requests.Session:
""" """
get or create session get url for the login api
Returns: Returns:
request.Session: created session object str: full url for web service to log in
""" """
if urlparse(self.address).scheme == "http+unix": return f"{self.address}/api/v1/login"
import requests_unixsocket
session: requests.Session = requests_unixsocket.Session() # type: ignore[no-untyped-call]
session.headers["User-Agent"] = f"ahriman/{__version__}"
return session
session = requests.Session() def adapters(self) -> dict[str, BaseAdapter]:
session.headers["User-Agent"] = f"ahriman/{__version__}"
self._login(session)
return session
def _login(self, session: requests.Session) -> None:
""" """
process login to the service get registered adapters
Returns:
dict[str, BaseAdapter]: map of protocol and adapter used for this protocol
"""
adapters = SyncHttpClient.adapters(self)
if (scheme := urlparse(self.address).scheme) == "http+unix":
from requests_unixsocket.adapters import UnixAdapter
adapters[f"{scheme}://"] = UnixAdapter() # type: ignore[no-untyped-call]
return adapters
def start(self, session: requests.Session) -> None:
"""
method which will be called on session creation
Args: Args:
session(requests.Session): request session to login session(requests.Session): created requests session
""" """
if self.auth is None: if self.auth is None:
return # no auth configured return # no auth configured
@@ -74,12 +77,3 @@ class SyncAhrimanClient(SyncHttpClient):
} }
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.make_request("POST", self._login_url(), json=payload, session=session) self.make_request("POST", self._login_url(), json=payload, session=session)
def _login_url(self) -> str:
"""
get url for the login api
Returns:
str: full url for web service to log in
"""
return f"{self.address}/api/v1/login"

View File

@@ -21,7 +21,9 @@ import requests
import sys import sys
from functools import cached_property from functools import cached_property
from requests.adapters import BaseAdapter, HTTPAdapter
from typing import Any, IO, Literal from typing import Any, IO, Literal
from urllib3.util.retry import Retry
from ahriman import __version__ from ahriman import __version__
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
@@ -62,6 +64,16 @@ class SyncHttpClient(LazyLogging):
self.timeout: int | None = configuration.getint(section, "timeout", fallback=30) self.timeout: int | None = configuration.getint(section, "timeout", fallback=30)
self.suppress_errors = suppress_errors self.suppress_errors = suppress_errors
retries = configuration.getint(section, "max_retries", fallback=0)
self.retry = Retry(
total=retries,
connect=retries,
read=retries,
status=retries,
status_forcelist=[429, 500, 502, 503, 504],
backoff_factor=configuration.getfloat(section, "retry_backoff", fallback=0.0),
)
@cached_property @cached_property
def session(self) -> requests.Session: def session(self) -> requests.Session:
""" """
@@ -71,11 +83,17 @@ class SyncHttpClient(LazyLogging):
request.Session: created session object request.Session: created session object
""" """
session = requests.Session() session = requests.Session()
for protocol, adapter in self.adapters().items():
session.mount(protocol, adapter)
python_version = ".".join(map(str, sys.version_info[:3])) # just major.minor.patch python_version = ".".join(map(str, sys.version_info[:3])) # just major.minor.patch
session.headers["User-Agent"] = f"ahriman/{__version__} " \ session.headers["User-Agent"] = f"ahriman/{__version__} " \
f"{requests.utils.default_user_agent()} " \ f"{requests.utils.default_user_agent()} " \
f"python/{python_version}" f"python/{python_version}"
self.start(session)
return session return session
@staticmethod @staticmethod
@@ -92,6 +110,19 @@ class SyncHttpClient(LazyLogging):
result: str = exception.response.text if exception.response is not None else "" result: str = exception.response.text if exception.response is not None else ""
return result return result
def adapters(self) -> dict[str, BaseAdapter]:
"""
get registered adapters
Returns:
dict[str, BaseAdapter]: map of protocol and adapter used for this protocol
"""
adapter = HTTPAdapter(max_retries=self.retry)
return {
"http://": adapter,
"https://": adapter,
}
def make_request(self, method: Literal["DELETE", "GET", "HEAD", "POST", "PUT"], url: str, *, def make_request(self, method: Literal["DELETE", "GET", "HEAD", "POST", "PUT"], url: str, *,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
params: list[tuple[str, str]] | None = None, params: list[tuple[str, str]] | None = None,
@@ -139,3 +170,11 @@ class SyncHttpClient(LazyLogging):
if not suppress_errors: if not suppress_errors:
self.logger.exception("could not perform http request") self.logger.exception("could not perform http request")
raise raise
def start(self, session: requests.Session) -> None:
"""
method which will be called on session creation
Args:
session(requests.Session): created requests session
"""

View File

@@ -302,6 +302,16 @@ class ReportTrigger(Trigger):
"empty": False, "empty": False,
"is_url": [], "is_url": [],
}, },
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"rss_url": { "rss_url": {
"type": "string", "type": "string",
"empty": False, "empty": False,

View File

@@ -54,6 +54,11 @@ class UploadTrigger(Trigger):
"type": "string", "type": "string",
"allowed": ["github"], "allowed": ["github"],
}, },
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"owner": { "owner": {
"type": "string", "type": "string",
"required": True, "required": True,
@@ -68,6 +73,11 @@ class UploadTrigger(Trigger):
"required": True, "required": True,
"empty": False, "empty": False,
}, },
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"timeout": { "timeout": {
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",
@@ -90,6 +100,16 @@ class UploadTrigger(Trigger):
"type": "string", "type": "string",
"allowed": ["ahriman", "remote-service"], "allowed": ["ahriman", "remote-service"],
}, },
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"timeout": { "timeout": {
"type": "integer", "type": "integer",
"coerce": "integer", "coerce": "integer",

View File

@@ -33,6 +33,14 @@ def test_normalize_coerce_boolean(validator: Validator, mocker: MockerFixture) -
convert_mock.assert_called_once_with("1") convert_mock.assert_called_once_with("1")
def test_normalize_coerce_float(validator: Validator) -> None:
"""
must convert string value to float by using configuration converters
"""
assert validator._normalize_coerce_float("1.5") == 1.5
assert validator._normalize_coerce_float("0.0") == 0.0
def test_normalize_coerce_integer(validator: Validator) -> None: def test_normalize_coerce_integer(validator: Validator) -> None:
""" """
must convert string value to integer by using configuration converters must convert string value to integer by using configuration converters

View File

@@ -1,6 +1,5 @@
import pytest import pytest
import requests import requests
import requests_unixsocket
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
@@ -8,31 +7,32 @@ from ahriman.core.http import SyncAhrimanClient
from ahriman.models.user import User from ahriman.models.user import User
def test_session(ahriman_client: SyncAhrimanClient, mocker: MockerFixture) -> None: def test_adapters(ahriman_client: SyncAhrimanClient) -> None:
""" """
must create normal requests session must return native adapters
""" """
login_mock = mocker.patch("ahriman.core.http.SyncAhrimanClient._login") assert "http+unix://" not in ahriman_client.adapters()
assert isinstance(ahriman_client.session, requests.Session)
assert not isinstance(ahriman_client.session, requests_unixsocket.Session)
login_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_session_unix_socket(ahriman_client: SyncAhrimanClient, mocker: MockerFixture) -> None: def test_adapters_unix_socket(ahriman_client: SyncAhrimanClient) -> None:
""" """
must create unix socket session must register unix socket adapter
""" """
login_mock = mocker.patch("ahriman.core.http.SyncAhrimanClient._login")
ahriman_client.address = "http+unix://path" ahriman_client.address = "http+unix://path"
assert "http+unix://" in ahriman_client.adapters()
assert isinstance(ahriman_client.session, requests_unixsocket.Session)
login_mock.assert_not_called()
def test_login(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None: def test_login_url(ahriman_client: SyncAhrimanClient) -> None:
""" """
must login user must generate login url correctly
"""
assert ahriman_client._login_url().startswith(ahriman_client.address)
assert ahriman_client._login_url().endswith("/api/v1/login")
def test_start(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None:
"""
must log in user on start
""" """
ahriman_client.auth = (user.username, user.password) ahriman_client.auth = (user.username, user.password)
requests_mock = mocker.patch("ahriman.core.http.SyncAhrimanClient.make_request") requests_mock = mocker.patch("ahriman.core.http.SyncAhrimanClient.make_request")
@@ -42,40 +42,32 @@ def test_login(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixt
} }
session = requests.Session() session = requests.Session()
ahriman_client._login(session) ahriman_client.start(session)
requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), json=payload, session=session) requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True), json=payload, session=session)
def test_login_failed(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None: def test_start_failed(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None:
""" """
must suppress any exception happened during login must suppress any exception happened during session start
""" """
ahriman_client.user = user ahriman_client.user = user
mocker.patch("requests.Session.request", side_effect=Exception) mocker.patch("requests.Session.request", side_effect=Exception)
ahriman_client._login(requests.Session()) ahriman_client.start(requests.Session())
def test_login_failed_http_error(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None: def test_start_failed_http_error(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None:
""" """
must suppress HTTP exception happened during login must suppress HTTP exception happened during session start
""" """
ahriman_client.user = user ahriman_client.user = user
mocker.patch("requests.Session.request", side_effect=requests.HTTPError) mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
ahriman_client._login(requests.Session()) ahriman_client.start(requests.Session())
def test_login_skip(ahriman_client: SyncAhrimanClient, mocker: MockerFixture) -> None: def test_start_skip(ahriman_client: SyncAhrimanClient, mocker: MockerFixture) -> None:
""" """
must skip login if no user set must skip login if no user set
""" """
requests_mock = mocker.patch("requests.Session.request") requests_mock = mocker.patch("requests.Session.request")
ahriman_client._login(requests.Session()) ahriman_client.start(requests.Session())
requests_mock.assert_not_called() requests_mock.assert_not_called()
def test_login_url(ahriman_client: SyncAhrimanClient) -> None:
"""
must generate login url correctly
"""
assert ahriman_client._login_url().startswith(ahriman_client.address)
assert ahriman_client._login_url().endswith("/api/v1/login")

View File

@@ -33,12 +33,15 @@ def test_init_auth_empty() -> None:
assert SyncHttpClient().auth is None assert SyncHttpClient().auth is None
def test_session() -> None: def test_session(mocker: MockerFixture) -> None:
""" """
must generate valid session must generate valid session
""" """
start_mock = mocker.patch("ahriman.core.http.sync_http_client.SyncHttpClient.start")
session = SyncHttpClient().session session = SyncHttpClient().session
assert "User-Agent" in session.headers assert "User-Agent" in session.headers
start_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_exception_response_text() -> None: def test_exception_response_text() -> None:
@@ -60,6 +63,18 @@ def test_exception_response_text_empty() -> None:
assert SyncHttpClient.exception_response_text(exception) == "" assert SyncHttpClient.exception_response_text(exception) == ""
def test_adapters() -> None:
"""
must create adapters with retry policy
"""
client = SyncHttpClient()
adapers = client.adapters()
assert "http://" in adapers
assert "https://" in adapers
assert all(adapter.max_retries == client.retry for adapter in adapers.values())
def test_make_request(mocker: MockerFixture) -> None: def test_make_request(mocker: MockerFixture) -> None:
""" """
must make HTTP request must make HTTP request
@@ -158,3 +173,11 @@ def test_make_request_session() -> None:
session_mock.request.assert_called_once_with( session_mock.request.assert_called_once_with(
"GET", "url", params=None, data=None, headers=None, files=None, json=None, "GET", "url", params=None, data=None, headers=None, files=None, json=None,
stream=None, auth=None, timeout=client.timeout) stream=None, auth=None, timeout=client.timeout)
def test_start() -> None:
"""
must do nothing on start
"""
client = SyncHttpClient()
client.start(client.session)