From 6f4dc2ffce80511d4f83f6b7864fc0b02de4d7ca Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Tue, 11 Nov 2025 10:05:24 +0200 Subject: [PATCH 1/6] feat: barebones async api methods --- UnleashClient/api/async_api.py | 204 +++++++++++++++++++++++++ UnleashClient/api/packet_building.py | 30 ++++ UnleashClient/api/sync_api.py | 26 +--- tests/unit_tests/api/test_async.api.py | 0 4 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 UnleashClient/api/async_api.py create mode 100644 UnleashClient/api/packet_building.py create mode 100644 tests/unit_tests/api/test_async.api.py diff --git a/UnleashClient/api/async_api.py b/UnleashClient/api/async_api.py new file mode 100644 index 00000000..2b117144 --- /dev/null +++ b/UnleashClient/api/async_api.py @@ -0,0 +1,204 @@ +import asyncio +from typing import Any, Mapping, Optional, Tuple +import aiohttp + +import json +from UnleashClient.api.packet_building import build_registration_packet +from UnleashClient.constants import APPLICATION_HEADERS, FEATURES_URL, REGISTER_URL +from UnleashClient.utils import LOGGER + + +_TRANSIENT_ERROR_CODES = {500, 502, 504} + + +def _backoff(attempt: int) -> float: + return min(0.5 * (2**attempt), 5.0) + + +async def register_client_async( + url: str, + app_name: str, + instance_id: str, + connection_id: str, + metrics_interval: int, + headers: dict, + custom_options: dict, + supported_strategies: dict, + request_timeout: int, +) -> bool: + payload = build_registration_packet( + app_name, instance_id, connection_id, metrics_interval, supported_strategies + ) + + LOGGER.info("Registering unleash client with unleash @ %s", url) + LOGGER.info("Registration request information: %s", payload) + + timeout = aiohttp.ClientTimeout(total=request_timeout) + session_kwargs = _session_opts_from(custom_options) + + try: + async with aiohttp.ClientSession(timeout=timeout, **session_kwargs) as session: + async with session.post( + url + REGISTER_URL, + data=json.dumps(payload), + headers={**headers, **APPLICATION_HEADERS}, + ) as resp: + if resp.status in (200, 202): + LOGGER.info("Unleash Client successfully registered!") + return True + + body = await resp.text() + LOGGER.warning( + "Unleash Client registration failed due to unexpected HTTP status code: %s; body: %r", + resp.status, + body, + ) + return False + + except (aiohttp.InvalidURL, ValueError) as exc: + LOGGER.exception( + "Registration failed fatally due to invalid request parameters: %s", exc + ) + raise + except (aiohttp.ClientError, TimeoutError) as exc: + LOGGER.exception("Registration failed due to exception: %s", exc) + return False + + +async def send_metrics_async( + url: str, + request_body: dict, + headers: dict, + custom_options: dict, + request_timeout: int, +) -> bool: + """ + Attempts to send metrics to Unleash server + + Notes: + * If unsuccessful (i.e. not HTTP status code 200), message will be logged + + :param url: + :param request_body: + :param headers: + :param custom_options: + :param request_timeout: + :return: true if registration successful, false if registration unsuccessful or exception. + """ + try: + LOGGER.info("Sending messages to with unleash @ %s", url) + LOGGER.info("unleash metrics information: %s", request_body) + + timeout = aiohttp.ClientTimeout(total=request_timeout) + session_kwargs = _session_opts_from(custom_options) + + async with aiohttp.ClientSession(timeout=timeout, **session_kwargs) as session: + async with session.post( + url, + data=json.dumps(request_body), + headers={**headers, **APPLICATION_HEADERS}, + ) as resp: + if resp.status == 200: + LOGGER.info("Unleash Client metrics successfully sent!") + return True + + body = await resp.text() + LOGGER.warning( + "Unleash Client metrics sending failed due to unexpected HTTP status code: %s; body: %r", + resp.status, + body, + ) + return False + except aiohttp.ClientError as exc: + LOGGER.warning( + "Unleash Client metrics submission failed due to exception: %s", exc + ) + return False + + +async def get_feature_toggles_async( + url: str, + app_name: str, + instance_id: str, + headers: dict, + custom_options: dict, + request_timeout: int, + request_retries: int, + project: Optional[str] = None, + cached_etag: str = "", +) -> Tuple[Optional[str], str]: + try: + LOGGER.info("Getting feature flag.") + timeout = aiohttp.ClientTimeout(total=request_timeout) + session_kwargs = _session_opts_from(custom_options) + + base_url = f"{url}{FEATURES_URL}" + params = {"project": project} if project else None + + request_specific_headers = { + "UNLEASH-APPNAME": app_name, + "UNLEASH-INSTANCEID": instance_id, + } + if cached_etag: + request_specific_headers["If-None-Match"] = cached_etag + + async with aiohttp.ClientSession(timeout=timeout, **session_kwargs) as session: + for attempt in range(request_retries + 1): + try: + async with session.get( + base_url, + headers={**headers, **request_specific_headers}, + params=params, + ) as resp: + status = resp.status + etag = resp.headers.get("etag", "") + + if status == 304: + return None, etag + if status == 200: + body = await resp.text() + return body, etag + + body = await resp.text() + if ( + status in _TRANSIENT_ERROR_CODES + and attempt < request_retries + ): + LOGGER.debug( + "Feature fetch got %s; retrying attempt %d/%d", + status, + attempt + 1, + request_retries, + ) + await asyncio.sleep(_backoff(attempt)) + continue + + LOGGER.warning( + "Unleash Client feature fetch failed due to unexpected HTTP status code: %s; body: %r", + status, + body, + ) + raise Exception("Unleash Client feature fetch failed!") + except aiohttp.ClientError as exc: + if attempt < request_retries: + LOGGER.debug("Feature fetch client error (%s); retrying", exc) + await asyncio.sleep(_backoff(attempt)) + continue + LOGGER.exception( + "Unleash Client feature fetch failed due to exception: %s", exc + ) + return None, "" + except Exception as exc: + LOGGER.exception( + "Unleash Client feature fetch failed due to exception: %s", exc + ) + return None, "" + + +def _session_opts_from(custom_options: Mapping[str, Any]) -> dict: + opts: dict = {} + if "verify" in custom_options and not custom_options["verify"]: + opts["connector"] = aiohttp.TCPConnector(ssl=False) + if custom_options.get("trust_env"): + opts["trust_env"] = True + return opts diff --git a/UnleashClient/api/packet_building.py b/UnleashClient/api/packet_building.py new file mode 100644 index 00000000..46e99591 --- /dev/null +++ b/UnleashClient/api/packet_building.py @@ -0,0 +1,30 @@ +from datetime import datetime, timezone +from platform import python_implementation, python_version +import yggdrasil_engine +from UnleashClient.constants import ( + CLIENT_SPEC_VERSION, + SDK_NAME, + SDK_VERSION, +) + + +def build_registration_packet( + app_name: str, + instance_id: str, + connection_id: str, + metrics_interval: int, + supported_strategies: dict, +) -> dict: + return { + "appName": app_name, + "instanceId": instance_id, + "connectionId": connection_id, + "sdkVersion": f"{SDK_NAME}:{SDK_VERSION}", + "strategies": [*supported_strategies], + "started": datetime.now(timezone.utc).isoformat(), + "interval": metrics_interval, + "platformName": python_implementation(), + "platformVersion": python_version(), + "yggdrasilVersion": yggdrasil_engine.__yggdrasil_core_version__, + "specVersion": CLIENT_SPEC_VERSION, + } diff --git a/UnleashClient/api/sync_api.py b/UnleashClient/api/sync_api.py index 3b5361c5..2bacb307 100644 --- a/UnleashClient/api/sync_api.py +++ b/UnleashClient/api/sync_api.py @@ -1,28 +1,23 @@ import json -from datetime import datetime, timezone -from platform import python_implementation, python_version from typing import Optional, Tuple import requests -import yggdrasil_engine from requests.adapters import HTTPAdapter from requests.exceptions import InvalidHeader, InvalidSchema, InvalidURL, MissingSchema from urllib3 import Retry +from UnleashClient.api.packet_building import build_registration_packet from UnleashClient.constants import ( APPLICATION_HEADERS, - CLIENT_SPEC_VERSION, FEATURES_URL, METRICS_URL, REGISTER_URL, - SDK_NAME, - SDK_VERSION, ) from UnleashClient.utils import LOGGER, log_resp_info # pylint: disable=broad-except -def register_client( +def register_client_async( url: str, app_name: str, instance_id: str, @@ -50,19 +45,10 @@ def register_client( :param request_timeout: :return: true if registration successful, false if registration unsuccessful or exception. """ - registration_request = { - "appName": app_name, - "instanceId": instance_id, - "connectionId": connection_id, - "sdkVersion": f"{SDK_NAME}:{SDK_VERSION}", - "strategies": [*supported_strategies], - "started": datetime.now(timezone.utc).isoformat(), - "interval": metrics_interval, - "platformName": python_implementation(), - "platformVersion": python_version(), - "yggdrasilVersion": yggdrasil_engine.__yggdrasil_core_version__, - "specVersion": CLIENT_SPEC_VERSION, - } + + registration_request = build_registration_packet( + app_name, instance_id, connection_id, metrics_interval, supported_strategies + ) try: LOGGER.info("Registering unleash client with unleash @ %s", url) diff --git a/tests/unit_tests/api/test_async.api.py b/tests/unit_tests/api/test_async.api.py new file mode 100644 index 00000000..e69de29b From 56321ed733722a6dff0acde1afd174cf9fde3eed Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Tue, 11 Nov 2025 10:21:29 +0200 Subject: [PATCH 2/6] chore: fix imports, some tests for client fetch --- UnleashClient/api/sync_api.py | 2 +- requirements.txt | 2 + tests/unit_tests/api/test_async.api.py | 0 tests/unit_tests/api/test_async.py | 73 ++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) delete mode 100644 tests/unit_tests/api/test_async.api.py create mode 100644 tests/unit_tests/api/test_async.py diff --git a/UnleashClient/api/sync_api.py b/UnleashClient/api/sync_api.py index 2bacb307..088b8bd1 100644 --- a/UnleashClient/api/sync_api.py +++ b/UnleashClient/api/sync_api.py @@ -17,7 +17,7 @@ # pylint: disable=broad-except -def register_client_async( +def register_client( url: str, app_name: str, instance_id: str, diff --git a/requirements.txt b/requirements.txt index 55041ecb..2d17b285 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ launchdarkly-eventsource # Development packages # - Testing +aioresponses black coveralls isort @@ -18,6 +19,7 @@ mimesis==4.1.3 mypy pylint pytest +pytest-asyncio pytest-cov pytest-html pytest-mock diff --git a/tests/unit_tests/api/test_async.api.py b/tests/unit_tests/api/test_async.api.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit_tests/api/test_async.py b/tests/unit_tests/api/test_async.py new file mode 100644 index 00000000..eaab807e --- /dev/null +++ b/tests/unit_tests/api/test_async.py @@ -0,0 +1,73 @@ +import json +import pytest +from aioresponses import aioresponses + +from UnleashClient.api.async_api import get_feature_toggles_async +from UnleashClient.constants import FEATURES_URL + +URL = "https://example.com" +FULL_FEATURE_URL = URL + FEATURES_URL + +APP_NAME = "myapp" +INSTANCE_ID = "iid" +CUSTOM_HEADERS = {"Authorization": "secret"} +CUSTOM_OPTIONS = {} +REQUEST_TIMEOUT = 3 +REQUEST_RETRIES = 3 +ETAG_VALUE = "W/123" +PROJECT_NAME = "default" + +MOCK_FEATURE_RESPONSE = {"version": 1, "features": []} +PROJECT_URL = f"{FULL_FEATURE_URL}?project={PROJECT_NAME}" + +@pytest.mark.asyncio +async def test_get_feature_toggles_success(): + with aioresponses() as m: + m.get(FULL_FEATURE_URL, status=200, payload=MOCK_FEATURE_RESPONSE, headers={"etag": ETAG_VALUE}) + + body, etag = await get_feature_toggles_async( + URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, + REQUEST_TIMEOUT, REQUEST_RETRIES + ) + + assert json.loads(body)["version"] == 1 + assert etag == ETAG_VALUE + +@pytest.mark.asyncio +async def test_get_feature_toggles_project_and_etag_present(): + with aioresponses() as m: + m.get(PROJECT_URL, status=304, headers={"etag": ETAG_VALUE}) + + body, etag = await get_feature_toggles_async( + URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, + REQUEST_TIMEOUT, REQUEST_RETRIES, project=PROJECT_NAME, cached_etag=ETAG_VALUE + ) + + assert body is None + assert etag == ETAG_VALUE + +@pytest.mark.asyncio +async def test_get_feature_toggles_retries_then_success(): + with aioresponses() as m: + m.get(PROJECT_URL, status=500) # first attempt + m.get(PROJECT_URL, status=200, payload=MOCK_FEATURE_RESPONSE, headers={"etag": ETAG_VALUE}) + + body, etag = await get_feature_toggles_async( + URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, + REQUEST_TIMEOUT, request_retries=1, project=PROJECT_NAME, cached_etag=ETAG_VALUE + ) + + assert json.loads(body)["version"] == 1 + assert etag == ETAG_VALUE + +@pytest.mark.asyncio +async def test_get_feature_toggles_failure_after_retries(): + with aioresponses() as m: + m.get(PROJECT_URL, status=500) + m.get(PROJECT_URL, status=500) + body, etag = await get_feature_toggles_async( + URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, + REQUEST_TIMEOUT, request_retries=1, project=PROJECT_NAME + ) + assert body is None + assert etag == "" From c5eb9ed558f98b2af76a65338b84b9a19b1bd0ac Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Tue, 11 Nov 2025 10:44:38 +0200 Subject: [PATCH 3/6] chore: cover register+metrics with tests --- UnleashClient/api/async_api.py | 11 ++- .../{test_async.py => test_async_feature.py} | 0 tests/unit_tests/api/test_async_metrics.py | 54 ++++++++++++ tests/unit_tests/api/test_async_register.py | 86 +++++++++++++++++++ 4 files changed, 148 insertions(+), 3 deletions(-) rename tests/unit_tests/api/{test_async.py => test_async_feature.py} (100%) create mode 100644 tests/unit_tests/api/test_async_metrics.py create mode 100644 tests/unit_tests/api/test_async_register.py diff --git a/UnleashClient/api/async_api.py b/UnleashClient/api/async_api.py index 2b117144..ebc59628 100644 --- a/UnleashClient/api/async_api.py +++ b/UnleashClient/api/async_api.py @@ -4,7 +4,12 @@ import json from UnleashClient.api.packet_building import build_registration_packet -from UnleashClient.constants import APPLICATION_HEADERS, FEATURES_URL, REGISTER_URL +from UnleashClient.constants import ( + APPLICATION_HEADERS, + FEATURES_URL, + REGISTER_URL, + METRICS_URL, +) from UnleashClient.utils import LOGGER @@ -94,11 +99,11 @@ async def send_metrics_async( async with aiohttp.ClientSession(timeout=timeout, **session_kwargs) as session: async with session.post( - url, + url + METRICS_URL, data=json.dumps(request_body), headers={**headers, **APPLICATION_HEADERS}, ) as resp: - if resp.status == 200: + if resp.status == 202: LOGGER.info("Unleash Client metrics successfully sent!") return True diff --git a/tests/unit_tests/api/test_async.py b/tests/unit_tests/api/test_async_feature.py similarity index 100% rename from tests/unit_tests/api/test_async.py rename to tests/unit_tests/api/test_async_feature.py diff --git a/tests/unit_tests/api/test_async_metrics.py b/tests/unit_tests/api/test_async_metrics.py new file mode 100644 index 00000000..363fd4d1 --- /dev/null +++ b/tests/unit_tests/api/test_async_metrics.py @@ -0,0 +1,54 @@ +import json +import pytest +from aioresponses import aioresponses +from aiohttp import ClientConnectionError +from yarl import URL as YURL + +from UnleashClient.api.async_api import send_metrics_async +from UnleashClient.constants import METRICS_URL + +URL = "https://example.com" +FULL_METRICS_URL = URL + METRICS_URL + +MOCK_METRICS_REQUEST = { + "appName": "myapp", + "instanceId": "iid", + "connectionId": "cid-123", + "bucket": { + "start": "2020-01-01T00:00:00Z", + "stop": "2020-01-01T00:01:00Z", + "toggles": {}, + }, +} + +CUSTOM_HEADERS = {"Authorization": "secret"} +CUSTOM_OPTIONS = {} +REQUEST_TIMEOUT = 3 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,inject_exception,expected", + [ + (202, None, True), + (500, None, False), + (200, ClientConnectionError(), False), + ], +) +async def test_send_metrics_async(status, inject_exception, expected): + with aioresponses() as m: + if inject_exception is not None: + m.post(FULL_METRICS_URL, exception=inject_exception) + else: + m.post(FULL_METRICS_URL, status=status) + + ok = await send_metrics_async( + URL, MOCK_METRICS_REQUEST, CUSTOM_HEADERS, CUSTOM_OPTIONS, REQUEST_TIMEOUT + ) + + assert ok is expected + # Ensure we actually attempted one POST + assert ("POST", YURL(FULL_METRICS_URL)) in m.requests + call = m.requests[("POST", YURL(FULL_METRICS_URL))][0] + sent = json.loads(call.kwargs["data"]) + assert sent["connectionId"] == MOCK_METRICS_REQUEST["connectionId"] \ No newline at end of file diff --git a/tests/unit_tests/api/test_async_register.py b/tests/unit_tests/api/test_async_register.py new file mode 100644 index 00000000..69949a90 --- /dev/null +++ b/tests/unit_tests/api/test_async_register.py @@ -0,0 +1,86 @@ +import json +import pytest +from aioresponses import aioresponses +from aiohttp import ClientConnectionError +from yarl import URL as YURL + +from UnleashClient.api.async_api import register_client_async +from UnleashClient.constants import REGISTER_URL, CLIENT_SPEC_VERSION + +BASE_URL = "https://example.com" +FULL_REGISTER_URL = BASE_URL + REGISTER_URL + +APP_NAME = "myapp" +INSTANCE_ID = "iid" +CONNECTION_ID = "cid-123" +METRICS_INTERVAL = 60 +CUSTOM_HEADERS = {"Authorization": "secret"} +CUSTOM_OPTIONS = {} +REQUEST_TIMEOUT = 3 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status,inject_exception,expected", + [ + (202, None, True), + (500, None, False), + (200, ClientConnectionError(), False), + ], +) +async def test_register_client_async(status, inject_exception, expected): + with aioresponses() as m: + if inject_exception is not None: + m.post(FULL_REGISTER_URL, exception=inject_exception) + else: + m.post(FULL_REGISTER_URL, status=status) + + ok = await register_client_async( + BASE_URL, + APP_NAME, + INSTANCE_ID, + CONNECTION_ID, + METRICS_INTERVAL, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + supported_strategies={}, + request_timeout=REQUEST_TIMEOUT, + ) + + assert ok is expected + + assert ("POST", YURL(FULL_REGISTER_URL)) in m.requests + assert len(m.requests[("POST", YURL(FULL_REGISTER_URL))]) == 1 + + +@pytest.mark.asyncio +async def test_register_includes_metadata_async(): + with aioresponses() as m: + m.post(FULL_REGISTER_URL, status=202) + + await register_client_async( + BASE_URL, + APP_NAME, + INSTANCE_ID, + CONNECTION_ID, + METRICS_INTERVAL, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + supported_strategies={}, + request_timeout=REQUEST_TIMEOUT, + ) + + call = m.requests[("POST", YURL(FULL_REGISTER_URL))][0] + payload = json.loads(call.kwargs["data"]) + + assert payload["connectionId"] == CONNECTION_ID + assert payload["specVersion"] == CLIENT_SPEC_VERSION + assert isinstance(payload.get("platformName"), str) and payload["platformName"] + assert ( + isinstance(payload.get("platformVersion"), str) + and payload["platformVersion"] + ) + assert ( + isinstance(payload.get("yggdrasilVersion"), str) + and payload["yggdrasilVersion"] + ) From 9357f227acbb5cb87fedde8f514a91d2d980b626 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 08:46:29 +0000 Subject: [PATCH 4/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- UnleashClient/api/async_api.py | 6 +-- UnleashClient/api/packet_building.py | 2 + tests/unit_tests/api/test_async_feature.py | 60 +++++++++++++++++---- tests/unit_tests/api/test_async_metrics.py | 5 +- tests/unit_tests/api/test_async_register.py | 5 +- 5 files changed, 61 insertions(+), 17 deletions(-) diff --git a/UnleashClient/api/async_api.py b/UnleashClient/api/async_api.py index ebc59628..02789406 100644 --- a/UnleashClient/api/async_api.py +++ b/UnleashClient/api/async_api.py @@ -1,18 +1,18 @@ import asyncio +import json from typing import Any, Mapping, Optional, Tuple + import aiohttp -import json from UnleashClient.api.packet_building import build_registration_packet from UnleashClient.constants import ( APPLICATION_HEADERS, FEATURES_URL, - REGISTER_URL, METRICS_URL, + REGISTER_URL, ) from UnleashClient.utils import LOGGER - _TRANSIENT_ERROR_CODES = {500, 502, 504} diff --git a/UnleashClient/api/packet_building.py b/UnleashClient/api/packet_building.py index 46e99591..2aa7115d 100644 --- a/UnleashClient/api/packet_building.py +++ b/UnleashClient/api/packet_building.py @@ -1,6 +1,8 @@ from datetime import datetime, timezone from platform import python_implementation, python_version + import yggdrasil_engine + from UnleashClient.constants import ( CLIENT_SPEC_VERSION, SDK_NAME, diff --git a/tests/unit_tests/api/test_async_feature.py b/tests/unit_tests/api/test_async_feature.py index eaab807e..d7a0c7c3 100644 --- a/tests/unit_tests/api/test_async_feature.py +++ b/tests/unit_tests/api/test_async_feature.py @@ -1,4 +1,5 @@ import json + import pytest from aioresponses import aioresponses @@ -20,54 +21,93 @@ MOCK_FEATURE_RESPONSE = {"version": 1, "features": []} PROJECT_URL = f"{FULL_FEATURE_URL}?project={PROJECT_NAME}" + @pytest.mark.asyncio async def test_get_feature_toggles_success(): with aioresponses() as m: - m.get(FULL_FEATURE_URL, status=200, payload=MOCK_FEATURE_RESPONSE, headers={"etag": ETAG_VALUE}) + m.get( + FULL_FEATURE_URL, + status=200, + payload=MOCK_FEATURE_RESPONSE, + headers={"etag": ETAG_VALUE}, + ) body, etag = await get_feature_toggles_async( - URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, - REQUEST_TIMEOUT, REQUEST_RETRIES + URL, + APP_NAME, + INSTANCE_ID, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + REQUEST_TIMEOUT, + REQUEST_RETRIES, ) assert json.loads(body)["version"] == 1 assert etag == ETAG_VALUE + @pytest.mark.asyncio async def test_get_feature_toggles_project_and_etag_present(): with aioresponses() as m: m.get(PROJECT_URL, status=304, headers={"etag": ETAG_VALUE}) body, etag = await get_feature_toggles_async( - URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, - REQUEST_TIMEOUT, REQUEST_RETRIES, project=PROJECT_NAME, cached_etag=ETAG_VALUE + URL, + APP_NAME, + INSTANCE_ID, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + REQUEST_TIMEOUT, + REQUEST_RETRIES, + project=PROJECT_NAME, + cached_etag=ETAG_VALUE, ) assert body is None assert etag == ETAG_VALUE + @pytest.mark.asyncio async def test_get_feature_toggles_retries_then_success(): with aioresponses() as m: m.get(PROJECT_URL, status=500) # first attempt - m.get(PROJECT_URL, status=200, payload=MOCK_FEATURE_RESPONSE, headers={"etag": ETAG_VALUE}) + m.get( + PROJECT_URL, + status=200, + payload=MOCK_FEATURE_RESPONSE, + headers={"etag": ETAG_VALUE}, + ) body, etag = await get_feature_toggles_async( - URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, - REQUEST_TIMEOUT, request_retries=1, project=PROJECT_NAME, cached_etag=ETAG_VALUE + URL, + APP_NAME, + INSTANCE_ID, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + REQUEST_TIMEOUT, + request_retries=1, + project=PROJECT_NAME, + cached_etag=ETAG_VALUE, ) assert json.loads(body)["version"] == 1 assert etag == ETAG_VALUE + @pytest.mark.asyncio async def test_get_feature_toggles_failure_after_retries(): with aioresponses() as m: m.get(PROJECT_URL, status=500) m.get(PROJECT_URL, status=500) body, etag = await get_feature_toggles_async( - URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, - REQUEST_TIMEOUT, request_retries=1, project=PROJECT_NAME + URL, + APP_NAME, + INSTANCE_ID, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + REQUEST_TIMEOUT, + request_retries=1, + project=PROJECT_NAME, ) assert body is None assert etag == "" diff --git a/tests/unit_tests/api/test_async_metrics.py b/tests/unit_tests/api/test_async_metrics.py index 363fd4d1..15a1b34a 100644 --- a/tests/unit_tests/api/test_async_metrics.py +++ b/tests/unit_tests/api/test_async_metrics.py @@ -1,7 +1,8 @@ import json + import pytest -from aioresponses import aioresponses from aiohttp import ClientConnectionError +from aioresponses import aioresponses from yarl import URL as YURL from UnleashClient.api.async_api import send_metrics_async @@ -51,4 +52,4 @@ async def test_send_metrics_async(status, inject_exception, expected): assert ("POST", YURL(FULL_METRICS_URL)) in m.requests call = m.requests[("POST", YURL(FULL_METRICS_URL))][0] sent = json.loads(call.kwargs["data"]) - assert sent["connectionId"] == MOCK_METRICS_REQUEST["connectionId"] \ No newline at end of file + assert sent["connectionId"] == MOCK_METRICS_REQUEST["connectionId"] diff --git a/tests/unit_tests/api/test_async_register.py b/tests/unit_tests/api/test_async_register.py index 69949a90..559c6e00 100644 --- a/tests/unit_tests/api/test_async_register.py +++ b/tests/unit_tests/api/test_async_register.py @@ -1,11 +1,12 @@ import json + import pytest -from aioresponses import aioresponses from aiohttp import ClientConnectionError +from aioresponses import aioresponses from yarl import URL as YURL from UnleashClient.api.async_api import register_client_async -from UnleashClient.constants import REGISTER_URL, CLIENT_SPEC_VERSION +from UnleashClient.constants import CLIENT_SPEC_VERSION, REGISTER_URL BASE_URL = "https://example.com" FULL_REGISTER_URL = BASE_URL + REGISTER_URL From a9cd5af150301f098c9152570a4a23dc1ce2ce51 Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Tue, 11 Nov 2025 10:46:30 +0200 Subject: [PATCH 5/6] chore: linting --- tests/unit_tests/api/test_async_feature.py | 59 ++++++++++++++++++---- tests/unit_tests/api/test_async_metrics.py | 2 +- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/tests/unit_tests/api/test_async_feature.py b/tests/unit_tests/api/test_async_feature.py index eaab807e..7ceeea2c 100644 --- a/tests/unit_tests/api/test_async_feature.py +++ b/tests/unit_tests/api/test_async_feature.py @@ -20,54 +20,93 @@ MOCK_FEATURE_RESPONSE = {"version": 1, "features": []} PROJECT_URL = f"{FULL_FEATURE_URL}?project={PROJECT_NAME}" + @pytest.mark.asyncio async def test_get_feature_toggles_success(): with aioresponses() as m: - m.get(FULL_FEATURE_URL, status=200, payload=MOCK_FEATURE_RESPONSE, headers={"etag": ETAG_VALUE}) + m.get( + FULL_FEATURE_URL, + status=200, + payload=MOCK_FEATURE_RESPONSE, + headers={"etag": ETAG_VALUE}, + ) body, etag = await get_feature_toggles_async( - URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, - REQUEST_TIMEOUT, REQUEST_RETRIES + URL, + APP_NAME, + INSTANCE_ID, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + REQUEST_TIMEOUT, + REQUEST_RETRIES, ) assert json.loads(body)["version"] == 1 assert etag == ETAG_VALUE + @pytest.mark.asyncio async def test_get_feature_toggles_project_and_etag_present(): with aioresponses() as m: m.get(PROJECT_URL, status=304, headers={"etag": ETAG_VALUE}) body, etag = await get_feature_toggles_async( - URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, - REQUEST_TIMEOUT, REQUEST_RETRIES, project=PROJECT_NAME, cached_etag=ETAG_VALUE + URL, + APP_NAME, + INSTANCE_ID, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + REQUEST_TIMEOUT, + REQUEST_RETRIES, + project=PROJECT_NAME, + cached_etag=ETAG_VALUE, ) assert body is None assert etag == ETAG_VALUE + @pytest.mark.asyncio async def test_get_feature_toggles_retries_then_success(): with aioresponses() as m: m.get(PROJECT_URL, status=500) # first attempt - m.get(PROJECT_URL, status=200, payload=MOCK_FEATURE_RESPONSE, headers={"etag": ETAG_VALUE}) + m.get( + PROJECT_URL, + status=200, + payload=MOCK_FEATURE_RESPONSE, + headers={"etag": ETAG_VALUE}, + ) body, etag = await get_feature_toggles_async( - URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, - REQUEST_TIMEOUT, request_retries=1, project=PROJECT_NAME, cached_etag=ETAG_VALUE + URL, + APP_NAME, + INSTANCE_ID, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + REQUEST_TIMEOUT, + request_retries=1, + project=PROJECT_NAME, + cached_etag=ETAG_VALUE, ) assert json.loads(body)["version"] == 1 assert etag == ETAG_VALUE + @pytest.mark.asyncio async def test_get_feature_toggles_failure_after_retries(): with aioresponses() as m: m.get(PROJECT_URL, status=500) m.get(PROJECT_URL, status=500) body, etag = await get_feature_toggles_async( - URL, APP_NAME, INSTANCE_ID, CUSTOM_HEADERS, CUSTOM_OPTIONS, - REQUEST_TIMEOUT, request_retries=1, project=PROJECT_NAME + URL, + APP_NAME, + INSTANCE_ID, + CUSTOM_HEADERS, + CUSTOM_OPTIONS, + REQUEST_TIMEOUT, + request_retries=1, + project=PROJECT_NAME, ) assert body is None assert etag == "" diff --git a/tests/unit_tests/api/test_async_metrics.py b/tests/unit_tests/api/test_async_metrics.py index 363fd4d1..b24ec278 100644 --- a/tests/unit_tests/api/test_async_metrics.py +++ b/tests/unit_tests/api/test_async_metrics.py @@ -51,4 +51,4 @@ async def test_send_metrics_async(status, inject_exception, expected): assert ("POST", YURL(FULL_METRICS_URL)) in m.requests call = m.requests[("POST", YURL(FULL_METRICS_URL))][0] sent = json.loads(call.kwargs["data"]) - assert sent["connectionId"] == MOCK_METRICS_REQUEST["connectionId"] \ No newline at end of file + assert sent["connectionId"] == MOCK_METRICS_REQUEST["connectionId"] From 6c6c90cfd4950e42d8194e1654793bb3b8bada10 Mon Sep 17 00:00:00 2001 From: Simon Hornby Date: Tue, 11 Nov 2025 10:56:02 +0200 Subject: [PATCH 6/6] fix: missing return, thank you mypy --- UnleashClient/api/async_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/UnleashClient/api/async_api.py b/UnleashClient/api/async_api.py index 02789406..65532959 100644 --- a/UnleashClient/api/async_api.py +++ b/UnleashClient/api/async_api.py @@ -193,6 +193,7 @@ async def get_feature_toggles_async( "Unleash Client feature fetch failed due to exception: %s", exc ) return None, "" + return None, "" except Exception as exc: LOGGER.exception( "Unleash Client feature fetch failed due to exception: %s", exc