From b0f1828ae7a77af36f026030639f4c8257b1748f Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Fri, 20 Feb 2026 02:44:32 +0200 Subject: [PATCH] feat: add retry policy --- docs/configuration.rst | 17 +++++ package/share/ahriman/settings/ahriman.ini | 24 +++++++ src/ahriman/core/configuration/schema.py | 30 +++++++++ src/ahriman/core/configuration/validator.py | 13 ++++ src/ahriman/core/http/sync_ahriman_client.py | 52 +++++++-------- src/ahriman/core/http/sync_http_client.py | 66 +++++++++++++++++-- src/ahriman/core/report/report_trigger.py | 10 +++ src/ahriman/core/repository/repository.py | 18 +++++ src/ahriman/core/upload/upload_trigger.py | 20 ++++++ .../core/configuration/test_validator.py | 8 +++ .../core/http/test_sync_ahriman_client.py | 58 +++++++--------- .../core/http/test_sync_http_client.py | 40 ++++++++++- .../core/repository/test_repository.py | 15 +++++ 13 files changed, 303 insertions(+), 68 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 0e43b5c4..71446cc6 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -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 diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini index d983b3dd..2708105f 100644 --- a/package/share/ahriman/settings/ahriman.ini +++ b/package/share/ahriman/settings/ahriman.ini @@ -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 diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py index 9d7746f9..d1284915 100644 --- a/src/ahriman/core/configuration/schema.py +++ b/src/ahriman/core/configuration/schema.py @@ -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", diff --git a/src/ahriman/core/configuration/validator.py b/src/ahriman/core/configuration/validator.py index 20233d79..aea43554 100644 --- a/src/ahriman/core/configuration/validator.py +++ b/src/ahriman/core/configuration/validator.py @@ -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 diff --git a/src/ahriman/core/http/sync_ahriman_client.py b/src/ahriman/core/http/sync_ahriman_client.py index 17abdabe..3d5544aa 100644 --- a/src/ahriman/core/http/sync_ahriman_client.py +++ b/src/ahriman/core/http/sync_ahriman_client.py @@ -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" diff --git a/src/ahriman/core/http/sync_http_client.py b/src/ahriman/core/http/sync_http_client.py index 75cdc2be..db987bc6 100644 --- a/src/ahriman/core/http/sync_http_client.py +++ b/src/ahriman/core/http/sync_http_client.py @@ -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 + """ diff --git a/src/ahriman/core/report/report_trigger.py b/src/ahriman/core/report/report_trigger.py index 7347e0e9..75940a53 100644 --- a/src/ahriman/core/report/report_trigger.py +++ b/src/ahriman/core/report/report_trigger.py @@ -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, diff --git a/src/ahriman/core/repository/repository.py b/src/ahriman/core/repository/repository.py index 9278eb01..19c736dc 100644 --- a/src/ahriman/core/repository/repository.py +++ b/src/ahriman/core/repository/repository.py @@ -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 diff --git a/src/ahriman/core/upload/upload_trigger.py b/src/ahriman/core/upload/upload_trigger.py index 1ff1bc54..c5c065e0 100644 --- a/src/ahriman/core/upload/upload_trigger.py +++ b/src/ahriman/core/upload/upload_trigger.py @@ -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", diff --git a/tests/ahriman/core/configuration/test_validator.py b/tests/ahriman/core/configuration/test_validator.py index 1abc4b75..039e4bb6 100644 --- a/tests/ahriman/core/configuration/test_validator.py +++ b/tests/ahriman/core/configuration/test_validator.py @@ -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 diff --git a/tests/ahriman/core/http/test_sync_ahriman_client.py b/tests/ahriman/core/http/test_sync_ahriman_client.py index 4d7e42ea..c9c96d4c 100644 --- a/tests/ahriman/core/http/test_sync_ahriman_client.py +++ b/tests/ahriman/core/http/test_sync_ahriman_client.py @@ -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") diff --git a/tests/ahriman/core/http/test_sync_http_client.py b/tests/ahriman/core/http/test_sync_http_client.py index c7a194fd..4324c1e8 100644 --- a/tests/ahriman/core/http/test_sync_http_client.py +++ b/tests/ahriman/core/http/test_sync_http_client.py @@ -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) diff --git a/tests/ahriman/core/repository/test_repository.py b/tests/ahriman/core/repository/test_repository.py index b687b20c..cb4e885f 100644 --- a/tests/ahriman/core/repository/test_repository.py +++ b/tests/ahriman/core/repository/test_repository.py @@ -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