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. * ``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. * ``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 ``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. * ``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 +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. * ``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 +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. 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 +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). #. 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 +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. 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

@@ -23,6 +23,14 @@ sync_files_database = yes
; as additional option for some subcommands). If set to no, databases must be synchronized manually. ; as additional option for some subcommands). If set to no, databases must be synchronized manually.
use_ahriman_cache = yes 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] [build]
; List of additional flags passed to archbuild command. ; List of additional flags passed to archbuild command.
;archbuild_flags = ;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.: ; 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 +228,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 +252,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 +273,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

@@ -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": { "auth": {
"type": "dict", "type": "dict",
"schema": { "schema": {
@@ -296,10 +316,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 on_session_creation(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
@@ -38,10 +40,14 @@ class SyncHttpClient(LazyLogging):
Attributes: Attributes:
auth(tuple[str, str] | None): HTTP basic auth object if set 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 suppress_errors(bool): suppress logging of request errors
timeout(int | None): HTTP request timeout in seconds 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, *, def __init__(self, configuration: Configuration | None = None, section: str | None = None, *,
suppress_errors: bool = False) -> None: suppress_errors: bool = False) -> None:
""" """
@@ -50,18 +56,21 @@ class SyncHttpClient(LazyLogging):
section(str | None, optional): settings section name (Default value = None) section(str | None, optional): settings section name (Default value = None)
suppress_errors(bool, optional): suppress logging of request errors (Default value = False) suppress_errors(bool, optional): suppress logging of request errors (Default value = False)
""" """
if configuration is None: configuration = configuration or Configuration() # dummy configuration
configuration = Configuration() # dummy configuration section = section or configuration.default_section
if section is None:
section = configuration.default_section
username = configuration.get(section, "username", fallback=None) username = configuration.get(section, "username", fallback=None)
password = configuration.get(section, "password", fallback=None) password = configuration.get(section, "password", fallback=None)
self.auth = (username, password) if username and password else 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.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 @cached_property
def session(self) -> requests.Session: def session(self) -> requests.Session:
""" """
@@ -71,11 +80,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.on_session_creation(session)
return session return session
@staticmethod @staticmethod
@@ -92,6 +107,39 @@ 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
@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, *, 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 +187,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 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, "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

@@ -21,6 +21,7 @@ from typing import Self
from ahriman.core import _Context, context from ahriman.core import _Context, context
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.remote import AUR
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.repository.executor import Executor from ahriman.core.repository.executor import Executor
@@ -73,9 +74,26 @@ class Repository(Executor, UpdateHandler):
""" """
instance = cls(repository_id, configuration, database, instance = cls(repository_id, configuration, database,
report=report, refresh_pacman_database=refresh_pacman_database) report=report, refresh_pacman_database=refresh_pacman_database)
instance._set_globals(configuration)
instance._set_context() instance._set_context()
return instance 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: def _set_context(self) -> None:
""" """
create context variables and set their values create context variables and set their values

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_on_session_creation(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.on_session_creation(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_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 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.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 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.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 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.on_session_creation(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

@@ -4,6 +4,7 @@ import requests
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import MagicMock, call as MockCall from unittest.mock import MagicMock, call as MockCall
from ahriman.core.alpm.remote import AUR
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.http import SyncHttpClient from ahriman.core.http import SyncHttpClient
@@ -33,12 +34,29 @@ 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
""" """
on_session_creation_mock = mocker.patch("ahriman.core.http.sync_http_client.SyncHttpClient.on_session_creation")
session = SyncHttpClient().session session = SyncHttpClient().session
assert "User-Agent" in session.headers 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: def test_exception_response_text() -> None:
@@ -60,6 +78,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()
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: def test_make_request(mocker: MockerFixture) -> None:
""" """
must make HTTP request must make HTTP request
@@ -158,3 +188,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_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 unittest.mock import call as MockCall
from ahriman.core.alpm.pacman import Pacman from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.remote import AUR
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite from ahriman.core.database import SQLite
from ahriman.core.repository import Repository from ahriman.core.repository import Repository
@@ -13,13 +14,27 @@ def test_load(configuration: Configuration, database: SQLite, mocker: MockerFixt
""" """
must correctly load instance must correctly load instance
""" """
globals_mock = mocker.patch("ahriman.core.repository.Repository._set_globals")
context_mock = mocker.patch("ahriman.core.repository.Repository._set_context") context_mock = mocker.patch("ahriman.core.repository.Repository._set_context")
_, repository_id = configuration.check_loaded() _, repository_id = configuration.check_loaded()
Repository.load(repository_id, configuration, database, report=False) Repository.load(repository_id, configuration, database, report=False)
globals_mock.assert_called_once_with(configuration)
context_mock.assert_called_once_with() 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: def test_set_context(configuration: Configuration, database: SQLite, mocker: MockerFixture) -> None:
""" """
must set context variables must set context variables