From b3e97b8625e7d48223b987e1531c5a72bed89ee9 Mon Sep 17 00:00:00 2001 From: Alexandra Szanto Date: Thu, 5 Nov 2026 12:41:04 +0100 Subject: [PATCH 1/3] feat: public sync transport --- CHANGELOG.md | 1 + README.md | 122 +++++++++++++ UnleashClient/__init__.py | 1 + UnleashClient/_aiohttp_transport.py | 2 +- UnleashClient/_async_unleash_api_client.py | 2 +- UnleashClient/_retry.py | 2 +- UnleashClient/_transport.py | 108 ----------- UnleashClient/_unleash_api_client.py | 17 +- UnleashClient/clients/unleash_client.py | 36 +++- UnleashClient/transport.py | 169 ++++++++++++++++++ examples/fake_transport.py | 44 +++++ examples/httpx_transport.py | 38 ++++ pyproject.toml | 2 +- requirements.txt | 1 + .../clients/test_unleash_client_transport.py | 163 +++++++++++++++++ tests/unit_tests/test_aiohttp_transport.py | 2 +- .../test_async_unleash_api_client.py | 2 +- tests/unit_tests/test_retry.py | 2 +- tests/unit_tests/test_transport.py | 91 +++++++++- tests/unit_tests/test_unleash_api_client.py | 19 +- 20 files changed, 700 insertions(+), 124 deletions(-) delete mode 100644 UnleashClient/_transport.py create mode 100644 UnleashClient/transport.py create mode 100644 examples/fake_transport.py create mode 100644 examples/httpx_transport.py create mode 100644 tests/unit_tests/clients/test_unleash_client_transport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ea5af6..685741ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * (Major): `initialize_client()` no longer raises when registration fails. A failed registration is logged and the client keeps running, the same way for both clients. * (Major): Upgraded to yggdrasil-engine 3, new engine built with PyO3 for better performance. * (Minor): Feature fetches are retried the same way by both clients: on a 500, 502 or 504, or when no response arrives, with a short randomised backoff between attempts (up to about 0.5s, 1s and 2s). Before, retries were immediate. +* (Minor): `UnleashClient` takes `transport=` to choose how its requests are sent: a `RequestsTransport` with your own `requests.Session`, for a proxy, TLS settings or connection reuse, or any object with an `execute(Request) -> Response` method, for another HTTP library. See [custom HTTP transport](./README.md#custom-http-transport). * (Minor): New `FETCH_COMPLETED` event, emitted after every feature fetch with how it ended: `CHANGED`, `NOT_CHANGED`, `UNAUTHORIZED`, `RATE_LIMITED`, `UNAVAILABLE` or `INVALID_RESPONSE`. * (Minor): `feature_definitions()` reports whether each feature is enabled in the environment. * (Minor): New `evaluate_all_toggles()` evaluates every feature for one context in a single call, without recording usage metrics. diff --git a/README.md b/README.md index 2facef43..a623829b 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ Settings can't be changed after the client is created. | scheduler_executor | Name of the executor to use on your custom `scheduler`. Required with `scheduler`. Sync client only. | None | | multiple_instance_mode | How to handle multiple client instances (`BLOCK`, `WARN`, `SILENTLY_ALLOW`). | `WARN` | | event_callback | Function that receives impression and lifecycle events. See [events and impression data](#events-and-impression-data). | None | +| transport | How requests are sent. Can't be combined with `http` (which will be removed). Sync client only yet. See [custom HTTP transport](#custom-http-transport). | `RequestsTransport` | #### `IdentityOptions` @@ -468,6 +469,127 @@ client = UnleashClient( ) ``` +### Custom HTTP transport + +By default, `UnleashClient` sends its requests with `requests`, on a new session for every request. Pass `transport=` to choose how requests are sent: + +- `RequestsTransport(session)` to keep `requests` but configure the session yourself; +- any object with an `execute(request) -> Response` method, to use another HTTP libraryl. + +#### Your own `requests` session + +The session is used for every request and never closed by the client: close it yourself after `client.destroy()`. + +```python +import requests +from UnleashClient import RequestsTransport, UnleashClient + +session = requests.Session() + +# examples +# proxy +session.proxies = {"https": "http://proxy.internal:3128"} + +# A private CA, or a client certificate for mutual TLS +session.verify = "/etc/ssl/certs/internal-ca.pem" +session.cert = ("/etc/unleash/client.crt", "/etc/unleash/client.key") + +# hooks +session.hooks["response"].append( + lambda resp, *args, **kwargs: metrics.observe(resp.elapsed.total_seconds()) +) + +client = UnleashClient( + url="https://YOUR-API-URL", + app_name="my-python-app", + api_key="", + transport=RequestsTransport( + session, + headers={"X-Team": "checkout"}, # sent with every request + timeout=10, # seconds, for the connection and each read + ), +) +``` + +A session also keeps connections open between polls, instead of opening a new one each time. + +`RequestsTransport` takes `headers` and `timeout` with or without a session. The SDK's own headers take precedence over custom ones, and an `Authorization` header raises `ValueError`: pass the API token as `api_key`. + +#### Another HTTP library + +A transport receives a `Request` (`method`, `url`, `headers`, `params`, `body`) and returns a `Response` (`status`, `headers`, `body` as bytes). Every status is a response: raise only when no response arrived, such as on a connection error or a timeout. The client logs the error and reports a failed request. For example, with [httpx](https://www.python-httpx.org/): + +```python +import httpx +from UnleashClient import Request, Response, UnleashClient + +class HttpxTransport: + def __init__(self, client: httpx.Client) -> None: + self._client = client + + def execute(self, request: Request) -> Response: + resp = self._client.request( + request.method, + request.url, + headers=dict(request.headers), + params=dict(request.params), + content=request.body, + ) + return Response(status=resp.status_code, headers=resp.headers, body=resp.content) + +with httpx.Client(timeout=30) as http_client: + client = UnleashClient( + url="https://YOUR-API-URL", + app_name="my-python-app", + api_key="", + transport=HttpxTransport(http_client), + ) +``` + +The response headers can be a plain `dict`: the client looks them up case-insensitively. + +#### Tests without a server + +A fake transport lets your tests choose the flags without a server or HTTP mocking: + +```python +import json +from UnleashClient import Request, Response, UnleashClient + +class FakeTransport: + def __init__(self, *flags): + self._features = json.dumps({"version": 1, "features": list(flags)}).encode() + self.requests = [] # what the SDK sent + + def execute(self, request: Request) -> Response: + self.requests.append(request) + if request.method == "GET": + return Response(status=200, headers={}, body=self._features) + return Response(status=202, headers={}, body=b"") # registration and metrics + +def flag(name, enabled): + return {"name": name, "enabled": enabled, "strategies": [{"name": "default", "parameters": {}}]} + +def test_new_checkout_is_on(tmp_path): + transport = FakeTransport(flag("new-checkout", True)) + + with UnleashClient( + "http://unleash.test/api", "my-app-tests", + transport=transport, cache_directory=str(tmp_path), + ) as client: + assert client.is_enabled("new-checkout") +``` + +Both examples are in [`examples/`](examples/) and run as part of the SDK's tests. + +#### Things to know + +- **Don't retry in your transport.** The client already retries failed feature fetches, with a backoff. A session with a retrying adapter mounted logs a warning, because failed fetches would be retried twice over. +- **Don't log request headers.** They include your API token. +- **Don't add an HTTP cache.** The client already sends `If-None-Match` and handles `304 Not Modified`. A caching session (such as CacheControl or requests-cache) turns each 304 into its cached 200, so the client reloads unchanged flags on every poll. The flags stay correct. +- **Create the session in the process that uses it.** With a forking server, such as gunicorn with `--preload` or uWSGI without `lazy-apps`, build the session together with the client in each worker. Forked processes must not share open connections. See [running in multi-process setups](#running-in-multi-process-setups). +- **Streaming mode doesn't use the transport.** With `mode=StreamingOptions()`, the stream connects with the client's default HTTP settings. + ## Running in multi-process setups The Python SDK runs a background thread to keep feature flags in sync with the Unleash server. Some runtime environments, like WSGI servers and Celery workers, need extra setup to make sure the SDK works correctly. diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index be1635c5..495a7fe5 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -13,6 +13,7 @@ PollingOptions, StreamingOptions, ) +from .transport import Request, RequestsTransport, Response, Transport if TYPE_CHECKING: from .clients.async_unleash_client import AsyncUnleashClient diff --git a/UnleashClient/_aiohttp_transport.py b/UnleashClient/_aiohttp_transport.py index 8ae90da7..e34aa0bb 100644 --- a/UnleashClient/_aiohttp_transport.py +++ b/UnleashClient/_aiohttp_transport.py @@ -5,9 +5,9 @@ from typing import Optional -from UnleashClient._transport import Request, Response from UnleashClient.constants import REQUEST_TIMEOUT from UnleashClient.errors import AlreadyClosedError +from UnleashClient.transport import Request, Response try: import aiohttp diff --git a/UnleashClient/_async_unleash_api_client.py b/UnleashClient/_async_unleash_api_client.py index 6b08b4c7..6e71226b 100644 --- a/UnleashClient/_async_unleash_api_client.py +++ b/UnleashClient/_async_unleash_api_client.py @@ -9,7 +9,6 @@ from UnleashClient._event_dispatcher import _EventDispatcher from UnleashClient._headers import _HeaderFactory from UnleashClient._retry import _send_with_retries_async -from UnleashClient._transport import AsyncTransport, Request from UnleashClient._unleash_api_client import ( FetchResult, _emit_fetch_completed, @@ -23,6 +22,7 @@ from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL from UnleashClient.errors import AlreadyClosedError from UnleashClient.events import FetchStatus +from UnleashClient.transport import AsyncTransport, Request from UnleashClient.utils import LOGGER diff --git a/UnleashClient/_retry.py b/UnleashClient/_retry.py index 8cbcbcf6..f382d1e2 100644 --- a/UnleashClient/_retry.py +++ b/UnleashClient/_retry.py @@ -10,8 +10,8 @@ import time from typing import Union -from UnleashClient._transport import AsyncTransport, Request, Response, Transport from UnleashClient.errors import AlreadyClosedError +from UnleashClient.transport import AsyncTransport, Request, Response, Transport from UnleashClient.utils import LOGGER RETRY_STATUSES = frozenset({500, 502, 504}) diff --git a/UnleashClient/_transport.py b/UnleashClient/_transport.py deleted file mode 100644 index 4fb38ecb..00000000 --- a/UnleashClient/_transport.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -HTTP layer under the Unleash API client: sends one request and returns the -response. -""" - -from dataclasses import dataclass, field -from typing import Any, Mapping, Optional, Protocol, runtime_checkable - -import requests - -from UnleashClient.constants import REQUEST_TIMEOUT - - -@dataclass(frozen=True, kw_only=True) -class Request: - """ - HTTP request. - - :param method: ``"GET"`` or ``"POST"``. - :param url: Absolute url, without the query string. - :param headers: Headers to send. - :param params: Query string parameters. - :param body: Request body, or None to send none. - """ - - method: str - url: str - headers: Mapping[str, str] - params: Mapping[str, str] = field(default_factory=dict) - body: Optional[bytes] = None - - -@dataclass(frozen=True, kw_only=True) -class Response: - """ - HTTP response. Every status is a response, not an error: deciding what - a status means is the API client's job. - - :param status: HTTP status code. - :param headers: Response headers, looked up case-insensitively. - :param body: Raw response body. - """ - - status: int - headers: Mapping[str, str] - body: bytes - - -@runtime_checkable -class Transport(Protocol): - """Sends requests for the sync client.""" - - def execute(self, request: Request) -> Response: - """ - Send ``request`` once and return the response, whatever its status. - - :raises Exception: only when no response arrived, e.g. a connection - error or a timeout. - """ - - -@runtime_checkable -class AsyncTransport(Protocol): - """Sends requests for the async client.""" - - async def execute(self, request: Request) -> Response: - """ - Send ``request`` once and return the response, whatever its status. - - :raises Exception: only when no response arrived, e.g. a connection - error or a timeout. - """ - - -class RequestsTransport: - """ - Sends requests with ``requests``, on a fresh session for every request. - - Sends each request once: retrying is the API client's job. - - :param timeout: Seconds to wait for the connection and for each read. - :param _request_kwargs: extra keyword arguments for every ``requests`` call. - """ - - def __init__( - self, - *, - timeout: float = REQUEST_TIMEOUT, - # going away with HttpOptions - _request_kwargs: Optional[Mapping[str, Any]] = None, - ) -> None: - self._timeout = timeout - self._request_kwargs = dict(_request_kwargs or {}) - - def execute(self, request: Request) -> Response: - kwargs: dict = {"headers": dict(request.headers), "timeout": self._timeout} - if request.params: - kwargs["params"] = dict(request.params) - if request.body is not None: - kwargs["data"] = request.body - - with requests.Session() as session: - resp = session.request( - request.method, request.url, **kwargs, **self._request_kwargs - ) - return Response( - status=resp.status_code, headers=resp.headers, body=resp.content - ) diff --git a/UnleashClient/_unleash_api_client.py b/UnleashClient/_unleash_api_client.py index 77696373..4888ad3b 100644 --- a/UnleashClient/_unleash_api_client.py +++ b/UnleashClient/_unleash_api_client.py @@ -5,7 +5,6 @@ from UnleashClient._event_dispatcher import _EventDispatcher from UnleashClient._headers import _HeaderFactory from UnleashClient._retry import _send_with_retries -from UnleashClient._transport import Request, RequestsTransport, Response, Transport from UnleashClient.config import PollingOptions, UnleashConfig from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL from UnleashClient.events import ( @@ -13,6 +12,7 @@ UnleashEventType, UnleashFetchCompletedEvent, ) +from UnleashClient.transport import Request, RequestsTransport, Response, Transport from UnleashClient.utils import LOGGER @@ -74,6 +74,14 @@ def _normalized_url(url: str, path: str) -> str: return f"{url}{path}" +def _header(resp: Response, name: str) -> str: + name = name.lower() + for key, value in resp.headers.items(): + if key.lower() == name: + return value + return "" + + def _text(resp: Response) -> str: return resp.body.decode("utf-8", errors="replace") @@ -94,7 +102,8 @@ class _UnleashApiClient: :param headers: Builds the header set each endpoint needs. :param events: Receives ``FETCH_COMPLETED`` after every feature fetch. :param transport: Sends the requests. Defaults to a :class:`RequestsTransport` - built from ``config.http``. + built from ``config.http``. A transport passed in is never + closed: whoever created it closes it. """ def __init__( @@ -182,9 +191,7 @@ def _fetch_features( "Unleash Client feature fetch failed!" ) # pylint: disable=broad-exception-raised - fetched_etag = "" - if "etag" in resp.headers.keys(): - fetched_etag = resp.headers["etag"] + fetched_etag = _header(resp, "etag") if resp.status == 304: return ( diff --git a/UnleashClient/clients/unleash_client.py b/UnleashClient/clients/unleash_client.py index b62af5cb..e78e84d7 100644 --- a/UnleashClient/clients/unleash_client.py +++ b/UnleashClient/clients/unleash_client.py @@ -1,4 +1,5 @@ # pylint: disable=invalid-name +import inspect import threading import uuid import warnings @@ -6,6 +7,7 @@ from enum import IntEnum from typing import Callable, Optional, Union +import requests from apscheduler.schedulers.base import BaseScheduler from yggdrasil_engine.engine import UnleashEngine @@ -46,10 +48,37 @@ UnleashReadyEvent, ) from UnleashClient.impact_metrics import ImpactMetrics +from UnleashClient.transport import Transport from UnleashClient.utils import LOGGER, InstanceAllowType INSTANCES = _get_instance_registry() +# temp check until http optiins is gone +def _check_transport( + transport: Optional[Transport], http: Optional[HttpOptions] +) -> None: + if transport is None: + return + if http is not None: + raise ValueError( + "Pass either http or transport, not both. Set headers and the " + "timeout on the transport: RequestsTransport(headers=..., timeout=...)." + ) + if isinstance(transport, requests.Session): + raise TypeError( + "Wrap the session in a transport: transport=RequestsTransport(session)." + ) + execute = getattr(transport, "execute", None) + if not callable(execute): + raise TypeError( + "transport must have an execute(request) method, see " + "UnleashClient.transport.Transport." + ) + if inspect.iscoroutinefunction(execute): + raise TypeError( + "transport.execute is async: UnleashClient needs a sync transport." + ) + class _RunState(IntEnum): UNINITIALIZED = 0 @@ -122,6 +151,7 @@ class UnleashClient: :param scheduler_executor: Name of APSCheduler executor to use if using a custom scheduler. :param multiple_instance_mode: Determines how multiple instances being instantiated is handled by the SDK, when set to InstanceAllowType.BLOCK, the client constructor will fail when more than one instance is detected, when set to InstanceAllowType.WARN, multiple instances will be allowed but log a warning, when set to InstanceAllowType.SILENTLY_ALLOW, no warning or failure will be raised when instantiating multiple instances of the client. Defaults to InstanceAllowType.WARN :param event_callback: Function to call if impression events are enabled. Called on a dedicated background thread, so it must not rely on thread local state from the caller. + :param transport: Sends the requests to the server, see :class:`~UnleashClient.transport.Transport`. """ def __init__( @@ -142,7 +172,9 @@ def __init__( scheduler_executor: Optional[str] = None, multiple_instance_mode: InstanceAllowType = InstanceAllowType.WARN, event_callback: Optional[Callable[[BaseEvent], None]] = None, + transport: Optional[Transport] = None, ) -> None: + _check_transport(transport, http) self._config = UnleashConfig( url=url, app_name=app_name, @@ -195,7 +227,9 @@ def __init__( events=self.__events, ) - self._api = _UnleashApiClient(self._config, self._headers, self.__events) + self._api = _UnleashApiClient( + self._config, self._headers, self.__events, transport=transport + ) self._scheduler = _Scheduler(scheduler, scheduler_executor) diff --git a/UnleashClient/transport.py b/UnleashClient/transport.py new file mode 100644 index 00000000..b03d95b2 --- /dev/null +++ b/UnleashClient/transport.py @@ -0,0 +1,169 @@ +""" +HTTP layer under the Unleash client: sends one request and returns the +response. + +Pass a transport to the client to choose how requests are sent: +:class:`RequestsTransport` with your own ``requests.Session``, or any object +with an ``execute(Request) -> Response`` method. +""" + +from dataclasses import dataclass, field +from typing import Any, Mapping, Optional, Protocol, runtime_checkable + +import requests +from requests.structures import CaseInsensitiveDict + +from UnleashClient.constants import REQUEST_TIMEOUT +from UnleashClient.utils import LOGGER + + +@dataclass(frozen=True, kw_only=True) +class Request: + """ + HTTP request. + + :param method: ``"GET"`` or ``"POST"``. + :param url: Absolute url, without the query string. + :param headers: Headers to send. + :param params: Query string parameters. + :param body: Request body, or None to send none. + """ + + method: str + url: str + headers: Mapping[str, str] + params: Mapping[str, str] = field(default_factory=dict) + body: Optional[bytes] = None + + +@dataclass(frozen=True, kw_only=True) +class Response: + """ + HTTP response. Every status is a response, not an error: deciding what + a status means is the API client's job. + + :param status: HTTP status code. + :param headers: Response headers. A plain ``dict`` is fine: the SDK looks + names up case-insensitively. + :param body: Raw response body. + """ + + status: int + headers: Mapping[str, str] + body: bytes + + +@runtime_checkable +class Transport(Protocol): + """ + Sends requests for the sync client. + + ``request.headers`` include the API key: don't log them. + """ + + def execute(self, request: Request) -> Response: + """ + Send ``request`` once and return the response, whatever its status. + Don't retry: the SDK retries feature fetches itself. + + :raises Exception: only when no response arrived, e.g. a connection + error or a timeout. + """ + + +@runtime_checkable +class AsyncTransport(Protocol): + """Sends requests for the async client.""" + + async def execute(self, request: Request) -> Response: + """ + Send ``request`` once and return the response, whatever its status. + + :raises Exception: only when no response arrived, e.g. a connection + error or a timeout. + """ + + +def _session_retries(session: requests.Session) -> bool: + """True if a mounted adapter retries; a plain Session's adapters don't.""" + for adapter in session.adapters.values(): + retry = getattr(adapter, "max_retries", None) + if retry is not None and ( + retry.total or retry.connect or retry.read or retry.status + ): + return True + return False + + +class RequestsTransport: + """ + Sends requests with ``requests``. + + Sends each request once: retrying is the SDK's job, so don't mount a + retrying adapter on the session. + + Example:: + + session = requests.Session() + session.proxies = {"https": "http://proxy.internal:3128"} + UnleashClient(url, app_name, transport=RequestsTransport(session)) + + :param session: Session to send every request on. It is never closed: its + creator closes it. Without one, every request gets a fresh + session, closed after the response. + :param headers: Extra headers sent with every request. The SDK's own headers + take precedence over them. + :param timeout: Seconds to wait for the connection and for each read. + :raises ValueError: If ``headers`` contains an ``Authorization`` header: pass + the API key as ``api_key`` to the client. + """ + + def __init__( + self, + session: Optional[requests.Session] = None, + *, + headers: Optional[Mapping[str, str]] = None, + timeout: float = REQUEST_TIMEOUT, + # going away with HttpOptions + _request_kwargs: Optional[Mapping[str, Any]] = None, + ) -> None: + self._session = session + self._headers = dict(headers or {}) + self._timeout = timeout + self._request_kwargs = dict(_request_kwargs or {}) + + if any(name.lower() == "authorization" for name in self._headers): + raise ValueError( + "Pass the API key as api_key to the client, " + "not as an Authorization header to RequestsTransport." + ) + if session is not None and _session_retries(session): + LOGGER.warning( + "The session passed to RequestsTransport retries requests itself. " + "The SDK also retries feature fetches, so failed fetches are " + "retried twice over." + ) + + def execute(self, request: Request) -> Response: + headers = CaseInsensitiveDict(self._headers) + headers.update(request.headers) + kwargs: dict = {"headers": headers, "timeout": self._timeout} + if request.params: + kwargs["params"] = dict(request.params) + if request.body is not None: + kwargs["data"] = request.body + + if self._session is not None: + return self._send(self._session, request, kwargs) + with requests.Session() as session: + return self._send(session, request, kwargs) + + def _send( + self, session: requests.Session, request: Request, kwargs: dict + ) -> Response: + resp = session.request( + request.method, request.url, **kwargs, **self._request_kwargs + ) + return Response( + status=resp.status_code, headers=resp.headers, body=resp.content + ) diff --git a/examples/fake_transport.py b/examples/fake_transport.py new file mode 100644 index 00000000..a80f63bf --- /dev/null +++ b/examples/fake_transport.py @@ -0,0 +1,44 @@ +""" +Test code that uses Unleash without a server or HTTP mocking. + +The fake transport answers every feature fetch with fixed flags and accepts +registration and metrics. ``requests`` lists what the SDK sent. +""" + +import json +from typing import Any, Dict, List + +from UnleashClient import Request, Response, UnleashClient + + +class FakeTransport: + def __init__(self, *flags: Dict[str, Any]) -> None: + self._features = json.dumps({"version": 1, "features": list(flags)}).encode() + self.requests: List[Request] = [] + + def execute(self, request: Request) -> Response: + self.requests.append(request) + if request.method == "GET": + return Response(status=200, headers={}, body=self._features) + return Response(status=202, headers={}, body=b"") + + +def flag(name: str, enabled: bool) -> Dict[str, Any]: + return { + "name": name, + "enabled": enabled, + "strategies": [{"name": "default", "parameters": {}}], + } + + +def test_new_checkout_is_on(tmp_path): + transport = FakeTransport(flag("new-checkout", True)) + + with UnleashClient( + "http://unleash.test/api", + "my-app-tests", + transport=transport, + cache_directory=str(tmp_path), + ) as client: + assert client.is_enabled("new-checkout") + assert not client.is_enabled("unknown-flag") diff --git a/examples/httpx_transport.py b/examples/httpx_transport.py new file mode 100644 index 00000000..dcc5cb5f --- /dev/null +++ b/examples/httpx_transport.py @@ -0,0 +1,38 @@ +""" +Send the SDK's requests with httpx instead of requests. + +A transport only needs an ``execute(Request) -> Response`` method. It sends +each request once: the SDK retries feature fetches itself. +""" + +import httpx + +from UnleashClient import Request, Response, UnleashClient + + +class HttpxTransport: + def __init__(self, client: httpx.Client) -> None: + self._client = client + + def execute(self, request: Request) -> Response: + resp = self._client.request( + request.method, + request.url, + headers=dict(request.headers), + params=dict(request.params), + content=request.body, + ) + return Response( + status=resp.status_code, headers=resp.headers, body=resp.content + ) + + +if __name__ == "__main__": + with httpx.Client(timeout=30) as http_client: + with UnleashClient( + "https://unleash.example.com/api", + "my-app", + api_key="", + transport=HttpxTransport(http_client), + ) as client: + print(client.is_enabled("new-checkout")) diff --git a/pyproject.toml b/pyproject.toml index ba9a95f8..03f6b8c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,6 @@ max-args = 25 include-package-data = true [tool.setuptools.packages] -find = {exclude = ["tests*"]} +find = {exclude = ["tests*", "examples*"]} [tool.setuptools_scm] diff --git a/requirements.txt b/requirements.txt index 961c2ac1..2e2f8e70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ aiohttp # - Testing black coveralls +httpx isort mimesis==4.1.3 multidict diff --git a/tests/unit_tests/clients/test_unleash_client_transport.py b/tests/unit_tests/clients/test_unleash_client_transport.py new file mode 100644 index 00000000..2b980c05 --- /dev/null +++ b/tests/unit_tests/clients/test_unleash_client_transport.py @@ -0,0 +1,163 @@ +import json + +import pytest +import requests +import responses + +from examples import fake_transport +from examples.fake_transport import FakeTransport, flag +from tests.utilities.testing_constants import APP_NAME, ETAG_VALUE, URL +from UnleashClient import INSTANCES, RequestsTransport, Response, UnleashClient +from UnleashClient.config import HttpOptions, MetricsOptions, PollingOptions +from UnleashClient.constants import FEATURES_URL +from UnleashClient.utils import InstanceAllowType + + +@pytest.fixture(autouse=True) +def before_each(): + INSTANCES._reset() + + +def build_client(tmp_path, transport, **kwargs): + return UnleashClient( + URL, + APP_NAME, + api_key="project:environment.hash", + cache_directory=str(tmp_path), + transport=transport, + **kwargs, + ) + + +def test_the_fake_transport_example_works(tmp_path): + fake_transport.test_new_checkout_is_on(tmp_path) + + +def test_the_client_sends_every_request_through_the_transport(tmp_path): + transport = FakeTransport(flag("testFlag", True)) + + with build_client(tmp_path, transport) as client: + assert client.is_enabled("testFlag") + + sent = [(request.method, request.url) for request in transport.requests] + assert ("POST", URL + "/client/register") in sent + assert ("GET", URL + FEATURES_URL) in sent + assert ("POST", URL + "/client/metrics") in sent + assert all( + request.headers["Authorization"] == "project:environment.hash" + for request in transport.requests + ) + + +class PlainDictEtagTransport(FakeTransport): + """Returns its ETag in a plain dict, with the header name capitalised.""" + + def execute(self, request): + response = super().execute(request) + if request.method != "GET": + return response + return Response(status=200, headers={"ETag": ETAG_VALUE}, body=response.body) + + +def test_an_etag_in_a_plain_dict_is_sent_back_on_the_next_fetch(tmp_path): + transport = PlainDictEtagTransport(flag("testFlag", True)) + client = build_client( + tmp_path, + transport, + metrics=MetricsOptions(disabled=True, disable_registration=True), + ) + client.initialize_client() + + client.connector._fetch_and_load() + client.destroy() + + first, second = transport.requests + assert "If-None-Match" not in first.headers + assert second.headers["If-None-Match"] == ETAG_VALUE + + +@responses.activate +def test_the_client_never_closes_a_session_passed_in(tmp_path, mocker): + responses.add( + responses.GET, + URL + FEATURES_URL, + json={"version": 1, "features": [flag("testFlag", True)]}, + status=200, + ) + session = requests.Session() + close = mocker.spy(session, "close") + + with build_client( + tmp_path, + RequestsTransport(session), + metrics=MetricsOptions(disabled=True, disable_registration=True), + ) as client: + assert client.is_enabled("testFlag") + + close.assert_not_called() + + +def test_http_and_transport_together_raise(tmp_path): + with pytest.raises(ValueError, match="either http or transport"): + build_client(tmp_path, FakeTransport(), http=HttpOptions(timeout=5)) + + +class AsyncTransport: + async def execute(self, request): + raise AssertionError("never sent") + + +@pytest.mark.parametrize( + "transport,message", + ( + (requests.Session(), "RequestsTransport\\(session\\)"), + (object(), "execute"), + (AsyncTransport(), "async"), + ), + ids=("a-bare-session", "no-execute", "async-execute"), +) +def test_a_transport_that_cannot_work_raises(tmp_path, transport, message): + with pytest.raises(TypeError, match=message): + build_client(tmp_path, transport) + + +def test_a_rejected_transport_does_not_register_the_instance(tmp_path): + with pytest.raises(ValueError): + build_client(tmp_path, FakeTransport(), http=HttpOptions()) + + with build_client( + tmp_path, FakeTransport(), multiple_instance_mode=InstanceAllowType.BLOCK + ): + pass + + +def test_the_httpx_example_works(tmp_path): + httpx = pytest.importorskip("httpx") + from examples.httpx_transport import HttpxTransport # noqa: PLC0415 + + sent = [] + + def server(request): + sent.append(request) + if request.method == "GET": + return httpx.Response( + 200, + json={"version": 1, "features": [flag("testFlag", True)]}, + headers={"ETag": ETAG_VALUE}, + ) + return httpx.Response(202) + + with httpx.Client(transport=httpx.MockTransport(server)) as http_client: + with build_client( + tmp_path, + HttpxTransport(http_client), + mode=PollingOptions(project_name="checkout"), + ) as client: + assert client.is_enabled("testFlag") + client.connector._fetch_and_load() + + register = next(r for r in sent if r.url.path.endswith("/register")) + assert json.loads(register.content)["appName"] == APP_NAME + fetches = [r for r in sent if r.method == "GET"] + assert fetches[0].url.params["project"] == "checkout" + assert fetches[1].headers["If-None-Match"] == ETAG_VALUE diff --git a/tests/unit_tests/test_aiohttp_transport.py b/tests/unit_tests/test_aiohttp_transport.py index ea02b01d..a6b80b22 100644 --- a/tests/unit_tests/test_aiohttp_transport.py +++ b/tests/unit_tests/test_aiohttp_transport.py @@ -8,8 +8,8 @@ from tests.utilities.fake_unleash_server import FakeUnleash from UnleashClient._aiohttp_transport import AiohttpTransport -from UnleashClient._transport import AsyncTransport, Request from UnleashClient.errors import AlreadyClosedError +from UnleashClient.transport import AsyncTransport, Request PATH = "/client/features" diff --git a/tests/unit_tests/test_async_unleash_api_client.py b/tests/unit_tests/test_async_unleash_api_client.py index 3a56c09f..b1531f5b 100644 --- a/tests/unit_tests/test_async_unleash_api_client.py +++ b/tests/unit_tests/test_async_unleash_api_client.py @@ -26,7 +26,6 @@ from UnleashClient._aiohttp_transport import AiohttpTransport from UnleashClient._async_unleash_api_client import _AsyncUnleashApiClient from UnleashClient._headers import _HeaderFactory -from UnleashClient._transport import Request, Response from UnleashClient.config import ( HttpOptions, IdentityOptions, @@ -42,6 +41,7 @@ ) from UnleashClient.errors import AlreadyClosedError from UnleashClient.events import FetchStatus, UnleashEventType +from UnleashClient.transport import Request, Response API_PREFIX = "/api" FEATURES_PATH = API_PREFIX + FEATURES_URL diff --git a/tests/unit_tests/test_retry.py b/tests/unit_tests/test_retry.py index 990e30e6..d95e6701 100644 --- a/tests/unit_tests/test_retry.py +++ b/tests/unit_tests/test_retry.py @@ -9,8 +9,8 @@ _send_with_retries, _send_with_retries_async, ) -from UnleashClient._transport import Request, Response from UnleashClient.errors import AlreadyClosedError +from UnleashClient.transport import Request, Response # Imported before the autouse no_retry_backoff fixture replaces it. _real_backoff_delay = _retry._backoff_delay diff --git a/tests/unit_tests/test_transport.py b/tests/unit_tests/test_transport.py index 60748cb4..e93a2415 100644 --- a/tests/unit_tests/test_transport.py +++ b/tests/unit_tests/test_transport.py @@ -1,11 +1,14 @@ import json import pytest +import requests import responses from requests import ConnectionError +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry from tests.utilities.testing_constants import URL -from UnleashClient._transport import Request, RequestsTransport, Transport +from UnleashClient.transport import Request, RequestsTransport, Transport ENDPOINT = URL + "/client/features" @@ -144,3 +147,89 @@ def test_execute_uses_a_fresh_session_for_every_request(): # A shared session would send the cookie back. assert "Cookie" not in responses.calls[1].request.headers + + + +@responses.activate +def test_execute_sends_on_the_session_passed_in(): + responses.add(responses.GET, ENDPOINT, status=200, headers={"Set-Cookie": "a=1"}) + responses.add(responses.GET, ENDPOINT, status=200) + session = requests.Session() + session.headers["X-From-Session"] = "yes" + transport = RequestsTransport(session) + + transport.execute(Request(method="GET", url=ENDPOINT, headers={})) + transport.execute(Request(method="GET", url=ENDPOINT, headers={})) + + assert responses.calls[0].request.headers["X-From-Session"] == "yes" + assert responses.calls[1].request.headers["Cookie"] == "a=1" + + +@responses.activate +def test_execute_never_closes_the_session_passed_in(mocker): + responses.add(responses.GET, ENDPOINT, status=200) + session = requests.Session() + close = mocker.spy(session, "close") + + RequestsTransport(session).execute(Request(method="GET", url=ENDPOINT, headers={})) + + close.assert_not_called() + + +def test_a_session_with_a_retrying_adapter_logs_a_warning(caplog): + session = requests.Session() + session.mount("https://", HTTPAdapter(max_retries=Retry(total=3))) + + RequestsTransport(session) + + assert "retries requests itself" in caplog.text + + +def test_a_plain_session_logs_no_warning(caplog): + RequestsTransport(requests.Session()) + + assert "retries requests itself" not in caplog.text + + + + +@responses.activate +def test_execute_sends_the_transport_headers(): + responses.add(responses.GET, ENDPOINT, status=200) + + RequestsTransport(headers={"X-Team": "checkout"}).execute( + Request(method="GET", url=ENDPOINT, headers={"X-Test": "value"}) + ) + + sent = responses.calls[0].request + assert sent.headers["X-Team"] == "checkout" + assert sent.headers["X-Test"] == "value" + + +@responses.activate +def test_the_request_headers_win_over_the_transport_headers_whatever_their_case(): + responses.add(responses.GET, ENDPOINT, status=200) + + RequestsTransport(headers={"UNLEASH-APPNAME": "spoofed"}).execute( + Request(method="GET", url=ENDPOINT, headers={"unleash-appname": "my-app"}) + ) + + assert responses.calls[0].request.headers["unleash-appname"] == "my-app" + + +@pytest.mark.parametrize("name", ["Authorization", "authorization"]) +def test_an_authorization_header_raises(name): + with pytest.raises(ValueError, match="api_key"): + RequestsTransport(headers={name: "token"}) + + +@responses.activate +def test_execute_uses_the_timeout(mocker): + responses.add(responses.GET, ENDPOINT, status=200) + send = mocker.spy(requests.Session, "request") + + RequestsTransport(timeout=7).execute( + Request(method="GET", url=ENDPOINT, headers={}) + ) + + assert send.call_args.kwargs["timeout"] == 7 diff --git a/tests/unit_tests/test_unleash_api_client.py b/tests/unit_tests/test_unleash_api_client.py index 4e80353e..649375c1 100644 --- a/tests/unit_tests/test_unleash_api_client.py +++ b/tests/unit_tests/test_unleash_api_client.py @@ -4,6 +4,7 @@ import responses from pytest import mark, param from requests import ConnectionError +from requests.structures import CaseInsensitiveDict from tests.utilities.mocks.mock_features import ( MOCK_FEATURE_RESPONSE, @@ -23,8 +24,7 @@ URL, ) from UnleashClient._headers import _HeaderFactory -from UnleashClient._transport import Request, Response -from UnleashClient._unleash_api_client import _UnleashApiClient +from UnleashClient._unleash_api_client import _header, _UnleashApiClient from UnleashClient.config import ( HttpOptions, IdentityOptions, @@ -39,6 +39,7 @@ REGISTER_URL, ) from UnleashClient.events import FetchStatus, UnleashEventType +from UnleashClient.transport import Request, Response FULL_FEATURE_URL = URL + FEATURES_URL FULL_REGISTER_URL = URL + REGISTER_URL @@ -521,3 +522,17 @@ def test_a_payload_that_cannot_be_serialised_still_raises(call): call(api_client_over(transport))({"not-json": object()}) assert transport.requests == [] + + +@pytest.mark.parametrize( + "headers,expected", + ( + ({"ETag": "v1"}, "v1"), + ({"etag": "v1"}, "v1"), + (CaseInsensitiveDict({"ETAG": "v1"}), "v1"), + ({"Content-Type": "application/json"}, ""), + ), + ids=("plain-dict-capitalised", "plain-dict-lowercase", "requests-dict", "absent"), +) +def test_header_matches_names_case_insensitively(headers, expected): + assert _header(Response(status=200, headers=headers, body=b""), "etag") == expected From 2ef586ea9bf1b2ed9a27fccaedb0de72326d6320 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:44:59 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- UnleashClient/clients/unleash_client.py | 1 + tests/unit_tests/test_transport.py | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/UnleashClient/clients/unleash_client.py b/UnleashClient/clients/unleash_client.py index e78e84d7..69b99ace 100644 --- a/UnleashClient/clients/unleash_client.py +++ b/UnleashClient/clients/unleash_client.py @@ -53,6 +53,7 @@ INSTANCES = _get_instance_registry() + # temp check until http optiins is gone def _check_transport( transport: Optional[Transport], http: Optional[HttpOptions] diff --git a/tests/unit_tests/test_transport.py b/tests/unit_tests/test_transport.py index e93a2415..599b82f7 100644 --- a/tests/unit_tests/test_transport.py +++ b/tests/unit_tests/test_transport.py @@ -149,7 +149,6 @@ def test_execute_uses_a_fresh_session_for_every_request(): assert "Cookie" not in responses.calls[1].request.headers - @responses.activate def test_execute_sends_on_the_session_passed_in(): responses.add(responses.GET, ENDPOINT, status=200, headers={"Set-Cookie": "a=1"}) @@ -191,8 +190,6 @@ def test_a_plain_session_logs_no_warning(caplog): assert "retries requests itself" not in caplog.text - - @responses.activate def test_execute_sends_the_transport_headers(): responses.add(responses.GET, ENDPOINT, status=200) From e78c9cd3c0359606412c86820492d91873dcf971 Mon Sep 17 00:00:00 2001 From: Alexandra Szanto Date: Fri, 6 Nov 2026 12:05:12 +0100 Subject: [PATCH 3/3] fix: only replace None transport with default --- UnleashClient/_unleash_api_client.py | 12 +++++++----- .../clients/test_unleash_client_transport.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/UnleashClient/_unleash_api_client.py b/UnleashClient/_unleash_api_client.py index 4888ad3b..9ddfca2e 100644 --- a/UnleashClient/_unleash_api_client.py +++ b/UnleashClient/_unleash_api_client.py @@ -117,11 +117,13 @@ def __init__( self._headers: _HeaderFactory = headers self._events: Optional[_EventDispatcher] = events - self._transport: Transport = transport or RequestsTransport( - timeout=config.http.timeout, - # this is going to go away soon, just like the whole httpOptions in the client constructor - _request_kwargs=config.http.options, - ) + if transport is None: + transport = RequestsTransport( + timeout=config.http.timeout, + # this is going to go away soon, just like the whole httpOptions in the client constructor + _request_kwargs=config.http.options, + ) + self._transport: Transport = transport # pylint: disable=broad-except def fetch_features(self, etag: str = "", *, options: PollingOptions) -> FetchResult: diff --git a/tests/unit_tests/clients/test_unleash_client_transport.py b/tests/unit_tests/clients/test_unleash_client_transport.py index 2b980c05..3721bd77 100644 --- a/tests/unit_tests/clients/test_unleash_client_transport.py +++ b/tests/unit_tests/clients/test_unleash_client_transport.py @@ -49,6 +49,20 @@ def test_the_client_sends_every_request_through_the_transport(tmp_path): ) +class RecordingTransport(FakeTransport): + def __len__(self): + return len(self.requests) + + +def test_a_falsy_transport_is_used_and_not_replaced_by_the_default(tmp_path): + transport = RecordingTransport(flag("testFlag", True)) + + with build_client(tmp_path, transport) as client: + assert client.is_enabled("testFlag") + + assert len(transport) > 0 + + class PlainDictEtagTransport(FakeTransport): """Returns its ETag in a plain dict, with the header name capitalised."""