diff --git a/UnleashClient/api/async_api.py b/UnleashClient/api/async_api.py new file mode 100644 index 00000000..65532959 --- /dev/null +++ b/UnleashClient/api/async_api.py @@ -0,0 +1,210 @@ +import asyncio +import json +from typing import Any, Mapping, Optional, Tuple + +import aiohttp + +from UnleashClient.api.packet_building import build_registration_packet +from UnleashClient.constants import ( + APPLICATION_HEADERS, + FEATURES_URL, + METRICS_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 + METRICS_URL, + data=json.dumps(request_body), + headers={**headers, **APPLICATION_HEADERS}, + ) as resp: + if resp.status == 202: + 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, "" + 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..2aa7115d --- /dev/null +++ b/UnleashClient/api/packet_building.py @@ -0,0 +1,32 @@ +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..088b8bd1 100644 --- a/UnleashClient/api/sync_api.py +++ b/UnleashClient/api/sync_api.py @@ -1,22 +1,17 @@ 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 @@ -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/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_feature.py b/tests/unit_tests/api/test_async_feature.py new file mode 100644 index 00000000..d7a0c7c3 --- /dev/null +++ b/tests/unit_tests/api/test_async_feature.py @@ -0,0 +1,113 @@ +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 == "" 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..15a1b34a --- /dev/null +++ b/tests/unit_tests/api/test_async_metrics.py @@ -0,0 +1,55 @@ +import json + +import pytest +from aiohttp import ClientConnectionError +from aioresponses import aioresponses +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"] 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..559c6e00 --- /dev/null +++ b/tests/unit_tests/api/test_async_register.py @@ -0,0 +1,87 @@ +import json + +import pytest +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 CLIENT_SPEC_VERSION, REGISTER_URL + +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"] + )