Skip to content

Commit faa629d

Browse files
feat: Add asynchronous client
1 parent 0bf3076 commit faa629d

File tree

30 files changed

+1950
-4
lines changed

30 files changed

+1950
-4
lines changed

.github/workflows/pull_request.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ on:
44
push:
55
pull_request:
66

7+
concurrency:
8+
cancel-in-progress: true
9+
group: CI-${{ github.ref }}
710

811
jobs:
912
main:
@@ -30,6 +33,7 @@ jobs:
3033
coveralls --service=github
3134
3235
tox:
36+
timeout-minutes: 10
3337
runs-on: ${{ matrix.os }}
3438
strategy:
3539
matrix:

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ pip install UnleashClient
2424
You must initialize the SDK before you use it. Note that until the SDK has synchronized with the API, all features will evaluate to `false` unless
2525
you have a [bootstrapped configuration](#bootstrap) or you use [fallbacks](#fallback-function).
2626

27+
#### Synchronous Client
28+
2729
```python
2830
from UnleashClient import UnleashClient
2931

@@ -35,6 +37,27 @@ client = UnleashClient(
3537
client.initialize_client()
3638
```
3739

40+
#### Asynchronous Client
41+
42+
The SDK also supports asynchronous operations using `asyncio`:
43+
44+
```python
45+
import asyncio
46+
from UnleashClient.asynchronous import AsyncUnleashClient
47+
48+
async def main():
49+
async with AsyncUnleashClient(
50+
url="https:<YOUR-API-URL>",
51+
app_name="my-python-app",
52+
custom_headers={'Authorization': '<API token>'}
53+
) as client:
54+
# Client is automatically initialized
55+
enabled = client.is_enabled("my_toggle")
56+
print(enabled)
57+
58+
asyncio.run(main())
59+
```
60+
3861
### Check features
3962

4063
Once the SDK is initialized, you can evaluate toggles using the `is_enabled` or `get_variant` methods.
@@ -61,10 +84,20 @@ print(variant)
6184

6285
If your program no longer needs the SDK, you can call `destroy()`, which shuts down the SDK and flushes any pending metrics to Unleash.
6386

87+
#### Synchronous Client
88+
6489
```python
6590
client.destroy()
6691
```
6792

93+
#### Asynchronous Client
94+
95+
```python
96+
await client.destroy()
97+
```
98+
99+
When using the async client as a context manager (recommended), cleanup is handled automatically.
100+
68101
## Usage
69102

70103
### Context
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# flake8: noqa
2+
from .features import get_feature_toggles
3+
from .metrics import send_metrics
4+
from .register import register_client
5+
6+
__all__ = ["get_feature_toggles", "send_metrics", "register_client"]
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from typing import Optional, Tuple
2+
3+
import niquests as requests
4+
5+
from ...constants import FEATURES_URL
6+
from ...utils import LOGGER, log_resp_info
7+
8+
9+
# pylint: disable=broad-except
10+
async def get_feature_toggles(
11+
url: str,
12+
app_name: str,
13+
instance_id: str,
14+
headers: dict,
15+
custom_options: dict,
16+
request_timeout: int,
17+
request_retries: int,
18+
project: Optional[str] = None,
19+
cached_etag: str = "",
20+
) -> Tuple[str, str]:
21+
"""
22+
Retrieves feature flags from unleash central server asynchronously.
23+
24+
Notes:
25+
* If unsuccessful (i.e. not HTTP status code 200), exception will be caught and logged.
26+
This is to allow "safe" error handling if unleash server goes down.
27+
28+
:param url:
29+
:param app_name:
30+
:param instance_id:
31+
:param headers:
32+
:param custom_options:
33+
:param request_timeout:
34+
:param request_retries:
35+
:param project:
36+
:param cached_etag:
37+
:return: (Feature flags, etag) if successful, ({},'') if not
38+
"""
39+
try:
40+
LOGGER.info("Getting feature flag.")
41+
42+
request_specific_headers = {
43+
"UNLEASH-APPNAME": app_name,
44+
"UNLEASH-INSTANCEID": instance_id,
45+
}
46+
47+
if cached_etag:
48+
request_specific_headers["If-None-Match"] = cached_etag
49+
50+
base_url = f"{url}{FEATURES_URL}"
51+
base_params = {}
52+
53+
if project:
54+
base_params = {"project": project}
55+
56+
async with requests.AsyncSession() as session:
57+
resp = await session.get(
58+
base_url,
59+
headers={**headers, **request_specific_headers},
60+
params=base_params,
61+
timeout=request_timeout,
62+
**custom_options,
63+
)
64+
65+
if resp.status_code not in [200, 304]:
66+
log_resp_info(resp)
67+
LOGGER.warning(
68+
"Unleash Client feature fetch failed due to unexpected HTTP status code: %s",
69+
resp.status_code,
70+
)
71+
raise Exception(
72+
"Unleash Client feature fetch failed!"
73+
) # pylint: disable=broad-exception-raised
74+
75+
etag = ""
76+
if "etag" in resp.headers.keys():
77+
etag = resp.headers["etag"] # type: ignore[assignment]
78+
79+
if resp.status_code == 304:
80+
return None, etag
81+
82+
return resp.text, etag
83+
except Exception as exc:
84+
LOGGER.exception(
85+
"Unleash Client feature fetch failed due to exception: %s", exc
86+
)
87+
88+
return None, ""
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import json
2+
3+
import niquests as requests
4+
5+
from ...constants import APPLICATION_HEADERS, METRICS_URL
6+
from ...utils import LOGGER, log_resp_info
7+
8+
9+
# pylint: disable=broad-except
10+
async def send_metrics(
11+
url: str,
12+
request_body: dict,
13+
headers: dict,
14+
custom_options: dict,
15+
request_timeout: int,
16+
) -> bool:
17+
"""
18+
Attempts to send metrics to Unleash server asynchronously
19+
20+
Notes:
21+
* If unsuccessful (i.e. not HTTP status code 200), message will be logged
22+
23+
:param url:
24+
:param request_body:
25+
:param headers:
26+
:param custom_options:
27+
:param request_timeout:
28+
:return: true if registration successful, false if registration unsuccessful or exception.
29+
"""
30+
try:
31+
LOGGER.info("Sending messages to with unleash @ %s", url)
32+
LOGGER.info("unleash metrics information: %s", request_body)
33+
34+
async with requests.AsyncSession() as session:
35+
resp = await session.post(
36+
url + METRICS_URL,
37+
data=json.dumps(request_body),
38+
headers={**headers, **APPLICATION_HEADERS},
39+
timeout=request_timeout,
40+
**custom_options,
41+
)
42+
43+
if resp.status_code != 202:
44+
log_resp_info(resp)
45+
LOGGER.warning(
46+
"Unleash Client metrics submission due to unexpected HTTP status code: %s",
47+
resp.status_code,
48+
)
49+
return False
50+
51+
LOGGER.info("Unleash Client metrics successfully sent!")
52+
53+
return True
54+
except requests.RequestException as exc:
55+
LOGGER.warning(
56+
"Unleash Client metrics submission failed due to exception: %s", exc
57+
)
58+
59+
return False
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import json
2+
from datetime import datetime, timezone
3+
from platform import python_implementation, python_version
4+
5+
import niquests as requests
6+
import yggdrasil_engine
7+
from niquests.exceptions import InvalidHeader, InvalidSchema, InvalidURL, MissingSchema
8+
9+
from ...constants import (
10+
APPLICATION_HEADERS,
11+
CLIENT_SPEC_VERSION,
12+
REGISTER_URL,
13+
SDK_NAME,
14+
SDK_VERSION,
15+
)
16+
from ...utils import LOGGER, log_resp_info
17+
18+
19+
# pylint: disable=broad-except
20+
async def register_client(
21+
url: str,
22+
app_name: str,
23+
instance_id: str,
24+
connection_id: str,
25+
metrics_interval: int,
26+
headers: dict,
27+
custom_options: dict,
28+
supported_strategies: dict,
29+
request_timeout: int,
30+
) -> bool:
31+
"""
32+
Attempts to register client with unleash server asynchronously.
33+
34+
Notes:
35+
* If unsuccessful (i.e. not HTTP status code 202), exception will be caught and logged.
36+
This is to allow "safe" error handling if unleash server goes down.
37+
38+
:param url:
39+
:param app_name:
40+
:param instance_id:
41+
:param metrics_interval:
42+
:param headers:
43+
:param custom_options:
44+
:param supported_strategies:
45+
:param request_timeout:
46+
:return: true if registration successful, false if registration unsuccessful or exception.
47+
"""
48+
registration_request = {
49+
"appName": app_name,
50+
"instanceId": instance_id,
51+
"connectionId": connection_id,
52+
"sdkVersion": f"{SDK_NAME}:{SDK_VERSION}",
53+
"strategies": [*supported_strategies],
54+
"started": datetime.now(timezone.utc).isoformat(),
55+
"interval": metrics_interval,
56+
"platformName": python_implementation(),
57+
"platformVersion": python_version(),
58+
"yggdrasilVersion": yggdrasil_engine.__yggdrasil_core_version__,
59+
"specVersion": CLIENT_SPEC_VERSION,
60+
}
61+
62+
try:
63+
LOGGER.info("Registering unleash client with unleash @ %s", url)
64+
LOGGER.info("Registration request information: %s", registration_request)
65+
66+
async with requests.AsyncSession() as session:
67+
resp = await session.post(
68+
url + REGISTER_URL,
69+
data=json.dumps(registration_request),
70+
headers={**headers, **APPLICATION_HEADERS},
71+
timeout=request_timeout,
72+
**custom_options,
73+
)
74+
75+
if resp.status_code not in {200, 202}:
76+
log_resp_info(resp)
77+
LOGGER.warning(
78+
"Unleash Client registration failed due to unexpected HTTP status code: %s",
79+
resp.status_code,
80+
)
81+
return False
82+
83+
LOGGER.info("Unleash Client successfully registered!")
84+
85+
return True
86+
except (MissingSchema, InvalidSchema, InvalidHeader, InvalidURL) as exc:
87+
LOGGER.exception(
88+
"Unleash Client registration failed fatally due to exception: %s", exc
89+
)
90+
raise exc
91+
except requests.RequestException as exc:
92+
LOGGER.exception("Unleash Client registration failed due to exception: %s", exc)
93+
94+
return False

UnleashClient/api/features.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def get_feature_toggles(
8181

8282
etag = ""
8383
if "etag" in resp.headers.keys():
84-
etag = resp.headers["etag"]
84+
etag = resp.headers["etag"] # type: ignore[assignment]
8585

8686
if resp.status_code == 304:
8787
return None, etag

0 commit comments

Comments
 (0)