feat: add retry policy

This commit is contained in:
2026-02-20 02:44:32 +02:00
parent dec025b45a
commit b0f1828ae7
13 changed files with 303 additions and 68 deletions

View File

@@ -97,6 +97,15 @@ libalpm and AUR related configuration. Group name can refer to architecture, e.g
* ``sync_files_database`` - download files database from mirror, boolean, required.
* ``use_ahriman_cache`` - use local pacman package cache instead of system one, boolean, required. With this option enabled you might want to refresh database periodically (available as additional flag for some subcommands). If set to ``no``, databases must be synchronized manually.
``aur`` group
-------------
Archlinux User Repository related configuration.
* ``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``.
``auth`` group
--------------
@@ -158,7 +167,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.
* ``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.
* ``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.
* ``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.
@@ -367,6 +378,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.
* ``homepage`` - link to homepage, string, optional.
* ``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.
* ``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``.
@@ -392,6 +405,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.
* ``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.
* ``password`` - created GitHub API key. In order to create it do the following:
@@ -401,6 +415,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).
* ``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``.
* ``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``.
@@ -411,6 +426,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.
* ``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``.
``rsync`` type

View File

@@ -23,6 +23,14 @@ sync_files_database = yes
; as additional option for some subcommands). If set to no, databases must be synchronized manually.
use_ahriman_cache = yes
[aur]
; Maximum amount of retries of HTTP requests.
max_retries = 3
; Retry exponential backoff.
retry_backoff = 1.0
; HTTP request timeout in seconds.
;timeout = 30
[build]
; List of additional flags passed to archbuild command.
;archbuild_flags =
@@ -73,8 +81,12 @@ enabled = yes
; 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://${web:host}:${web:port}
; Maximum amount of retries of HTTP requests.
;max_retries = 0
; Optional password for authentication (if enabled).
;password =
; Retry exponential backoff.
;retry_backoff = 0.0
; Do not log HTTP errors if occurs.
suppress_http_log_errors = yes
; HTTP request timeout in seconds.
@@ -216,6 +228,10 @@ templates[] = ${prefix}/share/ahriman/templates
;homepage=
; Prefix for packages links. Link to a package will be formed as link_path / filename.
;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.
;rss_url =
; Template name to be used.
@@ -236,12 +252,16 @@ target =
[github]
; Trigger type name.
;type = github
; Maximum amount of retries of HTTP requests.
;max_retries = 0
; GitHub repository owner username.
;owner =
; GitHub API key. public_repo (repo) scope is required.
;password =
; GitHub repository name.
;repository =
; Retry exponential backoff.
;retry_backoff = 0.0
; HTTP request timeout in seconds.
;timeout = 30
; Include repository name to release name (recommended).
@@ -253,6 +273,10 @@ target =
[remote-service]
; Trigger type name.
;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.
;timeout = 30

View File

