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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
122 changes: 122 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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="<API token>",
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="<API token>",
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.
Expand Down
1 change: 1 addition & 0 deletions UnleashClient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
PollingOptions,
StreamingOptions,
)
from .transport import Request, RequestsTransport, Response, Transport

if TYPE_CHECKING:
from .clients.async_unleash_client import AsyncUnleashClient
Expand Down
2 changes: 1 addition & 1 deletion UnleashClient/_aiohttp_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion UnleashClient/_async_unleash_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion UnleashClient/_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
108 changes: 0 additions & 108 deletions UnleashClient/_transport.py

This file was deleted.

29 changes: 19 additions & 10 deletions UnleashClient/_unleash_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
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 (
FetchStatus,
UnleashEventType,
UnleashFetchCompletedEvent,
)
from UnleashClient.transport import Request, RequestsTransport, Response, Transport
from UnleashClient.utils import LOGGER


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

Expand All @@ -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.
Comment on lines +105 to +106

@alexanto alexanto Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems rare but possibly legit to me, so pushed a fix

"""

def __init__(
Expand All @@ -108,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:
Expand Down Expand Up @@ -182,9 +193,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 (
Expand Down
Loading
Loading