Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions UnleashClient/_async_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 6 additions & 1 deletion UnleashClient/headers.py → UnleashClient/_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions UnleashClient/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions UnleashClient/clients/async_unleash_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions UnleashClient/clients/unleash_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,7 +43,6 @@
UnleashEventType,
UnleashReadyEvent,
)
from UnleashClient.headers import HeaderFactory
from UnleashClient.impact_metrics import ImpactMetrics
from UnleashClient.utils import (
LOGGER,
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions tests/unit_tests/connectors/test_async_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand Down
4 changes: 2 additions & 2 deletions tests/unit_tests/connectors/test_polling_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/unit_tests/test_async_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
16 changes: 8 additions & 8 deletions tests/unit_tests/test_headers.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
from UnleashClient._headers import _HeaderFactory
from UnleashClient.config import UnleashConfig
from UnleashClient.constants import (
APPLICATION_HEADERS,
CLIENT_SPEC_VERSION,
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()

Expand Down Expand Up @@ -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"}

Expand All @@ -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"

Expand All @@ -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"
Expand All @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions tests/unit_tests/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -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=(
Expand Down
Loading
Loading