@@ -97,6 +97,26 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
},
},
},
"aur": {
"type": "dict",
"schema": {
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"timeout": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
},
},
"auth": {
"type": "dict",
"schema": {
@@ -296,10 +316,20 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"empty": False,
"is_url": [],
},
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"password": {
"type": "string",
"empty": False,
},
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"suppress_http_log_errors": {
"type": "boolean",
"coerce": "boolean",

View File

@@ -76,6 +76,19 @@ class Validator(RootValidator):
converted: bool = self.configuration._convert_to_boolean(value) # type: ignore[attr-defined]
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:
"""
extract integer from string value

View File

@@ -20,10 +20,9 @@
import contextlib
import requests
from functools import cached_property
from requests.adapters import BaseAdapter
from urllib.parse import urlparse
from ahriman import __version__
from ahriman.core.http.sync_http_client import SyncHttpClient
@@ -37,32 +36,36 @@ class SyncAhrimanClient(SyncHttpClient):
address: str
@cached_property
def session(self) -> requests.Session:
def _login_url(self) -> str:
"""
get or create session
get url for the login api
Returns:
request.Session: created session object
str: full url for web service to log in
"""
if urlparse(self.address).scheme == "http+unix":
import requests_unixsocket
session: requests.Session = requests_unixsocket.Session() # type: ignore[no-untyped-call]
session.headers["User-Agent"] = f"ahriman/{__version__}"
return session
return f"{self.address}/api/v1/login"
session = requests.Session()
session.headers["User-Agent"] = f"ahriman/{__version__}"
self._login(session)
return session
def _login(self, session: requests.Session) -> None:
def adapters(self) -> dict[str, BaseAdapter]:
"""
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 on_session_creation(self, session: requests.Session) -> None:
"""
method which will be called on session creation
Args:
session(requests.Session): request session to login
session(requests.Session): created requests session
"""
if self.auth is None:
return # no auth configured
@@ -74,12 +77,3 @@ class SyncAhrimanClient(SyncHttpClient):
}
with contextlib.suppress(Exception):
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
from functools import cached_property
from requests.adapters import BaseAdapter, HTTPAdapter
from typing import Any, IO, Literal
from urllib3.util.retry import Retry
from ahriman import __version__
from ahriman.core.configuration import Configuration
@@ -38,10 +40,14 @@ class SyncHttpClient(LazyLogging):
Attributes:
auth(tuple[str, str] | None): HTTP basic auth object if set
retry(Retry): retry policy of the HTTP client. Disabled by default
suppress_errors(bool): suppress logging of request errors
timeout(int | None): HTTP request timeout in seconds
"""
retry: Retry = Retry()
timeout: int | None = None
def __init__(self, configuration: Configuration | None = None, section: str | None = None, *,
suppress_errors: bool = False) -> None:
"""
@@ -50,18 +56,21 @@ class SyncHttpClient(LazyLogging):
section(str | None, optional): settings section name (Default value = None)
suppress_errors(bool, optional): suppress logging of request errors (Default value = False)
"""
if configuration is None:
configuration = Configuration() # dummy configuration
if section is None:
section = configuration.default_section
configuration = configuration or Configuration() # dummy configuration
section = section or configuration.default_section
username = configuration.get(section, "username", fallback=None)
password = configuration.get(section, "password", fallback=None)
self.auth = (username, password) if username and password else None
self.timeout: int | None = configuration.getint(section, "timeout", fallback=30)
self.suppress_errors = suppress_errors
self.timeout = configuration.getint(section, "timeout", fallback=30)
self.retry = SyncHttpClient.retry_policy(
max_retries=configuration.getint(section, "max_retries", fallback=0),
retry_backoff=configuration.getfloat(section, "retry_backoff", fallback=0.0),
)
@cached_property
def session(self) -> requests.Session:
"""
@@ -71,11 +80,17 @@ class SyncHttpClient(LazyLogging):
request.Session: created session object
"""
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
session.headers["User-Agent"] = f"ahriman/{__version__} " \
f"{requests.utils.default_user_agent()} " \
f"python/{python_version}"
self.on_session_creation(session)
return session
@staticmethod
@@ -92,6 +107,39 @@ class SyncHttpClient(LazyLogging):
result: str = exception.response.text if exception.response is not None else ""
return result
@staticmethod
def retry_policy(max_retries: int = 0, retry_backoff: float = 0.0) -> Retry:
"""
build retry policy for class
Args:
max_retries(int, optional): maximum amount of retries allowed (Default value = 0)
retry_backoff(float, optional): retry exponential backoff (Default value = 0.0)
Returns:
Retry: built retry policy
"""
return Retry(
total=max_retries,
connect=max_retries,
read=max_retries,
status=max_retries,
status_forcelist=[429, 500, 502, 503, 504],
backoff_factor=retry_backoff,
)
def adapters(self) -> dict[str, BaseAdapter]:
"""
get registered adapters
Returns:
dict[str, BaseAdapter]: map of protocol and adapter used for this protocol
"""
return {
"http://": HTTPAdapter(max_retries=self.retry),
"https://": HTTPAdapter(max_retries=self.retry),
}
def make_request(self, method: Literal["DELETE", "GET", "HEAD", "POST", "PUT"], url: str, *,
headers: dict[str, str] | None = None,
params: list[tuple[str, str]] | None = None,
@@ -139,3 +187,11 @@ class SyncHttpClient(LazyLogging):
if not suppress_errors:
self.logger.exception("could not perform http request")
raise
def on_session_creation(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,
"is_url": [],
},
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"rss_url": {
"type": "string",
"empty": False,

View File

@@ -21,6 +21,7 @@ from typing import Self
from ahriman.core import _Context, context
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.remote import AUR
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository.executor import Executor
@@ -73,9 +74,26 @@ class Repository(Executor, UpdateHandler):
"""
instance = cls(repository_id, configuration, database,
report=report, refresh_pacman_database=refresh_pacman_database)
instance._set_globals(configuration)
instance._set_context()
return instance
@staticmethod
def _set_globals(configuration: Configuration) -> None:
"""
set global settings based on configuration via class attributes
Args:
configuration(Configuration): configuration instance
"""
AUR.timeout = configuration.getint("aur", "timeout", fallback=30)
AUR.retry = AUR.retry_policy(
max_retries=configuration.getint("aur", "max_retries", fallback=0),
retry_backoff=configuration.getfloat("aur", "retry_backoff", fallback=0.0),
)
def _set_context(self) -> None:
"""
create context variables and set their values

View File

@@ -54,6 +54,11 @@ class UploadTrigger(Trigger):
"type": "string",
"allowed": ["github"],
},
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"owner": {
"type": "string",
"required": True,
@@ -68,6 +73,11 @@ class UploadTrigger(Trigger):
"required": True,
"empty": False,
},
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"timeout": {
"type": "integer",
"coerce": "integer",
@@ -90,6 +100,16 @@ class UploadTrigger(Trigger):
"type": "string",
"allowed": ["ahriman", "remote-service"],
},
"max_retries": {
"type": "integer",
"coerce": "integer",
"min": 0,
},
"retry_backoff": {
"type": "float",
"coerce": "float",
"min": 0,
},
"timeout": {
"type": "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")
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:
"""
must convert string value to integer by using configuration converters

View File

@@ -1,6 +1,5 @@
import pytest
import requests
import requests_unixsocket
from pytest_mock import MockerFixture
@@ -8,31 +7,32 @@ from ahriman.core.http import SyncAhrimanClient
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 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))
assert "http+unix://" not in ahriman_client.adapters()
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"
assert isinstance(ahriman_client.session, requests_unixsocket.Session)
login_mock.assert_not_called()
assert "http+unix://" in ahriman_client.adapters()
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_on_session_creation(ahriman_client: SyncAhrimanClient, user: User, mocker: MockerFixture) -> None:
"""
must log in user on start
"""
ahriman_client.auth = (user.username, user.password)
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()
ahriman_client._login(session)
ahriman_client.on_session_creation(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_on_session_creation_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
mocker.patch("requests.Session.request", side_effect=Exception)
ahriman_client._login(requests.Session())
ahriman_client.on_session_creation(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
mocker.patch("requests.Session.request", side_effect=requests.HTTPError)
ahriman_client._login(requests.Session())
ahriman_client.on_session_creation(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
"""
requests_mock = mocker.patch("requests.Session.request")
ahriman_client._login(requests.Session())
ahriman_client.on_session_creation(requests.Session())
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

@@ -4,6 +4,7 @@ import requests
from pytest_mock import MockerFixture
from unittest.mock import MagicMock, call as MockCall
from ahriman.core.alpm.remote import AUR
from ahriman.core.configuration import Configuration
from ahriman.core.http import SyncHttpClient
@@ -33,12 +34,29 @@ def test_init_auth_empty() -> None:
assert SyncHttpClient().auth is None
def test_session() -> None:
def test_session(mocker: MockerFixture) -> None:
"""
must generate valid session
"""
on_session_creation_mock = mocker.patch("ahriman.core.http.sync_http_client.SyncHttpClient.on_session_creation")
session = SyncHttpClient().session
assert "User-Agent" in session.headers
on_session_creation_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_retry_policy() -> None:
"""
must set retry policy
"""
SyncHttpClient.retry = SyncHttpClient.retry_policy(1, 2.0)
AUR.retry = AUR.retry_policy(3, 4.0)
assert SyncHttpClient.retry.connect == 1
assert SyncHttpClient.retry.backoff_factor == 2.0
assert AUR.retry.connect == 3
assert AUR.retry.backoff_factor == 4.0
def test_exception_response_text() -> None:
@@ -60,6 +78,18 @@ def test_exception_response_text_empty() -> None:
assert SyncHttpClient.exception_response_text(exception) == ""
def test_adapters() -> None:
"""
must create adapters with retry policy
"""
client = SyncHttpClient()
adapters = client.adapters()
assert "http://" in adapters
assert "https://" in adapters
assert all(adapter.max_retries == client.retry for adapter in adapters.values())
def test_make_request(mocker: MockerFixture) -> None:
"""
must make HTTP request
@@ -158,3 +188,11 @@ def test_make_request_session() -> None:
session_mock.request.assert_called_once_with(
"GET", "url", params=None, data=None, headers=None, files=None, json=None,
stream=None, auth=None, timeout=client.timeout)
def test_on_session_creation() -> None:
"""
must do nothing on start
"""
client = SyncHttpClient()
client.on_session_creation(client.session)

View File

@@ -2,6 +2,7 @@ from pytest_mock import MockerFixture
from unittest.mock import call as MockCall
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.remote import AUR
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository import Repository
@@ -13,13 +14,27 @@ def test_load(configuration: Configuration, database: SQLite, mocker: MockerFixt
"""
must correctly load instance
"""
globals_mock = mocker.patch("ahriman.core.repository.Repository._set_globals")
context_mock = mocker.patch("ahriman.core.repository.Repository._set_context")
_, repository_id = configuration.check_loaded()
Repository.load(repository_id, configuration, database, report=False)
globals_mock.assert_called_once_with(configuration)
context_mock.assert_called_once_with()
def test_set_globals(configuration: Configuration) -> None:
"""
must correctly set globals
"""
configuration.set_option("aur", "timeout", "42")
configuration.set_option("aur", "max_retries", "10")
Repository._set_globals(configuration)
assert AUR.timeout == 42
assert AUR.retry.connect == 10
def test_set_context(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> None:
"""
must set context variables