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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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="<API token>"`. An `Authorization` header in `HttpOptions.headers` raises `ValueError`.
* (Major): The API token is passed as `api_key="<API token>"`. 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.
Expand Down
22 changes: 6 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<API token>",
http=HttpOptions(timeout=10),
transport=RequestsTransport(timeout=10),
mode=PollingOptions(interval=30),
metrics=MetricsOptions(disabled=True),
)
Expand All @@ -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 |
Expand All @@ -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`

Expand All @@ -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.
Expand Down Expand Up @@ -531,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:
Expand Down Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion UnleashClient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
build_ready_callback,
)
from .config import (
HttpOptions,
IdentityOptions,
MetricsOptions,
PollingOptions,
Expand Down
13 changes: 5 additions & 8 deletions UnleashClient/_async_unleash_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,21 @@ 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
self._events: Optional[_EventDispatcher] = events
# 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()
Expand Down Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions UnleashClient/_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 10 additions & 6 deletions UnleashClient/_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
10 changes: 2 additions & 8 deletions UnleashClient/_unleash_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand All @@ -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:
Expand Down Expand Up @@ -177,7 +172,6 @@ def _fetch_features(
},
params=base_params,
),
config.http.retries,
)

http_status_code = resp.status
Expand Down
7 changes: 1 addition & 6 deletions UnleashClient/clients/async_unleash_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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__(
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand Down
Loading
Loading