From 58a2288289da010f517ca3f8ea5a54b6f97949cd Mon Sep 17 00:00:00 2001 From: Alexandra Szanto Date: Thu, 5 Nov 2026 14:05:29 +0100 Subject: [PATCH 1/3] chore: remove redundant http options --- CHANGELOG.md | 5 +- README.md | 20 ++---- UnleashClient/__init__.py | 1 - UnleashClient/_async_unleash_api_client.py | 13 ++-- UnleashClient/_headers.py | 5 -- UnleashClient/_retry.py | 16 +++-- UnleashClient/_unleash_api_client.py | 10 +-- UnleashClient/clients/async_unleash_client.py | 7 +-- UnleashClient/clients/unleash_client.py | 10 +-- UnleashClient/config.py | 36 +---------- .../connectors/streaming_connector.py | 11 ++-- UnleashClient/constants.py | 1 - UnleashClient/transport.py | 16 +---- tests/conftest.py | 6 ++ .../test_async_client_specs.py | 3 +- .../clients/test_async_unleash_client.py | 11 +--- .../test_async_unleash_client_transport.py | 11 +--- .../unit_tests/clients/test_unleash_client.py | 8 +-- .../clients/test_unleash_client_transport.py | 13 ++-- .../connectors/test_async_connector.py | 6 +- .../connectors/test_polling_connector.py | 11 ---- .../test_async_unleash_api_client.py | 21 +++---- tests/unit_tests/test_config.py | 59 ++---------------- tests/unit_tests/test_headers.py | 37 +---------- tests/unit_tests/test_transport.py | 20 ------ tests/unit_tests/test_unleash_api_client.py | 54 +--------------- tests/utilities/testing_constants.py | 3 - v7_MIGRATION_GUIDE.md | 61 ++++++++++++++++--- 28 files changed, 124 insertions(+), 351 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8209847c..eccc8aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ ## Unreleased (v7.0.0) * (Major): Python 3.10 or newer is required. Support for Python 3.8 and 3.9 has been dropped. -* (Major): `UnleashClient` and `AsyncUnleashClient` take their settings as option groups: `identity=IdentityOptions(...)`, `http=HttpOptions(...)`, `mode=PollingOptions(...)` or `mode=StreamingOptions()`, and `metrics=MetricsOptions(...)`. Every argument after `url` and `app_name` is keyword-only. `ExperimentalMode` has been removed. See the [v7 migration guide](./v7_MIGRATION_GUIDE.md). +* (Major): `UnleashClient` and `AsyncUnleashClient` take their settings as option groups: `identity=IdentityOptions(...)`, `mode=PollingOptions(...)` or `mode=StreamingOptions()`, and `metrics=MetricsOptions(...)`. Every argument after `url` and `app_name` is keyword-only. `ExperimentalMode` has been removed. See the [v7 migration guide](./v7_MIGRATION_GUIDE.md). * (Major): Settings can no longer be changed on an existing client. The `unleash_*` settings properties and the `strategy_mapping` and `connector_mode` properties have been removed, together with their setters. -* (Major): The API token is passed as `api_key=""`. An `Authorization` header in `HttpOptions.headers` raises `ValueError`. +* (Major): The API token is passed as `api_key=""`. An `Authorization` header in a transport's `headers` raises `ValueError`. +* (Major): HTTP settings are set on the transport: `custom_headers` and `request_timeout` become `RequestsTransport(headers=..., timeout=...)`, or `AiohttpTransport(...)` for `AsyncUnleashClient`. `custom_options` has been removed: configure a `requests.Session` and pass `RequestsTransport(session)`. `request_retries` has been removed: a failed feature fetch is retried up to 3 times. The streaming connection doesn't use the transport: it is sent with the SDK's own headers and a 30s timeout. * (Major): The deprecated `environment` setting has been removed. The `environment` field of the evaluation context and the impact metrics `environment` label come from the API token, or are `"default"` when the token doesn't name one. * (Major): The `UnleashClient` and `AsyncUnleashClient` constructors raise `ValueError` if `url` is not an absolute http(s) URL with a host, for every client. * (Major): The constructors raise `ValueError` if `api_key`, `app_name` or `IdentityOptions.instance_id` contains a line break, such as an API token read from a file with a trailing newline, and `TypeError` if one isn't a str. These values are sent as headers. diff --git a/README.md b/README.md index 1e968679..80c58259 100644 --- a/README.md +++ b/README.md @@ -117,14 +117,14 @@ The client will evaluate the fallback function if the feature flag is not found ```python from UnleashClient import ( - HttpOptions, MetricsOptions, PollingOptions, UnleashClient, + MetricsOptions, PollingOptions, RequestsTransport, UnleashClient, ) client = UnleashClient( url="https://YOUR-API-URL", app_name="my-python-app", api_key="", - http=HttpOptions(timeout=10), + transport=RequestsTransport(timeout=10), mode=PollingOptions(interval=30), metrics=MetricsOptions(disabled=True), ) @@ -138,7 +138,6 @@ Settings can't be changed after the client is created. | app_name | Name of the application using the client. Required. | — | | api_key | Client API token, sent as the `Authorization` header. | None | | identity | How the client identifies itself. See [`IdentityOptions`](#identityoptions). | `IdentityOptions()` | -| http | How the client talks to the server. See [`HttpOptions`](#httpoptions). | `HttpOptions()` | | mode | How the client receives flag updates: [`PollingOptions`](#pollingoptions) or [`StreamingOptions`](#streamingoptions). | `PollingOptions()` | | metrics | How the client reports to the server. See [`MetricsOptions`](#metricsoptions). | `MetricsOptions()` | | verbose_log_level | [Log level](https://docs.python.org/3/library/logging.html#logging-levels) for messages about unknown flags and failed impression events. | 30 | @@ -149,7 +148,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). See [custom HTTP transport](#custom-http-transport). | `RequestsTransport` or `AiohttpTransport` | +| transport | How requests are sent, including headers, timeout, proxy and TLS settings. See [custom HTTP transport](#custom-http-transport). | `RequestsTransport` or `AiohttpTransport` | #### `IdentityOptions` @@ -159,15 +158,6 @@ Settings can't be changed after the client is created. | sdk_flavor | Identifier of an integration built on this SDK (e.g. an OpenFeature provider). | None | | sdk_flavor_version | Version of the integration named by `sdk_flavor`. | None | -#### `HttpOptions` - -| Parameter | Description | Default | -| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------- | -| headers | Extra HTTP headers sent with every request. Pass the API token as `api_key`; an `Authorization` header here raises `ValueError`. | None | -| options | Extra keyword arguments for every [HTTP request](https://requests.readthedocs.io/en/latest/api/#main-interface). Sync client only. | None | -| timeout | HTTP request timeout (seconds). | 30 | -| retries | How many times a failed feature fetch is retried. | 3 | - #### `PollingOptions` The default: the client fetches feature flags from the server on an interval. @@ -671,11 +661,11 @@ All four examples are in [`examples/`](examples/) and run as part of the SDK's t #### Things to know -- **Don't retry in your transport.** The client already retries failed feature fetches, with a backoff. A `requests` session with a retrying adapter mounted logs a warning, because failed fetches would be retried twice over. aiohttp has no retry setting, but resends a GET once by itself when the server drops a reused connection; that's expected, and not a retry to turn off. +- **Don't retry in your transport.** The client already retries a failed feature fetch up to 3 times, with a backoff. A `requests` session with a retrying adapter mounted logs a warning, because failed fetches would be retried twice over. aiohttp has no retry setting, but resends a GET once by itself when the server drops a reused connection; that's expected, and not a retry to turn off. - **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. +- **Streaming mode doesn't use the transport.** With `mode=StreamingOptions()`, the stream connects with the SDK's own headers and a 30 second timeout. Custom transport's headers, timeout, proxy and TLS settings don't apply to it. ## Running in multi-process setups diff --git a/UnleashClient/__init__.py b/UnleashClient/__init__.py index c3618d3d..8d0b81ac 100644 --- a/UnleashClient/__init__.py +++ b/UnleashClient/__init__.py @@ -7,7 +7,6 @@ build_ready_callback, ) from .config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, diff --git a/UnleashClient/_async_unleash_api_client.py b/UnleashClient/_async_unleash_api_client.py index 67cbc042..3fb97767 100644 --- a/UnleashClient/_async_unleash_api_client.py +++ b/UnleashClient/_async_unleash_api_client.py @@ -38,13 +38,13 @@ def __init__( transport: Optional[AsyncTransport] = None, ) -> None: """ - :param config: read for the url, timeouts, retries and project. + :param config: read for the url and the identity. :param headers: builds the header set each request needs. :param events: receives ``FETCH_COMPLETED`` after every feature fetch. :param transport: sends the requests. Defaults to an - :class:`AiohttpTransport` built from ``config.http``, - which :meth:`aclose` closes. A transport passed in is - left open: whoever created it closes it. + :class:`AiohttpTransport`, which :meth:`aclose` + closes. A transport passed in is left open: whoever + created it closes it. """ self._config: UnleashConfig = config self._headers: _HeaderFactory = headers @@ -52,9 +52,7 @@ def __init__( # set only when this API client built the transport, and so it closes it. self._default_transport: Optional[AiohttpTransport] = None if transport is None: - transport = self._default_transport = AiohttpTransport( - timeout=config.http.timeout - ) + transport = self._default_transport = AiohttpTransport() self._transport: AsyncTransport = transport self._is_closed: bool = False self._close_lock = threading.Lock() @@ -128,7 +126,6 @@ async def _fetch_features( Request( method="GET", url=base_url, headers=headers, params=base_params ), - config.http.retries, ) http_status_code = resp.status diff --git a/UnleashClient/_headers.py b/UnleashClient/_headers.py index 32792cd2..996fbc94 100644 --- a/UnleashClient/_headers.py +++ b/UnleashClient/_headers.py @@ -24,11 +24,6 @@ def __init__(self, config: UnleashConfig) -> None: def base(self) -> Dict[str, str]: return { - # Custom headers go first: the application and identification - # headers below are not overridable. - **self._config.http.headers, - # HttpOptions rejects an Authorization header, so the custom - # headers cannot override the API key. **({"Authorization": self._config.api_key} if self._config.api_key else {}), **APPLICATION_HEADERS, "unleash-connection-id": self._config.connection_id, diff --git a/UnleashClient/_retry.py b/UnleashClient/_retry.py index f382d1e2..3561c8af 100644 --- a/UnleashClient/_retry.py +++ b/UnleashClient/_retry.py @@ -8,13 +8,14 @@ import asyncio import random import time -from typing import Union +from typing import Optional, Union from UnleashClient.errors import AlreadyClosedError from UnleashClient.transport import AsyncTransport, Request, Response, Transport from UnleashClient.utils import LOGGER RETRY_STATUSES = frozenset({500, 502, 504}) +FETCH_RETRIES = 3 BACKOFF_BASE = 0.5 _sleep = time.sleep @@ -38,11 +39,11 @@ def _backoff_delay(retry: int) -> float: def _attempts(retries: int) -> range: """The attempt numbers: the first attempt and ``retries`` retries.""" - return range(1 + max(retries, 0)) + return range(1 + retries) def _should_retry(attempt: int, retries: int, outcome: _Outcome) -> bool: - if attempt >= max(retries, 0): + if attempt >= retries: return False if isinstance(outcome, Response): return outcome.status in RETRY_STATUSES @@ -66,16 +67,18 @@ def _next_step(attempt: int, retries: int, outcome: _Outcome) -> Union[Response, def _send_with_retries( - transport: Transport, request: Request, retries: int + transport: Transport, request: Request, retries: Optional[int] = None ) -> Response: """ Send ``request``, retrying on a status in :data:`RETRY_STATUSES` and on any exception, with a backoff between attempts.ca - :param retries: Retries after the first attempt; negative counts as 0. + :param retries: Retries after the first attempt; :data:`FETCH_RETRIES` when + None :return: The last response, which can still have a retry status. :raises Exception: The last attempt's exception, if it raised. """ + retries = FETCH_RETRIES if retries is None else retries for attempt in _attempts(retries): try: outcome: _Outcome = transport.execute(request) @@ -90,9 +93,10 @@ def _send_with_retries( async def _send_with_retries_async( - transport: AsyncTransport, request: Request, retries: int + transport: AsyncTransport, request: Request, retries: Optional[int] = None ) -> Response: """The asyncio twin of :func:`_send_with_retries`: only the awaits differ.""" + retries = FETCH_RETRIES if retries is None else retries for attempt in _attempts(retries): try: outcome: _Outcome = await transport.execute(request) diff --git a/UnleashClient/_unleash_api_client.py b/UnleashClient/_unleash_api_client.py index 4888ad3b..0b352c05 100644 --- a/UnleashClient/_unleash_api_client.py +++ b/UnleashClient/_unleash_api_client.py @@ -101,8 +101,7 @@ class _UnleashApiClient: :param config: Client configuration. :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``. A transport passed in is never + :param transport: Sends the requests. Defaults to a :class:`RequestsTransport`. A transport passed in is never closed: whoever created it closes it. """ @@ -117,11 +116,7 @@ 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, - ) + self._transport: Transport = transport or RequestsTransport() # pylint: disable=broad-except def fetch_features(self, etag: str = "", *, options: PollingOptions) -> FetchResult: @@ -177,7 +172,6 @@ def _fetch_features( }, params=base_params, ), - config.http.retries, ) http_status_code = resp.status diff --git a/UnleashClient/clients/async_unleash_client.py b/UnleashClient/clients/async_unleash_client.py index 01bf60b4..86049800 100644 --- a/UnleashClient/clients/async_unleash_client.py +++ b/UnleashClient/clients/async_unleash_client.py @@ -27,7 +27,6 @@ from UnleashClient.cache import BaseCache, FileCache from UnleashClient.clients.unleash_client import _RunState from UnleashClient.config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, @@ -71,7 +70,6 @@ class AsyncUnleashClient: :param app_name: Name of the application using the client, required. :param api_key: Client API token. Optional, because a client that only loads from a backup never talks to the server. :param identity: How the client identifies itself. - :param http: How the client talks to the server. :param mode: How often the client polls for flag updates. Streaming is not supported yet. :param metrics: How the client reports to the server. :param verbose_log_level: Numerical log level (https://docs.python.org/3/library/logging.html#logging-levels) for cases where checking a feature flag fails. @@ -80,7 +78,7 @@ class AsyncUnleashClient: :param cache_directory: Location of the cache directory. :param multiple_instance_mode: Determines how multiple instances being instantiated is handled by the SDK. Defaults to InstanceAllowType.WARN. :param event_callback: Function to call if impression events are enabled. Called on a dedicated background thread. - :param transport: Sends the requests to the server, see :class:`~UnleashClient.transport.AsyncTransport`. Pass an :class:`AiohttpTransport` with your own ``aiohttp.ClientSession`` for a proxy, TLS settings or tracing. The client never closes it. Can't be combined with ``http``. + :param transport: Sends the requests to the server, see :class:`~UnleashClient.transport.AsyncTransport`. Pass an :class:`AiohttpTransport` with your own ``aiohttp.ClientSession`` for a proxy, TLS settings or tracing. The client never closes it. """ def __init__( @@ -90,7 +88,6 @@ def __init__( *, api_key: Optional[str] = None, identity: Optional[IdentityOptions] = None, - http: Optional[HttpOptions] = None, mode: Optional[PollingOptions] = None, metrics: Optional[MetricsOptions] = None, verbose_log_level: int = 30, @@ -104,7 +101,6 @@ def __init__( if transport is not None: _check_transport( transport, - http_given=http is not None, is_async=True, session_type=aiohttp.ClientSession, wrapper_name="AiohttpTransport", @@ -119,7 +115,6 @@ def __init__( app_name=app_name, api_key=api_key, identity=identity, - http=http, mode=mode, metrics=metrics, verbose_log_level=verbose_log_level, diff --git a/UnleashClient/clients/unleash_client.py b/UnleashClient/clients/unleash_client.py index 70374899..2759de55 100644 --- a/UnleashClient/clients/unleash_client.py +++ b/UnleashClient/clients/unleash_client.py @@ -25,7 +25,6 @@ from UnleashClient._unleash_api_client import _UnleashApiClient from UnleashClient.cache import BaseCache, FileCache from UnleashClient.config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, @@ -40,7 +39,7 @@ PollingConnector, StreamingConnector, ) -from UnleashClient.constants import ETAG, METRIC_LAST_SENT_TIME +from UnleashClient.constants import ETAG, METRIC_LAST_SENT_TIME, REQUEST_TIMEOUT from UnleashClient.events import ( BaseEvent, UnleashEventType, @@ -113,7 +112,6 @@ class UnleashClient: :param app_name: Name of the application using the client, required. :param api_key: Client API token. Optional, because a client that only loads from a backup never talks to the server. :param identity: How the client identifies itself, see :class:`~UnleashClient.config.IdentityOptions`. - :param http: How the client talks to the server, see :class:`~UnleashClient.config.HttpOptions`. :param mode: How the client receives flag update. :param metrics: How the client reports to the server, see :class:`~UnleashClient.config.MetricsOptions`. :param verbose_log_level: Numerical log level (https://docs.python.org/3/library/logging.html#logging-levels) for cases where checking a feature flag fails. @@ -134,7 +132,6 @@ def __init__( *, api_key: Optional[str] = None, identity: Optional[IdentityOptions] = None, - http: Optional[HttpOptions] = None, mode: Optional[Union[PollingOptions, StreamingOptions]] = None, metrics: Optional[MetricsOptions] = None, verbose_log_level: int = 30, @@ -150,7 +147,6 @@ def __init__( if transport is not None: _check_transport( transport, - http_given=http is not None, is_async=False, session_type=requests.Session, wrapper_name="RequestsTransport", @@ -160,7 +156,6 @@ def __init__( app_name=app_name, api_key=api_key, identity=identity, - http=http, mode=mode, metrics=metrics, verbose_log_level=verbose_log_level, @@ -315,8 +310,7 @@ def initialize_client(self, fetch_toggles: bool = True) -> None: store=self._store, url=self._config.url, headers=self._headers.streaming(), - request_timeout=self._config.http.timeout, - custom_options=self._config.http.options, + request_timeout=REQUEST_TIMEOUT, ) elif isinstance(mode, PollingOptions) and fetch_toggles: start_scheduler = True diff --git a/UnleashClient/config.py b/UnleashClient/config.py index e61393a2..87ebc94c 100644 --- a/UnleashClient/config.py +++ b/UnleashClient/config.py @@ -2,10 +2,8 @@ import uuid from dataclasses import KW_ONLY, dataclass, field -from types import MappingProxyType -from typing import Any, Mapping, Optional, Union +from typing import Any, Optional, Union -from UnleashClient.constants import REQUEST_RETRIES, REQUEST_TIMEOUT from UnleashClient.utils import ( LOGGER, _validate_header_value, @@ -26,35 +24,6 @@ def _optional_int(value: Any) -> Optional[int]: return int(value) if value is not None else None -@dataclass(frozen=True, kw_only=True) -class HttpOptions: - """ - :param headers: Extra headers sent with every request. The API key is not one of them: pass it as ``api_key`` to the client. - :param options: Extra keyword arguments passed to every ``requests`` call. - :param timeout: Request timeout in seconds. - :param retries: Number of retries for failed requests. - :raises ValueError: If ``headers`` contains an ``Authorization`` header. - """ - - # repr=False: custom headers can carry credentials of their own, such as a - # proxy's, which the dataclass __repr__ would put into logs and tracebacks. - headers: Optional[Mapping[str, str]] = field(default=None, repr=False) - options: Optional[Mapping[str, Any]] = None - timeout: int = REQUEST_TIMEOUT - retries: int = REQUEST_RETRIES - - def __post_init__(self) -> None: - object.__setattr__(self, "headers", MappingProxyType(dict(self.headers or {}))) - object.__setattr__(self, "options", MappingProxyType(dict(self.options or {}))) - # not just silently ignoring the authorization header, because not properly migrated v6 clients - # could start without an api_key and get 401s and serve cached flags - if any(key.lower() == "authorization" for key in self.headers): - raise ValueError( - "Pass the API key as api_key to the client, " - "not as an Authorization header in HttpOptions.headers." - ) - - @dataclass(frozen=True, kw_only=True) class IdentityOptions: """ @@ -146,7 +115,6 @@ class UnleashConfig: # logs and tracebacks. api_key: Optional[str] = field(default=None, repr=False) identity: IdentityOptions = field(default_factory=IdentityOptions) - http: HttpOptions = field(default_factory=HttpOptions) mode: Union[PollingOptions, StreamingOptions] = field( default_factory=PollingOptions ) @@ -167,8 +135,6 @@ def __post_init__(self) -> None: ): if value is not None: _validate_header_value(name, value) - if self.http is None: - object.__setattr__(self, "http", HttpOptions()) if self.mode is None: object.__setattr__(self, "mode", PollingOptions()) if self.metrics is None: diff --git a/UnleashClient/connectors/streaming_connector.py b/UnleashClient/connectors/streaming_connector.py index f4c73417..594e49c0 100644 --- a/UnleashClient/connectors/streaming_connector.py +++ b/UnleashClient/connectors/streaming_connector.py @@ -1,5 +1,5 @@ import threading -from typing import Any, Mapping, Optional +from typing import Optional from ld_eventsource import SSEClient from ld_eventsource.config import ConnectStrategy, ErrorStrategy, RetryDelayStrategy @@ -21,7 +21,6 @@ def __init__( backoff_max: float = 30.0, backoff_multiplier: float = 2.0, backoff_jitter: Optional[float] = 0.5, - custom_options: Optional[Mapping[str, Any]] = None, ) -> None: """ :param request_timeout: Seconds to wait for the stream response. @@ -44,10 +43,6 @@ def __init__( self._stop = threading.Event() self._thread: Optional[threading.Thread] = None self._client: Optional[SSEClient] = None - base_options = dict(custom_options or {}) - if self._timeout is not None and "timeout" not in base_options: - base_options = {"timeout": self._timeout, **base_options} - self._custom_options = base_options def start(self): if self._thread and self._thread.is_alive(): @@ -75,7 +70,9 @@ def _run(self): connect_strategy = ConnectStrategy.http( self._base_url, headers=self._headers, - urllib3_request_options=self._custom_options, + urllib3_request_options=( + {"timeout": self._timeout} if self._timeout is not None else {} + ), ) retry_strategy = RetryDelayStrategy.default( diff --git a/UnleashClient/constants.py b/UnleashClient/constants.py index bf61f6ed..65570742 100644 --- a/UnleashClient/constants.py +++ b/UnleashClient/constants.py @@ -5,7 +5,6 @@ SDK_NAME = "unleash-python-sdk" SDK_VERSION = version("UnleashClient") REQUEST_TIMEOUT = 30 -REQUEST_RETRIES = 3 METRIC_LAST_SENT_TIME = "mlst" CLIENT_SPEC_VERSION = UnleashEngine.get_spec_version() diff --git a/UnleashClient/transport.py b/UnleashClient/transport.py index c94c9767..2ccaaad5 100644 --- a/UnleashClient/transport.py +++ b/UnleashClient/transport.py @@ -10,7 +10,7 @@ import inspect from dataclasses import dataclass, field -from typing import Any, Mapping, Optional, Protocol, runtime_checkable +from typing import Mapping, Optional, Protocol, runtime_checkable import requests from requests.structures import CaseInsensitiveDict @@ -102,7 +102,6 @@ def _reject_authorization(headers: Mapping[str, str], transport_name: str) -> No def _check_transport( transport: object, *, - http_given: bool, is_async: bool, session_type: type, wrapper_name: str, @@ -111,12 +110,6 @@ def _check_transport( Raises for a transport the client can't use, so the mistake shows up in the constructor instead of as a failed request on every poll. """ - # temp check until HttpOptions is gone - if http_given: - raise ValueError( - "Pass either http or transport, not both. Set headers and the " - f"timeout on the transport: {wrapper_name}(headers=..., timeout=...)." - ) if isinstance(transport, session_type): raise TypeError( f"Wrap the session in a transport: transport={wrapper_name}(session)." @@ -176,13 +169,10 @@ def __init__( *, 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 {}) _reject_authorization(self._headers, "RequestsTransport") if session is not None and _session_retries(session): @@ -209,9 +199,7 @@ def execute(self, request: Request) -> Response: def _send( self, session: requests.Session, request: Request, kwargs: dict ) -> Response: - resp = session.request( - request.method, request.url, **kwargs, **self._request_kwargs - ) + resp = session.request(request.method, request.url, **kwargs) return Response( status=resp.status_code, headers=resp.headers, body=resp.content ) diff --git a/tests/conftest.py b/tests/conftest.py index 0b6e71e1..29e5c712 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,3 +84,9 @@ def cache_segments(tmp_path): @pytest.fixture(autouse=True) def no_retry_backoff(monkeypatch): monkeypatch.setattr("UnleashClient._retry._backoff_delay", lambda retry: 0) + + +@pytest.fixture() +def no_fetch_retries(monkeypatch): + """One attempt per feature fetch, for tests that script a single failure.""" + monkeypatch.setattr("UnleashClient._retry.FETCH_RETRIES", 0) diff --git a/tests/specification_tests/test_async_client_specs.py b/tests/specification_tests/test_async_client_specs.py index eaa2c29c..3e037079 100644 --- a/tests/specification_tests/test_async_client_specs.py +++ b/tests/specification_tests/test_async_client_specs.py @@ -8,7 +8,7 @@ from tests.utilities.testing_constants import APP_NAME from UnleashClient.cache import FileCache from UnleashClient.clients.async_unleash_client import AsyncUnleashClient -from UnleashClient.config import HttpOptions, IdentityOptions, MetricsOptions +from UnleashClient.config import IdentityOptions, MetricsOptions from UnleashClient.constants import FEATURES_URL API_PREFIX = "/api" @@ -38,7 +38,6 @@ async def get_async_client(url, state, cache_directory=None): url=url, app_name=APP_NAME, identity=IdentityOptions(instance_id="pytest_%s" % uuid.uuid4()), - http=HttpOptions(retries=0), metrics=MetricsOptions(disabled=True, disable_registration=True), cache=cache, ) diff --git a/tests/unit_tests/clients/test_async_unleash_client.py b/tests/unit_tests/clients/test_async_unleash_client.py index 22f59a1e..a7889852 100644 --- a/tests/unit_tests/clients/test_async_unleash_client.py +++ b/tests/unit_tests/clients/test_async_unleash_client.py @@ -18,7 +18,6 @@ from UnleashClient.cache import FileCache from UnleashClient.clients.async_unleash_client import AsyncUnleashClient from UnleashClient.config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, @@ -79,12 +78,6 @@ def test_both_clients_build_the_same_config(tmpdir): sdk_flavor="openfeature", sdk_flavor_version="1.2.3", ), - http=HttpOptions( - headers={"X-Custom": "header"}, - options={"verify": False}, - timeout=9, - retries=2, - ), mode=PollingOptions(interval=1, jitter=2, project_name="ivan"), metrics=MetricsOptions( interval=3, jitter=4, disabled=True, disable_registration=True @@ -147,7 +140,6 @@ def test_both_clients_build_the_same_headers(tmpdir): app_name=APP_NAME, api_key="project:environment.hash", identity=IdentityOptions(instance_id="123"), - http=HttpOptions(headers={"X-Custom": "header"}), mode=PollingOptions(interval=1), metrics=MetricsOptions(interval=3), ) @@ -479,13 +471,12 @@ async def server(): @pytest_asyncio.fixture -async def build_running_client(tmpdir, server: FakeUnleash): +async def build_running_client(tmpdir, server: FakeUnleash, no_fetch_retries): built = [] def _build(**kwargs) -> AsyncUnleashClient: kwargs.setdefault("url", server.base_url) kwargs.setdefault("app_name", APP_NAME) - kwargs.setdefault("http", HttpOptions(retries=0)) kwargs.setdefault("metrics", MetricsOptions(disabled=True)) client = build_async_client(tmpdir, **kwargs) built.append(client) diff --git a/tests/unit_tests/clients/test_async_unleash_client_transport.py b/tests/unit_tests/clients/test_async_unleash_client_transport.py index 76212b83..3f189e19 100644 --- a/tests/unit_tests/clients/test_async_unleash_client_transport.py +++ b/tests/unit_tests/clients/test_async_unleash_client_transport.py @@ -9,7 +9,7 @@ from tests.utilities.testing_constants import APP_NAME, ETAG_VALUE, URL from UnleashClient import INSTANCES, AiohttpTransport, Request, Response from UnleashClient.clients.async_unleash_client import AsyncUnleashClient -from UnleashClient.config import HttpOptions, MetricsOptions, PollingOptions +from UnleashClient.config import MetricsOptions, PollingOptions from UnleashClient.constants import FEATURES_URL, METRICS_URL, REGISTER_URL from UnleashClient.utils import InstanceAllowType @@ -152,11 +152,6 @@ async def test_a_session_closed_too_early_is_a_failed_fetch(tmp_path, server): assert server.requests == [] -def test_http_and_transport_together_raise(tmp_path): - with pytest.raises(ValueError, match="either http or transport"): - build_client(tmp_path, FakeAsyncTransport(), http=HttpOptions(timeout=5)) - - class SyncTransport: def execute(self, request): raise AssertionError("never sent") @@ -183,8 +178,8 @@ async def test_a_transport_that_cannot_work_raises(tmp_path, make_transport, mes def test_a_rejected_transport_does_not_register_the_instance(tmp_path): - with pytest.raises(ValueError): - build_client(tmp_path, FakeAsyncTransport(), http=HttpOptions()) + with pytest.raises(TypeError): + build_client(tmp_path, object()) # BLOCK would raise if the rejected client had been registered build_client( diff --git a/tests/unit_tests/clients/test_unleash_client.py b/tests/unit_tests/clients/test_unleash_client.py index cba8137d..658b3567 100644 --- a/tests/unit_tests/clients/test_unleash_client.py +++ b/tests/unit_tests/clients/test_unleash_client.py @@ -32,10 +32,9 @@ REFRESH_INTERVAL, URL, ) -from UnleashClient import INSTANCES, UnleashClient +from UnleashClient import INSTANCES, RequestsTransport, UnleashClient from UnleashClient.cache import BaseCache, FileCache from UnleashClient.config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, @@ -164,12 +163,10 @@ def unleash_client_bootstrap_dependencies(tmp_path): def test_UC_builds_its_config_from_its_arguments(tmp_path): - http = HttpOptions(headers={"X-Custom": "header"}) client = UnleashClient( url=f"{URL}/", app_name=APP_NAME, api_key="project:environment.hash", - http=http, verbose_log_level=40, cache_directory=str(tmp_path), ) @@ -177,7 +174,6 @@ def test_UC_builds_its_config_from_its_arguments(tmp_path): assert client._config.url == URL assert client._config.app_name == APP_NAME assert client._config.api_key == "project:environment.hash" - assert client._config.http is http assert client._config.identity == IdentityOptions() assert client._config.mode == PollingOptions() assert client._config.metrics == MetricsOptions() @@ -887,7 +883,7 @@ def test_uc_with_network_error(tmp_path): unleash_client = UnleashClient( "https://this-will-never-try-to-dns-resolve.invalid/", APP_NAME, - http=HttpOptions(timeout=1), + transport=RequestsTransport(timeout=1), metrics=MetricsOptions(disabled=True, disable_registration=True), cache_directory=str(tmp_path), ) diff --git a/tests/unit_tests/clients/test_unleash_client_transport.py b/tests/unit_tests/clients/test_unleash_client_transport.py index 2b980c05..67f3666d 100644 --- a/tests/unit_tests/clients/test_unleash_client_transport.py +++ b/tests/unit_tests/clients/test_unleash_client_transport.py @@ -8,8 +8,8 @@ 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.config import MetricsOptions, PollingOptions, StreamingOptions +from UnleashClient.constants import FEATURES_URL, REQUEST_TIMEOUT from UnleashClient.utils import InstanceAllowType @@ -97,11 +97,6 @@ def test_the_client_never_closes_a_session_passed_in(tmp_path, mocker): 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") @@ -122,8 +117,8 @@ def test_a_transport_that_cannot_work_raises(tmp_path, transport, message): def test_a_rejected_transport_does_not_register_the_instance(tmp_path): - with pytest.raises(ValueError): - build_client(tmp_path, FakeTransport(), http=HttpOptions()) + with pytest.raises(TypeError): + build_client(tmp_path, object()) with build_client( tmp_path, FakeTransport(), multiple_instance_mode=InstanceAllowType.BLOCK diff --git a/tests/unit_tests/connectors/test_async_connector.py b/tests/unit_tests/connectors/test_async_connector.py index 1a94edf5..bf342aa2 100644 --- a/tests/unit_tests/connectors/test_async_connector.py +++ b/tests/unit_tests/connectors/test_async_connector.py @@ -14,7 +14,7 @@ from UnleashClient._event_dispatcher import _EventDispatcher from UnleashClient._feature_store import _FeatureStore from UnleashClient._headers import _HeaderFactory -from UnleashClient.config import HttpOptions, PollingOptions, UnleashConfig +from UnleashClient.config import PollingOptions, UnleashConfig from UnleashClient.connectors._async_connector import _AsyncPollingConnector from UnleashClient.constants import ETAG, FEATURES_URL from UnleashClient.events import UnleashEventType @@ -37,7 +37,7 @@ async def server(): @pytest_asyncio.fixture -async def build_connector(server: FakeUnleash): +async def build_connector(server: FakeUnleash, no_fetch_retries): built = [] def _build_connector( @@ -45,7 +45,7 @@ def _build_connector( refresh_interval: float = INTERVAL, refresh_jitter: float = None, ) -> _AsyncPollingConnector: - config = UnleashConfig(server.base_url, APP_NAME, http=HttpOptions(retries=0)) + config = UnleashConfig(server.base_url, APP_NAME) api_client = _AsyncUnleashApiClient(config, _HeaderFactory(config)) connector = _AsyncPollingConnector( store=store, diff --git a/tests/unit_tests/connectors/test_polling_connector.py b/tests/unit_tests/connectors/test_polling_connector.py index 6ecc3bf5..77935b09 100644 --- a/tests/unit_tests/connectors/test_polling_connector.py +++ b/tests/unit_tests/connectors/test_polling_connector.py @@ -8,14 +8,10 @@ ) from tests.utilities.testing_constants import ( APP_NAME, - CUSTOM_HEADERS, - CUSTOM_OPTIONS, ETAG_VALUE, INSTANCE_ID, PROJECT_NAME, PROJECT_URL, - REQUEST_RETRIES, - REQUEST_TIMEOUT, URL, ) from UnleashClient._event_dispatcher import _EventDispatcher @@ -24,7 +20,6 @@ from UnleashClient._scheduler import _Scheduler from UnleashClient._unleash_api_client import _UnleashApiClient from UnleashClient.config import ( - HttpOptions, IdentityOptions, PollingOptions, UnleashConfig, @@ -39,12 +34,6 @@ def build_api_client(**kwargs) -> _UnleashApiClient: defaults = { "identity": IdentityOptions(instance_id=INSTANCE_ID), - "http": HttpOptions( - headers=CUSTOM_HEADERS, - options=CUSTOM_OPTIONS, - timeout=REQUEST_TIMEOUT, - retries=REQUEST_RETRIES, - ), } defaults.update(kwargs) config = UnleashConfig(URL, APP_NAME, **defaults) diff --git a/tests/unit_tests/test_async_unleash_api_client.py b/tests/unit_tests/test_async_unleash_api_client.py index b1531f5b..d1173dc5 100644 --- a/tests/unit_tests/test_async_unleash_api_client.py +++ b/tests/unit_tests/test_async_unleash_api_client.py @@ -15,19 +15,15 @@ from tests.utilities.mocks.mock_metrics import MOCK_METRICS_REQUEST from tests.utilities.testing_constants import ( APP_NAME, - CUSTOM_HEADERS, ETAG_VALUE, INSTANCE_ID, PROJECT_NAME, - REQUEST_RETRIES, - REQUEST_TIMEOUT, URL, ) from UnleashClient._aiohttp_transport import AiohttpTransport from UnleashClient._async_unleash_api_client import _AsyncUnleashApiClient from UnleashClient._headers import _HeaderFactory from UnleashClient.config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, @@ -73,11 +69,6 @@ def _build_api_client(events=None, **kwargs) -> _AsyncUnleashApiClient: "url": server.base_url, "app_name": APP_NAME, "identity": IdentityOptions(instance_id=INSTANCE_ID), - "http": HttpOptions( - headers=CUSTOM_HEADERS, - timeout=REQUEST_TIMEOUT, - retries=REQUEST_RETRIES, - ), } defaults.update(kwargs) config = UnleashConfig(**defaults) @@ -318,10 +309,17 @@ async def test_send_metrics(server, api_client, reply, expected): async def test_send_metrics_returns_false_when_the_request_times_out( server, build_api_client ): - api_client = build_api_client(http=HttpOptions(timeout=0.1)) + config = UnleashConfig(server.base_url, APP_NAME) + transport = AiohttpTransport(timeout=0.1) + api_client = _AsyncUnleashApiClient( + config, _HeaderFactory(config), transport=transport + ) server.on("POST", METRICS_PATH, hang=True) - result = await api_client.send_metrics(MOCK_METRICS_REQUEST) + try: + result = await api_client.send_metrics(MOCK_METRICS_REQUEST) + finally: + await transport.aclose() assert len(server.calls("POST", METRICS_PATH)) == 1 assert result is False @@ -357,7 +355,6 @@ async def test_every_endpoint_gets_its_headers_from_the_factory( register = server.calls("POST", REGISTER_PATH)[0].headers metrics = server.calls("POST", METRICS_PATH)[0].headers for headers in (fetch, register, metrics): - assert headers["name"] == CUSTOM_HEADERS["name"] assert headers["unleash-appname"] == APP_NAME assert headers["Unleash-Client-Spec"] == CLIENT_SPEC_VERSION assert fetch["unleash-interval"] == "1000" diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 3f4055c9..6b5dbf16 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -4,7 +4,6 @@ import pytest from UnleashClient.config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, @@ -30,7 +29,6 @@ def test_defaults(): assert config.metrics == MetricsOptions( interval=60, jitter=None, disabled=False, disable_registration=False ) - assert config.http == HttpOptions(headers={}, options={}, timeout=30, retries=3) assert config.verbose_log_level == 30 @@ -39,7 +37,6 @@ def test_groups_left_as_none_get_their_defaults(): URL, APP_NAME, identity=None, - http=None, mode=None, metrics=None, connection_id="fixed-id", @@ -55,8 +52,6 @@ def test_is_immutable(): config.url = "http://elsewhere:4242/api" with pytest.raises(FrozenInstanceError): config.mode.interval = 1 - with pytest.raises(FrozenInstanceError): - config.http.timeout = 1 with pytest.raises(FrozenInstanceError): config.metrics.disabled = True with pytest.raises(FrozenInstanceError): @@ -64,12 +59,12 @@ def test_is_immutable(): def test_options_are_shared_between_configs_without_copying(): - http = HttpOptions(headers={"X-Custom": "header"}) + identity = IdentityOptions(instance_id="instance-1") - first = UnleashConfig(URL, APP_NAME, http=http) - second = UnleashConfig(URL, "other", http=http) + first = UnleashConfig(URL, APP_NAME, identity=identity) + second = UnleashConfig(URL, "other", identity=identity) - assert first.http is second.http + assert first.identity is second.identity def test_url_is_rstripped(): @@ -118,14 +113,12 @@ def test_no_other_coercion(): config = UnleashConfig( URL, APP_NAME, - http=HttpOptions(timeout="9"), mode=PollingOptions(interval="60"), metrics=MetricsOptions(interval="60"), ) assert config.mode.interval == "60" assert config.metrics.interval == "60" - assert config.http.timeout == "9" def test_connection_id_is_generated_and_unique(): @@ -142,43 +135,6 @@ def test_connection_id_can_be_supplied(): assert config.connection_id == "fixed-id" -def test_headers_and_options_are_copied(): - headers = {"X-Custom": "header"} - options = {"verify": False} - http = HttpOptions(headers=headers, options=options) - - headers["extra"] = "header" - options["extra"] = "option" - - assert http.headers == {"X-Custom": "header"} - assert http.options == {"verify": False} - - -def test_missing_headers_and_options_become_empty_dicts(): - http = HttpOptions(headers=None, options=None) - - assert http.headers == {} - assert http.options == {} - - -def test_headers_and_options_are_read_only(): - http = HttpOptions(headers={"X-Custom": "header"}, options={"verify": True}) - - with pytest.raises(TypeError): - http.headers["X-Custom"] = "changed" - with pytest.raises(TypeError): - http.options["verify"] = False - - assert http.headers == {"X-Custom": "header"} - assert http.options == {"verify": True} - - -@pytest.mark.parametrize("name", ["Authorization", "authorization", "AUTHORIZATION"]) -def test_an_authorization_header_is_rejected(name): - with pytest.raises(ValueError, match="api_key"): - HttpOptions(headers={name: "project:environment.hash"}) - - @pytest.mark.parametrize( "kwargs", [ @@ -207,13 +163,6 @@ def test_a_rejected_api_key_is_kept_out_of_the_message(): assert "secret" not in str(error.value) -def test_an_authorization_header_cannot_be_added_later(): - http = HttpOptions(headers={"X-Custom": "header"}) - - with pytest.raises(TypeError): - http.headers["Authorization"] = "project:environment.hash" - - @pytest.mark.parametrize("mode", ["streaming", "polling", {"interval": 5}]) def test_a_mode_of_the_wrong_type_falls_back_on_polling(mode, caplog): config = UnleashConfig(URL, APP_NAME, mode=mode) diff --git a/tests/unit_tests/test_headers.py b/tests/unit_tests/test_headers.py index c6b03f0e..3a70c060 100644 --- a/tests/unit_tests/test_headers.py +++ b/tests/unit_tests/test_headers.py @@ -1,6 +1,5 @@ from UnleashClient._headers import _HeaderFactory from UnleashClient.config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, @@ -44,14 +43,6 @@ def test_base_carries_the_application_headers(): assert headers["Unleash-Client-Spec"] == CLIENT_SPEC_VERSION -def test_base_includes_the_custom_headers(): - factory = build_factory(http=HttpOptions(headers={"X-Custom": "header"})) - - headers = factory.base() - - assert headers["X-Custom"] == "header" - - def test_base_sends_the_api_key_as_the_authorization_header(): factory = build_factory(api_key="project:env.hash") @@ -68,36 +59,14 @@ def test_base_has_no_authorization_header_without_an_api_key(): assert "Authorization" not in headers -def test_application_headers_win_over_custom_headers(): - factory = build_factory( - http=HttpOptions(headers={"Content-Type": "application/xml"}) - ) - - headers = factory.base() - - assert headers["Content-Type"] == "application/json" - - -def test_custom_headers_cannot_override_the_identification_headers(): - factory = build_factory(http=HttpOptions(headers={"unleash-appname": "spoofed"})) - - headers = factory.base() - - assert headers["unleash-appname"] == TEST_APP_NAME - - 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, http=HttpOptions(headers={"name": "header"}) - ) - factory = _HeaderFactory(config) + factory = build_factory() first = factory.base() first["injected"] = "value" assert "injected" not in factory.base() - assert "injected" not in config.http.headers assert "injected" not in APPLICATION_HEADERS @@ -142,9 +111,7 @@ def test_streaming_adds_the_sse_accept_header(): def test_streaming_is_what_the_streaming_connector_used_to_rebuild(): # StreamingConnector used to re-merge these itself. Pinning the # equivalence keeps the two from drifting apart. - factory = build_factory( - api_key="project:env.hash", http=HttpOptions(headers={"X-Custom": "header"}) - ) + factory = build_factory(api_key="project:env.hash") headers = factory.streaming() diff --git a/tests/unit_tests/test_transport.py b/tests/unit_tests/test_transport.py index 599b82f7..9c96ce3c 100644 --- a/tests/unit_tests/test_transport.py +++ b/tests/unit_tests/test_transport.py @@ -116,26 +116,6 @@ def test_execute_sends_once_on_a_connection_error(): assert len(responses.calls) == 1 -@responses.activate -def test_execute_passes_the_extra_request_kwargs(): - responses.add( - responses.GET, ENDPOINT, status=302, headers={"Location": URL + "/elsewhere"} - ) - - response = RequestsTransport(_request_kwargs={"allow_redirects": False}).execute( - Request(method="GET", url=ENDPOINT, headers={}) - ) - - assert response.status == 302 - - -def test_execute_rejects_an_extra_kwarg_it_already_sets(): - transport = RequestsTransport(_request_kwargs={"timeout": 5}) - - with pytest.raises(TypeError): - transport.execute(Request(method="GET", url=ENDPOINT, headers={})) - - @responses.activate def test_execute_uses_a_fresh_session_for_every_request(): responses.add(responses.GET, ENDPOINT, status=200, headers={"Set-Cookie": "a=1"}) diff --git a/tests/unit_tests/test_unleash_api_client.py b/tests/unit_tests/test_unleash_api_client.py index 649375c1..3aacda41 100644 --- a/tests/unit_tests/test_unleash_api_client.py +++ b/tests/unit_tests/test_unleash_api_client.py @@ -13,20 +13,15 @@ from tests.utilities.mocks.mock_metrics import MOCK_METRICS_REQUEST from tests.utilities.testing_constants import ( APP_NAME, - CUSTOM_HEADERS, - CUSTOM_OPTIONS, ETAG_VALUE, INSTANCE_ID, PROJECT_NAME, PROJECT_URL, - REQUEST_RETRIES, - REQUEST_TIMEOUT, URL, ) from UnleashClient._headers import _HeaderFactory from UnleashClient._unleash_api_client import _header, _UnleashApiClient from UnleashClient.config import ( - HttpOptions, IdentityOptions, MetricsOptions, PollingOptions, @@ -55,12 +50,6 @@ def _build_api_client(events=None, **kwargs) -> _UnleashApiClient: "url": URL, "app_name": APP_NAME, "identity": IdentityOptions(instance_id=INSTANCE_ID), - "http": HttpOptions( - headers=CUSTOM_HEADERS, - options=CUSTOM_OPTIONS, - timeout=REQUEST_TIMEOUT, - retries=REQUEST_RETRIES, - ), } defaults.update(kwargs) config = UnleashConfig(**defaults) @@ -103,8 +92,7 @@ def test_fetch_features(api_client, response, status, calls, expected): result = api_client.fetch_features(options=PollingOptions()) - # 4 calls on a 500 is 1 + REQUEST_RETRIES: the mounted HTTPAdapter/Retry - # has to survive every move of this code. + # 4 calls on a 500 is 1 + FETCH_RETRIES. assert len(responses.calls) == calls assert expected(result.raw_state) @@ -179,21 +167,6 @@ def test_fetch_features_retries_and_recovers(build_api_client): assert result.etag == ETAG_VALUE -@responses.activate -def test_fetch_features_swallows_a_bad_custom_option(build_api_client): - responses.add( - responses.GET, FULL_FEATURE_URL, json=MOCK_FEATURE_RESPONSE, status=200 - ) - - # `timeout` collides with the keyword the API client already passes. It is a - # TypeError, not a RequestException, and fetch_features swallows everything. - result = build_api_client(http=HttpOptions(options={"timeout": 5})).fetch_features( - options=PollingOptions() - ) - - assert result == (None, "", False) - - # register @@ -217,7 +190,7 @@ def test_register(api_client, payload, status, expected): result = api_client.register({"appName": APP_NAME}) - # No retry adapter on this one, unlike fetch_features. + # Never retried, unlike fetch_features. assert len(responses.calls) == 1 assert result is expected @@ -252,16 +225,6 @@ def test_register_reports_a_url_requests_rejects_as_a_failure(build_api_client, assert build_api_client(url=url).register({}) is False -@responses.activate -def test_register_reports_a_bad_custom_option_as_a_failure(build_api_client): - responses.add(responses.POST, FULL_REGISTER_URL, json={}, status=202) - - api_client = build_api_client(http=HttpOptions(options={"timeout": 5})) - - assert api_client.register({}) is False - assert len(responses.calls) == 0 - - # send_metrics @@ -293,16 +256,6 @@ def test_send_metrics(api_client, payload, status, expected): assert request["connectionId"] == MOCK_METRICS_REQUEST.get("connectionId") -@responses.activate -def test_send_metrics_reports_a_bad_custom_option_as_a_failure(build_api_client): - responses.add(responses.POST, FULL_METRICS_URL, json={}, status=202) - - api_client = build_api_client(http=HttpOptions(options={"timeout": 5})) - - assert api_client.send_metrics({}) is False - assert len(responses.calls) == 0 - - @responses.activate def test_every_endpoint_gets_its_headers_from_the_factory(build_api_client): responses.add( @@ -317,9 +270,8 @@ def test_every_endpoint_gets_its_headers_from_the_factory(build_api_client): api_client.send_metrics(MOCK_METRICS_REQUEST) fetch, register, metrics = (call.request.headers for call in responses.calls) - # The custom header and the identification pair reach all three... + # The identification headers reach all three... for headers in (fetch, register, metrics): - assert headers["name"] == CUSTOM_HEADERS["name"] assert headers["unleash-appname"] == APP_NAME assert headers["Unleash-Client-Spec"] == CLIENT_SPEC_VERSION # ...and each endpoint gets the interval its own header set carries. diff --git a/tests/utilities/testing_constants.py b/tests/utilities/testing_constants.py index 8fade90a..c61f5588 100644 --- a/tests/utilities/testing_constants.py +++ b/tests/utilities/testing_constants.py @@ -11,10 +11,7 @@ METRICS_JITTER = None DISABLE_METRICS = True DISABLE_REGISTRATION = True -CUSTOM_HEADERS = {"name": "My random header."} -CUSTOM_OPTIONS = {"verify": False} REQUEST_TIMEOUT = 30 -REQUEST_RETRIES = 3 # URLs URL = "http://localhost:4242/api" diff --git a/v7_MIGRATION_GUIDE.md b/v7_MIGRATION_GUIDE.md index f354da86..6fcbdde4 100644 --- a/v7_MIGRATION_GUIDE.md +++ b/v7_MIGRATION_GUIDE.md @@ -4,7 +4,7 @@ This guide highlights the key changes you should be aware of when upgrading to v ## Settings are grouped into option objects -`url` and `app_name` are still the first two arguments. The API token is now its own `api_key` argument. All other arguments must now be passed by keyword. Most other settings moved into option objects: `IdentityOptions`, `HttpOptions`, `MetricsOptions`, and either `PollingOptions` or `StreamingOptions`. The option objects take keyword arguments only. +`url` and `app_name` are still the first two arguments. The API token is now its own `api_key` argument. All other arguments must now be passed by keyword. Most other settings moved into option objects: `IdentityOptions`, `MetricsOptions`, and either `PollingOptions` or `StreamingOptions`. HTTP settings moved to the transport, see [below](#http-settings-are-set-on-the-transport). The option objects take keyword arguments only. Before: @@ -52,10 +52,10 @@ The same applies to `AsyncUnleashClient`, but only polling is supported. | `instance_id` | `IdentityOptions(instance_id=...)` | | `sdk_flavor` | `IdentityOptions(sdk_flavor=...)` | | `sdk_flavor_version` | `IdentityOptions(sdk_flavor_version=...)` | -| `custom_headers` | `HttpOptions(headers=...)`, without `Authorization` | -| `custom_options` | `HttpOptions(options=...)` | -| `request_timeout` | `HttpOptions(timeout=...)` | -| `request_retries` | `HttpOptions(retries=...)` | +| `custom_headers` | `transport=RequestsTransport(headers=...)`, without `Authorization` | +| `custom_options` | removed: configure a `requests.Session`, see [below](#http-settings-are-set-on-the-transport) | +| `request_timeout` | `transport=RequestsTransport(timeout=...)` | +| `request_retries` | removed: a failed feature fetch is retried up to 3 times | | `refresh_interval` | `PollingOptions(interval=...)` | | `refresh_jitter` | `PollingOptions(jitter=...)` | | `project_name` | `PollingOptions(project_name=...)` | @@ -79,7 +79,48 @@ Pass the API token as `api_key` instead of as an `Authorization` header: client = UnleashClient(url, app_name, api_key="") ``` -An `Authorization` header in `HttpOptions.headers` raises a `ValueError`, in any letter case. It is not silently ignored, because a client without a key still starts: its requests fail with 401 and it keeps serving the flags in its cache, so the mistake could go unnoticed. All other custom headers work as before. +An `Authorization` header in the `headers` of a `RequestsTransport` or `AiohttpTransport` raises a `ValueError`, in any letter case. It is not silently ignored, because a client without a key still starts: its requests fail with 401 and it keeps serving the flags in its cache, so the mistake could go unnoticed. All other custom headers work as before. + +## HTTP settings are set on the transport + +Headers, timeout and anything else about how requests are sent are set on the transport, passed as `transport=`. For `UnleashClient` that's a `RequestsTransport`, for `AsyncUnleashClient` an `AiohttpTransport`, with the same `headers` and `timeout` arguments. + +Before: + +``` python +client = UnleashClient( + url="https://YOUR-API-URL", + app_name="my-python-app", + custom_headers={"Authorization": "", "X-Team": "checkout"}, + custom_options={"verify": "/etc/ssl/certs/internal-ca.pem", "proxies": {"https": "http://proxy.internal:3128"}}, + request_timeout=10, + request_retries=1, +) +``` + +After: + +``` python +import requests +from UnleashClient import RequestsTransport, UnleashClient + +session = requests.Session() +session.verify = "/etc/ssl/certs/internal-ca.pem" +session.proxies = {"https": "http://proxy.internal:3128"} + +client = UnleashClient( + url="https://YOUR-API-URL", + app_name="my-python-app", + api_key="", + transport=RequestsTransport(session, headers={"X-Team": "checkout"}, timeout=10), +) +``` + +`custom_options` were keyword arguments for every `requests` call. Most of them are session settings, anything else, such as `allow_redirects`, can be done in your own transport, see [custom HTTP transport](./README.md#custom-http-transport). The client never closes a session you pass in. + +`request_retries` has been removed. A failed feature fetch is retried up to 3 times, on a 500, 502 or 504 or when no response arrives, with a short backoff. Registration and metrics are never retried. To stop retries stacking, don't mount a retrying adapter on your session. + +In streaming mode, the stream doesn't use the transport: it connects with the SDK's own headers and a 30 second timeout. In v6 it also received `custom_headers`, `request_timeout` and `custom_options`; there is no replacement for those. If your proxy or gateway needs a custom header on every request, use polling for now. ## The URL is validated @@ -131,17 +172,17 @@ client.is_enabled("my-flag", {"environment": "default"}) A client's settings can't be changed after it is created. To change a setting, create a new client. -In v6.9, assigning to `unleash_url`, `unleash_custom_headers` and the other request settings on a running client took effect on the next request; that is no longer possible. Headers and options are copied when the option objects are built, so changing the dicts you passed in has no effect either. +In v6.9, assigning to `unleash_url`, `unleash_custom_headers` and the other request settings on a running client took effect on the next request; that is no longer possible. Option objects and transport headers are copied when they are built, so changing the dicts you passed in has no effect either. ## Removed `unleash_*` properties The `unleash_*` properties that mirrored settings (`unleash_url`, `unleash_app_name`, `unleash_refresh_interval`, `unleash_custom_headers`, and so on), as well as `strategy_mapping`, `connector_mode` and `unleash_metrics_interval_str_millis`, have been removed. There is no replacement: the settings are the ones you passed in, so keep a reference to the option objects if you need them later. ``` python -http = HttpOptions(headers={"X-Custom": ""}) -client = UnleashClient(url, app_name, http=http) +mode = PollingOptions(interval=30) +client = UnleashClient(url, app_name, mode=mode) -http.headers # instead of client.unleash_custom_headers +mode.interval # instead of client.unleash_refresh_interval ``` `client.connection_id`, `client.unleash_scheduler`, `client.unleash_executor_name` and `client.metric_job` are unchanged. From 9f141bf2c8ae83570516e419de35131dbf378104 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 13:08:45 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80c58259..d4e74681 100644 --- a/README.md +++ b/README.md @@ -521,7 +521,7 @@ tls.load_cert_chain("/etc/unleash/client.crt", "/etc/unleash/client.key") async def main(): async with aiohttp.ClientSession( - trust_env=True, + trust_env=True, connector=aiohttp.TCPConnector(ssl=tls), trace_configs=[my_trace_config], # e.g. aiohttp tracing for OpenTelemetry ) as session: From 509255dfd185d78000548ae20dd09bdb00482453 Mon Sep 17 00:00:00 2001 From: Alexandra Szanto Date: Fri, 6 Nov 2026 09:15:00 +0100 Subject: [PATCH 3/3] fix: unused imports --- tests/unit_tests/clients/test_unleash_client_transport.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/clients/test_unleash_client_transport.py b/tests/unit_tests/clients/test_unleash_client_transport.py index 67f3666d..3a5c6fe2 100644 --- a/tests/unit_tests/clients/test_unleash_client_transport.py +++ b/tests/unit_tests/clients/test_unleash_client_transport.py @@ -8,8 +8,8 @@ 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 MetricsOptions, PollingOptions, StreamingOptions -from UnleashClient.constants import FEATURES_URL, REQUEST_TIMEOUT +from UnleashClient.config import MetricsOptions, PollingOptions +from UnleashClient.constants import FEATURES_URL from UnleashClient.utils import InstanceAllowType