diff --git a/CHANGELOG.md b/CHANGELOG.md index a275f12b..201c252a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ * (Minor): Custom strategies are now registered on the engine by `initialize_client()` rather than by the constructor. The `ValueError` raised for a strategy that has no `apply` method, or whose `apply` does not take exactly two parameters, therefore surfaces from `initialize_client()` instead of from `UnleashClient(...)`. Code that passes valid strategies and calls `initialize_client()` before evaluating anything is unaffected; a bootstrapped client that evaluates a custom-strategy flag before initializing now gets `False` for that strategy. * (Minor): New optional `async` extra: `pip install UnleashClient[async]` installs `aiohttp` for the in-progress asynchronous client. The default install is unchanged and pulls in no new packages. The asynchronous client itself is still unfinished and unexported. * (Minor): Outbound HTTP now happens through one internal `_Transport` object, in the private `UnleashClient._transport` module, instead of the three module-level functions in `UnleashClient.api`. The module is not part of the public API and may change or disappear without notice. The requests on the wire (urls, methods, headers, bodies, status handling, the retry adapter on feature fetches, and the fatal-URL exceptions that registration re-raises) are exactly what they were. `UnleashClient.api` and its `get_feature_toggles`, `send_metrics`, `register_client` and `build_normalized_url` are gone, as is `UnleashClient.utils.log_resp_info`; `PollingConnector` now takes a `transport` instead of `url`, `app_name`, `instance_id`, `headers`, `custom_options`, `request_timeout`, `request_retries` and `project`, and `aggregate_and_send_metrics` takes one in first position instead of `url`, `headers`, `custom_options` and `request_timeout`. This only affects code importing from `UnleashClient.api`, `UnleashClient.connectors` or `UnleashClient.periodic_tasks` directly. Nothing changes for code using `UnleashClient`. -* (Minor): `_Transport` asks the internal `HeaderFactory` for the header set each request needs, rather than being handed a dict built once at startup. As a result, reassigning `unleash_url`, `unleash_custom_headers`, `unleash_custom_options`, `unleash_request_timeout`, `unleash_request_retries`, `unleash_project_name`, `unleash_app_name` or `unleash_instance_id` after `initialize_client()` now takes effect on the next poll and the next metrics send. Those used to be captured when the connector and the metrics job were created, and changes to them afterwards were silently ignored. Registration always read them at call time and is unaffected. The headers on the wire are otherwise unchanged. +* (Minor): `_Transport` asks the internal `_HeaderFactory`, in the private `UnleashClient._headers` module, which is not part of the public API and may change or disappear without notice, for the header set each request needs, rather than being handed a dict built once at startup. As a result, reassigning `unleash_url`, `unleash_custom_headers`, `unleash_custom_options`, `unleash_request_timeout`, `unleash_request_retries`, `unleash_project_name`, `unleash_app_name` or `unleash_instance_id` after `initialize_client()` now takes effect on the next poll and the next metrics send. Those used to be captured when the connector and the metrics job were created, and changes to them afterwards were silently ignored. Registration always read them at call time and is unaffected. The headers on the wire are otherwise unchanged. * (Minor): The `metrics_headers` attribute has been removed from `UnleashClient`. It used to hold the header dict handed to the metrics job at `initialize_client()`, so reassigning it changed what the metrics request sent; the `_Transport` now builds that header set per request. Set `unleash_custom_headers` instead, as it is read on every send. * (Minor): The registration request body is now assembled by `_build_register_payload` in the new private `UnleashClient._payloads` module rather than inline in `register_client`. The module is not part of the public API and may change or disappear without notice. The fields sent are unchanged, `started` is still stamped at the moment of the request, and nothing changes for code using `UnleashClient`. * (Minor): Job scheduling now happens through one internal `_Scheduler` object, in the private `UnleashClient._scheduler` module, instead of being re-implemented by the client and each connector. The module is not part of the public API and may change or disappear without notice. The jobs, intervals, jitter and executors are exactly what they were. `PollingConnector` and `OfflineConnector` now take that `_Scheduler` rather than an APScheduler instance, and no longer take `scheduler_executor`, so code importing from `UnleashClient.connectors` directly must build one. The `scheduler` and `scheduler_executor` constructor arguments, and the `unleash_scheduler` and `unleash_executor_name` attributes, are unchanged. @@ -35,7 +35,7 @@ * (Minor): A `fallback_function` that raises now results in `False` and a logged warning, instead of the exception propagating out of `is_enabled()`. The toggle is not counted in that case. * (Minor): Event callbacks are now invoked on a dedicated background thread instead of on whichever thread produced the event. `is_enabled()` and `get_variant()` no longer wait for your callback, so a slow callback can't hold up flag evaluation. Three consequences worth knowing about: callbacks can no longer read thread local state from the caller (Flask `g`, the current Django request, contextvars); they return before the callback has run, so tests asserting straight after the call now need to wait; and reassigning `unleash_event_callback` after construction is no longer honoured. * (Minor): Connectors take an `EventDispatcher` instead of `ready_callback`/`event_callback`. These classes aren't part of the documented API, so this only affects code importing from `UnleashClient.connectors` directly. -* (Minor): Request headers are now assembled once, by an internal `HeaderFactory`, and passed to each collaborator complete. The headers on the wire are unchanged. `PollingConnector` and `StreamingConnector` no longer add `unleash-interval` and `Accept`/`Content-Type`/`Unleash-Client-Spec` themselves, so code importing from `UnleashClient.connectors` directly must now supply complete headers. Nothing changes for code using `UnleashClient`. +* (Minor): Request headers are now assembled once, by an internal `_HeaderFactory`, in the private `UnleashClient._headers` module, which is not part of the public API and may change or disappear without notice, and passed to each collaborator complete. The headers on the wire are unchanged. `PollingConnector` and `StreamingConnector` no longer add `unleash-interval` and `Accept`/`Content-Type`/`Unleash-Client-Spec` themselves, so code importing from `UnleashClient.connectors` directly must now supply complete headers. Nothing changes for code using `UnleashClient`. * (Minor): Applying feature state (the cache write, the handover to the engine, and the READY and FETCHED events that follow) now happens in one internal `_FeatureStore`, in the private `UnleashClient._feature_store` module, instead of being re-implemented by each connector. The module is not part of the public API and may change or disappear without notice. The cache writes, engine updates and events are exactly what they were. Connectors now take a `store` instead of `engine`, `cache` and `events`, and `BaseConnector.load_features()` is gone, so code importing from `UnleashClient.connectors` directly must build a `_FeatureStore` and call `store.load_from_cache()`. Nothing changes for code using `UnleashClient`. * (Minor): The `engine` and `cache` attributes are gone from `UnleashClient`. Both were always configured through the constructor (pass `cache=` to supply your own), and neither appears in the documented API. * (Bugfix): `refresh_jitter` now reaches the polling job. It was accepted by the constructor, documented, and applied to the offline refresh job, but never passed to the polling connector, so jitter was silently dropped in the default polling mode. diff --git a/UnleashClient/_async_transport.py b/UnleashClient/_async_transport.py index 359a70e3..66e21257 100644 --- a/UnleashClient/_async_transport.py +++ b/UnleashClient/_async_transport.py @@ -5,11 +5,11 @@ import threading from typing import Any, Dict, Optional +from UnleashClient._headers import _HeaderFactory from UnleashClient._transport import FetchResult, _normalized_url from UnleashClient.config import UnleashConfig from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL from UnleashClient.errors import AlreadyClosedError -from UnleashClient.headers import HeaderFactory from UnleashClient.utils import LOGGER try: @@ -37,13 +37,13 @@ async def _log_resp_info(resp: "aiohttp.ClientResponse") -> None: class _AsyncTransport: """The asyncio twin of :class:`UnleashClient._transport._Transport`.""" - def __init__(self, config: UnleashConfig, headers: HeaderFactory) -> None: + def __init__(self, config: UnleashConfig, headers: _HeaderFactory) -> None: """ :param config: read for the url, timeouts, retries and project. :param headers: builds the header set each request needs. """ self._config: UnleashConfig = config - self._headers: HeaderFactory = headers + self._headers: _HeaderFactory = headers self._session: Optional["aiohttp.ClientSession"] = None self._is_closed: bool = False self._close_lock = threading.Lock() diff --git a/UnleashClient/headers.py b/UnleashClient/_headers.py similarity index 93% rename from UnleashClient/headers.py rename to UnleashClient/_headers.py index 5c9378fe..1394c0a6 100644 --- a/UnleashClient/headers.py +++ b/UnleashClient/_headers.py @@ -6,7 +6,7 @@ from UnleashClient.constants import APPLICATION_HEADERS, SDK_NAME, SDK_VERSION -class HeaderFactory: +class _HeaderFactory: """ Builds the header sets the SDK sends to Unleash. @@ -17,6 +17,11 @@ class HeaderFactory: it once per request, so a change reaches the wire on the next poll or send. The client still calls ``streaming()`` once, at initialization, and hands the dict to the connector; that one keeps the headers it was given. + + Example:: + + headers = _HeaderFactory(config) + response = requests.get(url, headers=headers.polling()) """ def __init__(self, config: UnleashConfig) -> None: diff --git a/UnleashClient/_transport.py b/UnleashClient/_transport.py index f1d81caa..bdf0ff02 100644 --- a/UnleashClient/_transport.py +++ b/UnleashClient/_transport.py @@ -7,9 +7,9 @@ from requests.exceptions import InvalidHeader, InvalidSchema, InvalidURL, MissingSchema from urllib3 import Retry +from UnleashClient._headers import _HeaderFactory from UnleashClient.config import UnleashConfig from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL -from UnleashClient.headers import HeaderFactory from UnleashClient.utils import LOGGER @@ -52,9 +52,9 @@ class _Transport: :param headers: Builds the header set each endpoint needs. """ - def __init__(self, config: UnleashConfig, headers: HeaderFactory) -> None: + def __init__(self, config: UnleashConfig, headers: _HeaderFactory) -> None: self._config: UnleashConfig = config - self._headers: HeaderFactory = headers + self._headers: _HeaderFactory = headers # pylint: disable=broad-except def fetch_features(self, etag: str = "") -> FetchResult: diff --git a/UnleashClient/clients/async_unleash_client.py b/UnleashClient/clients/async_unleash_client.py index 712be157..f6b76ceb 100644 --- a/UnleashClient/clients/async_unleash_client.py +++ b/UnleashClient/clients/async_unleash_client.py @@ -12,13 +12,13 @@ from UnleashClient._context import _ContextEnricher from UnleashClient._evaluator import _Evaluator from UnleashClient._feature_store import _FeatureStore +from UnleashClient._headers import _HeaderFactory from UnleashClient._instance_registry import _get_instance_registry from UnleashClient._metrics import _AsyncMetricsReporter from UnleashClient.cache import BaseCache, FileCache from UnleashClient.config import ExperimentalMode, UnleashConfig from UnleashClient.constants import REQUEST_RETRIES, REQUEST_TIMEOUT from UnleashClient.events import BaseEvent, EventDispatcher -from UnleashClient.headers import HeaderFactory from UnleashClient.impact_metrics import ImpactMetrics from UnleashClient.utils import InstanceAllowType @@ -85,7 +85,7 @@ def __init__( # noqa: PLR0913, PLR0917 custom_strategies=custom_strategies, ) self._enricher: _ContextEnricher = _ContextEnricher(self._config) - self._headers: HeaderFactory = HeaderFactory(self._config) + self._headers: _HeaderFactory = _HeaderFactory(self._config) self._event_dispatcher: Optional[EventDispatcher] = ( EventDispatcher(event_callback) if event_callback is not None else None diff --git a/UnleashClient/clients/unleash_client.py b/UnleashClient/clients/unleash_client.py index 4d29714e..ac7f2e6f 100644 --- a/UnleashClient/clients/unleash_client.py +++ b/UnleashClient/clients/unleash_client.py @@ -12,6 +12,7 @@ from UnleashClient._context import _ContextEnricher from UnleashClient._evaluator import _Evaluator from UnleashClient._feature_store import _FeatureStore +from UnleashClient._headers import _HeaderFactory from UnleashClient._instance_registry import _get_instance_registry from UnleashClient._metrics import _MetricsReporter from UnleashClient._payloads import _build_register_payload @@ -42,7 +43,6 @@ UnleashEventType, UnleashReadyEvent, ) -from UnleashClient.headers import HeaderFactory from UnleashClient.impact_metrics import ImpactMetrics from UnleashClient.utils import ( LOGGER, @@ -178,7 +178,7 @@ def __init__( # noqa: PLR0913, PLR0917 custom_strategies=custom_strategies, ) self._enricher = _ContextEnricher(self._config) - self._headers = HeaderFactory(self._config) + self._headers = _HeaderFactory(self._config) self.unleash_event_callback = event_callback # Events are handed to the dispatcher, which delivers them to the user's # callback on its own thread. The callback is never called from here. diff --git a/tests/unit_tests/connectors/test_async_connector.py b/tests/unit_tests/connectors/test_async_connector.py index dc5229d0..b751abc0 100644 --- a/tests/unit_tests/connectors/test_async_connector.py +++ b/tests/unit_tests/connectors/test_async_connector.py @@ -12,11 +12,11 @@ from tests.utilities.testing_constants import APP_NAME, ETAG_VALUE from UnleashClient._async_transport import _AsyncTransport from UnleashClient._feature_store import _FeatureStore +from UnleashClient._headers import _HeaderFactory from UnleashClient.config import UnleashConfig from UnleashClient.connectors._async_connector import _AsyncPollingConnector from UnleashClient.constants import ETAG, FEATURES_URL from UnleashClient.events import EventDispatcher, UnleashEventType -from UnleashClient.headers import HeaderFactory API_PREFIX = "/api" FEATURES_PATH = API_PREFIX + FEATURES_URL @@ -43,7 +43,7 @@ def _build_connector( store: _FeatureStore, refresh_interval: float = INTERVAL ) -> _AsyncPollingConnector: config = UnleashConfig(server.base_url, APP_NAME, request_retries=0) - transport = _AsyncTransport(config, HeaderFactory(config)) + transport = _AsyncTransport(config, _HeaderFactory(config)) connector = _AsyncPollingConnector( store=store, transport=transport, refresh_interval=refresh_interval ) diff --git a/tests/unit_tests/connectors/test_polling_connector.py b/tests/unit_tests/connectors/test_polling_connector.py index 9b79498d..b39d6fb0 100644 --- a/tests/unit_tests/connectors/test_polling_connector.py +++ b/tests/unit_tests/connectors/test_polling_connector.py @@ -19,13 +19,13 @@ URL, ) from UnleashClient._feature_store import _FeatureStore +from UnleashClient._headers import _HeaderFactory from UnleashClient._scheduler import _Scheduler from UnleashClient._transport import _Transport from UnleashClient.config import UnleashConfig from UnleashClient.connectors import PollingConnector from UnleashClient.constants import ETAG, FEATURES_URL from UnleashClient.events import EventDispatcher, UnleashEventType -from UnleashClient.headers import HeaderFactory FULL_FEATURE_URL = URL + FEATURES_URL @@ -40,7 +40,7 @@ def build_transport(**kwargs) -> _Transport: } defaults.update(kwargs) config = UnleashConfig(URL, APP_NAME, **defaults) - return _Transport(config, HeaderFactory(config)) + return _Transport(config, _HeaderFactory(config)) @responses.activate diff --git a/tests/unit_tests/test_async_transport.py b/tests/unit_tests/test_async_transport.py index cfb9b82a..dc91237c 100644 --- a/tests/unit_tests/test_async_transport.py +++ b/tests/unit_tests/test_async_transport.py @@ -23,6 +23,7 @@ REQUEST_TIMEOUT, ) from UnleashClient._async_transport import _AsyncTransport +from UnleashClient._headers import _HeaderFactory from UnleashClient.config import UnleashConfig from UnleashClient.constants import ( CLIENT_SPEC_VERSION, @@ -31,7 +32,6 @@ REGISTER_URL, ) from UnleashClient.errors import AlreadyClosedError -from UnleashClient.headers import HeaderFactory API_PREFIX = "/api" FEATURES_PATH = API_PREFIX + FEATURES_URL @@ -67,7 +67,7 @@ def _build_transport(**kwargs) -> _AsyncTransport: } defaults.update(kwargs) config = UnleashConfig(server.base_url, APP_NAME, **defaults) - transport = _AsyncTransport(config, HeaderFactory(config)) + transport = _AsyncTransport(config, _HeaderFactory(config)) built.append(transport) return transport diff --git a/tests/unit_tests/test_headers.py b/tests/unit_tests/test_headers.py index 89793067..dcc276ea 100644 --- a/tests/unit_tests/test_headers.py +++ b/tests/unit_tests/test_headers.py @@ -1,3 +1,4 @@ +from UnleashClient._headers import _HeaderFactory from UnleashClient.config import UnleashConfig from UnleashClient.constants import ( APPLICATION_HEADERS, @@ -5,19 +6,18 @@ SDK_NAME, SDK_VERSION, ) -from UnleashClient.headers import HeaderFactory TEST_URL = "http://localhost:4242/api" TEST_APP_NAME = "pytest" -def build_factory(**kwargs) -> HeaderFactory: - return HeaderFactory(UnleashConfig(TEST_URL, TEST_APP_NAME, **kwargs)) +def build_factory(**kwargs) -> _HeaderFactory: + return _HeaderFactory(UnleashConfig(TEST_URL, TEST_APP_NAME, **kwargs)) def test_base_carries_the_identification_headers(): config = UnleashConfig(TEST_URL, TEST_APP_NAME, instance_id="123") - factory = HeaderFactory(config) + factory = _HeaderFactory(config) headers = factory.base() @@ -64,7 +64,7 @@ def test_custom_headers_are_read_on_every_call(): # UnleashClient.unleash_custom_headers has a setter, so a client can swap # the dict out after the factory was constructed. config = UnleashConfig(TEST_URL, TEST_APP_NAME) - factory = HeaderFactory(config) + factory = _HeaderFactory(config) config.custom_headers = {"Authorization": "replaced"} @@ -73,7 +73,7 @@ def test_custom_headers_are_read_on_every_call(): def test_custom_headers_mutated_in_place_are_picked_up(): config = UnleashConfig(TEST_URL, TEST_APP_NAME, custom_headers={"name": "header"}) - factory = HeaderFactory(config) + factory = _HeaderFactory(config) config.custom_headers["extra"] = "another" @@ -82,7 +82,7 @@ def test_custom_headers_mutated_in_place_are_picked_up(): def test_identity_is_read_on_every_call(): config = UnleashConfig(TEST_URL, TEST_APP_NAME) - factory = HeaderFactory(config) + factory = _HeaderFactory(config) config.app_name = "renamed" config.instance_id = "456" @@ -95,7 +95,7 @@ def test_identity_is_read_on_every_call(): def test_each_call_returns_a_fresh_dict(): # The returned dicts are handed to collaborators that hold on to them. config = UnleashConfig(TEST_URL, TEST_APP_NAME, custom_headers={"name": "header"}) - factory = HeaderFactory(config) + factory = _HeaderFactory(config) first = factory.base() first["injected"] = "value" diff --git a/tests/unit_tests/test_metrics.py b/tests/unit_tests/test_metrics.py index 3de62eab..c238580b 100644 --- a/tests/unit_tests/test_metrics.py +++ b/tests/unit_tests/test_metrics.py @@ -12,12 +12,12 @@ from tests.utilities.fake_unleash_server import FakeUnleash from UnleashClient._async_scheduler import _AsyncJob, _AsyncJobFn, _AsyncScheduler from UnleashClient._async_transport import _AsyncTransport +from UnleashClient._headers import _HeaderFactory from UnleashClient._metrics import _AsyncMetricsReporter, _MetricsReporter from UnleashClient._scheduler import _Scheduler from UnleashClient._transport import _Transport from UnleashClient.config import UnleashConfig from UnleashClient.constants import CLIENT_SPEC_VERSION, METRICS_URL -from UnleashClient.headers import HeaderFactory from UnleashClient.impact_metrics import ImpactMetrics URL = "http://localhost:4242/api" @@ -121,7 +121,7 @@ def build_sync_reporter( engine = UnleashEngine() return _MetricsReporter( config=config, - transport=_Transport(config, HeaderFactory(config)), + transport=_Transport(config, _HeaderFactory(config)), scheduler=scheduler if scheduler is not None else _Scheduler(), engine=engine, impact_metrics=( @@ -436,7 +436,7 @@ def _build_reporter(impact_metrics=None, **kwargs) -> _AsyncMetricsReporter: engine = UnleashEngine() reporter = _AsyncMetricsReporter( config=config, - transport=_AsyncTransport(config, HeaderFactory(config)), + transport=_AsyncTransport(config, _HeaderFactory(config)), scheduler=RecordingAsyncScheduler(), engine=engine, impact_metrics=( diff --git a/tests/unit_tests/test_transport.py b/tests/unit_tests/test_transport.py index 664a3144..58e17661 100644 --- a/tests/unit_tests/test_transport.py +++ b/tests/unit_tests/test_transport.py @@ -23,6 +23,7 @@ REQUEST_TIMEOUT, URL, ) +from UnleashClient._headers import _HeaderFactory from UnleashClient._transport import _Transport from UnleashClient.config import UnleashConfig from UnleashClient.constants import ( @@ -31,7 +32,6 @@ METRICS_URL, REGISTER_URL, ) -from UnleashClient.headers import HeaderFactory FULL_FEATURE_URL = URL + FEATURES_URL FULL_REGISTER_URL = URL + REGISTER_URL @@ -52,7 +52,7 @@ def _build_transport(**kwargs) -> _Transport: } defaults.update(kwargs) config = UnleashConfig(URL, APP_NAME, **defaults) - return _Transport(config, HeaderFactory(config)) + return _Transport(config, _HeaderFactory(config)) return _build_transport