devcycle-python-server-sdk 2.0.1__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/PKG-INFO +1 -1
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/README.md +36 -14
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/VERSION.txt +1 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/__init__.py +5 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/api/backoff.py +11 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/api/bucketing_client.py +17 -25
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/api/config_client.py +116 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/api/event_client.py +95 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/api/local_bucketing.py +436 -0
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/dvc_cloud_client.py → devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/cloud_client.py +69 -41
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/exceptions.py +43 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/local_client.py +220 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/managers/config_manager.py +95 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/managers/event_queue_manager.py +223 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/models/bucketed_config.py +150 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/models/event.py +123 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/models/feature.py +12 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/models/platform_data.py +33 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/models/user.py +76 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/models/variable.py +17 -5
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/options.py +106 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/protobuf/utils.py +129 -0
- devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/util/version.py +1 -1
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_server_sdk.egg-info/PKG-INFO +1 -1
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_server_sdk.egg-info/SOURCES.txt +23 -6
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_server_sdk.egg-info/requires.txt +2 -0
- devcycle-python-server-sdk-2.0.1/example/example.py → devcycle-python-server-sdk-3.0.0/example/cloud_client_example.py +24 -20
- devcycle-python-server-sdk-3.0.0/example/local_bucketing_client_example.py +74 -0
- devcycle-python-server-sdk-3.0.0/pyproject.toml +3 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/test/api/test_bucketing_client.py +10 -5
- devcycle-python-server-sdk-3.0.0/test/api/test_config_client.py +133 -0
- devcycle-python-server-sdk-3.0.0/test/api/test_event_client.py +97 -0
- devcycle-python-server-sdk-3.0.0/test/api/test_local_bucketing.py +392 -0
- devcycle-python-server-sdk-3.0.0/test/fixture/data.py +57 -0
- devcycle-python-server-sdk-3.0.0/test/managers/__init__.py +0 -0
- devcycle-python-server-sdk-3.0.0/test/managers/test_config_manager.py +132 -0
- devcycle-python-server-sdk-3.0.0/test/managers/test_event_queue_manager.py +271 -0
- devcycle-python-server-sdk-3.0.0/test/models/__init__.py +0 -0
- devcycle-python-server-sdk-3.0.0/test/models/test_bucketed_config.py +26 -0
- devcycle-python-server-sdk-2.0.1/test/test_dvc_cloud_client.py → devcycle-python-server-sdk-3.0.0/test/test_cloud_client.py +11 -17
- devcycle-python-server-sdk-3.0.0/test/test_local_client.py +397 -0
- devcycle-python-server-sdk-3.0.0/test/util/__init__.py +0 -0
- devcycle-python-server-sdk-3.0.0/test/util/test_utils.py +183 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/test/util/test_version.py +2 -2
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/VERSION.txt +0 -1
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/__init__.py +0 -5
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/api/local_bucketing.py +0 -9
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/dvc_local_client.py +0 -152
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/dvc_options.py +0 -45
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/exceptions.py +0 -21
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/managers/config_manager.py +0 -27
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/managers/event_queue_manager.py +0 -18
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/models/event.py +0 -20
- devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/models/user.py +0 -31
- devcycle-python-server-sdk-2.0.1/test/api/test_local_bucketing.py +0 -17
- devcycle-python-server-sdk-2.0.1/test/managers/test_config_manager.py +0 -17
- devcycle-python-server-sdk-2.0.1/test/managers/test_event_queue_manager.py +0 -17
- devcycle-python-server-sdk-2.0.1/test/test_dvc_local_client.py +0 -143
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/LICENSE +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/api/__init__.py +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/managers/__init__.py +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/models/__init__.py +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/models/error_response.py +0 -0
- {devcycle-python-server-sdk-2.0.1/example → devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/protobuf}/__init__.py +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/py.typed +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/util/__init__.py +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_server_sdk.egg-info/dependency_links.txt +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_server_sdk.egg-info/top_level.txt +0 -0
- {devcycle-python-server-sdk-2.0.1/test → devcycle-python-server-sdk-3.0.0/example}/__init__.py +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/setup.cfg +0 -0
- {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/setup.py +0 -0
- {devcycle-python-server-sdk-2.0.1/test/api → devcycle-python-server-sdk-3.0.0/test}/__init__.py +0 -0
- {devcycle-python-server-sdk-2.0.1/test/managers → devcycle-python-server-sdk-3.0.0/test/api}/__init__.py +0 -0
- {devcycle-python-server-sdk-2.0.1/test/util → devcycle-python-server-sdk-3.0.0/test/fixture}/__init__.py +0 -0
|
@@ -15,31 +15,30 @@ pip install devcycle-python-server-sdk
|
|
|
15
15
|
```
|
|
16
16
|
(you may need to run `pip` with root permission: `sudo pip install devcycle-python-server-sdk`)
|
|
17
17
|
|
|
18
|
-
Then import the package:
|
|
19
|
-
```python
|
|
20
|
-
import devcycle_python_sdk
|
|
21
|
-
```
|
|
22
|
-
|
|
23
18
|
## Getting Started
|
|
24
19
|
|
|
20
|
+
The core DevCycle objects are in the `devcycle_python_sdk` package. To get started, import the `DevCycleLocalClient` class and the `DevCycleLocalOptions` class. The `DevCycleLocalClient` class is used to interact with the DevCycle API. The `DevCycleLocalOptions` class is used to configure the client.
|
|
21
|
+
|
|
25
22
|
```python
|
|
26
|
-
from devcycle_python_sdk import
|
|
27
|
-
from devcycle_python_sdk.models.user import
|
|
23
|
+
from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions
|
|
24
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
28
25
|
|
|
29
|
-
options =
|
|
26
|
+
options = DevCycleLocalOptions()
|
|
30
27
|
|
|
31
28
|
# create an instance of the client class
|
|
32
|
-
|
|
29
|
+
client = DevCycleLocalClient('DEVCYCLE_SERVER_SDK_KEY', options)
|
|
33
30
|
|
|
34
|
-
user =
|
|
31
|
+
user = DevCycleUser(
|
|
35
32
|
user_id='test',
|
|
36
33
|
email='example@example.ca',
|
|
37
34
|
country='CA'
|
|
38
35
|
)
|
|
39
36
|
|
|
40
|
-
value =
|
|
37
|
+
value = client.variable_value(user, 'variable-key', 'default-value')
|
|
41
38
|
```
|
|
42
39
|
|
|
40
|
+
The DevCycle client is designed to work as a singleton in your application. You should create a single instance of the client during application initialization
|
|
41
|
+
|
|
43
42
|
## Usage
|
|
44
43
|
|
|
45
44
|
To find usage documentation, visit our [docs](https://docs.devcycle.com/docs/sdk/server-side-sdks/python#usage).
|
|
@@ -62,7 +61,7 @@ pip install --editable .
|
|
|
62
61
|
from the top level of the repo (same level as setup.py). Then run the example app as normal.
|
|
63
62
|
|
|
64
63
|
|
|
65
|
-
### Linting
|
|
64
|
+
### Linting & Formatting
|
|
66
65
|
|
|
67
66
|
Linting checks on PRs are run using [ruff](https://github.com/charliermarsh/ruff), and are configured using `.ruff.toml`. To run the linter locally, run this command from the top level of the repo:
|
|
68
67
|
```
|
|
@@ -74,9 +73,32 @@ Ruff can automatically fix simple linting errors (the ones marked with `[*]`). T
|
|
|
74
73
|
ruff check . --fix
|
|
75
74
|
```
|
|
76
75
|
|
|
76
|
+
Formatting checks on PRs are done using [black](https://github.com/psf/black). To run the formatter locally, run this command from the top level of the repo:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
black --target-version py37 --extend-exclude '_pb2\.pyi?$' .
|
|
80
|
+
```
|
|
81
|
+
|
|
77
82
|
### Unit Tests
|
|
78
83
|
|
|
79
84
|
To run the unit tests, run:
|
|
80
85
|
```bash
|
|
81
|
-
|
|
82
|
-
```
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Benchmarks
|
|
90
|
+
|
|
91
|
+
To run the benchmarks, run:
|
|
92
|
+
```bash
|
|
93
|
+
pytest --benchmark-only
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Protobuf Code Generation
|
|
97
|
+
|
|
98
|
+
To generate the protobuf source files run the following from the root of the project. Ensure you have `protoc` installed.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
protoc --proto_path=./protobuf/ --python_out=devcycle_python_sdk/protobuf --pyi_out=devcycle_python_sdk/protobuf variableForUserParams.proto
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This will rebuild the `variableForUserParams_pb2.py` file. DO NOT edit this file directly.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.0.0
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def exponential_backoff(attempt: int, base_delay: float) -> float:
|
|
6
|
+
"""
|
|
7
|
+
Exponential backoff starting with 200ms +- 0...40ms jitter
|
|
8
|
+
"""
|
|
9
|
+
delay = math.pow(2, attempt) * base_delay / 2.0
|
|
10
|
+
random_sum = delay * 0.1 * random.random()
|
|
11
|
+
return delay + random_sum
|
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import math
|
|
3
|
-
from os.path import join
|
|
4
|
-
import random
|
|
5
2
|
import time
|
|
3
|
+
from os.path import join
|
|
6
4
|
from typing import Dict, List, Optional
|
|
7
5
|
|
|
8
6
|
import requests
|
|
9
7
|
|
|
8
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
9
|
+
from devcycle_python_sdk.options import DevCycleCloudOptions
|
|
10
10
|
from devcycle_python_sdk.exceptions import (
|
|
11
11
|
CloudClientError,
|
|
12
12
|
NotFoundError,
|
|
13
13
|
CloudClientUnauthorizedError,
|
|
14
14
|
)
|
|
15
|
-
from devcycle_python_sdk.
|
|
16
|
-
from devcycle_python_sdk.models.user import User
|
|
17
|
-
from devcycle_python_sdk.models.event import Event
|
|
18
|
-
from devcycle_python_sdk.models.variable import Variable
|
|
15
|
+
from devcycle_python_sdk.models.event import DevCycleEvent
|
|
19
16
|
from devcycle_python_sdk.models.feature import Feature
|
|
17
|
+
from devcycle_python_sdk.models.user import DevCycleUser
|
|
18
|
+
from devcycle_python_sdk.models.variable import Variable
|
|
20
19
|
|
|
21
20
|
logger = logging.getLogger(__name__)
|
|
22
21
|
|
|
@@ -34,7 +33,7 @@ class BucketingAPIClient:
|
|
|
34
33
|
self.session.max_redirects = 0
|
|
35
34
|
|
|
36
35
|
def _url(self, *path_args: str) -> str:
|
|
37
|
-
return join(self.options.
|
|
36
|
+
return join(self.options.bucketing_api_uri, "v1", *path_args)
|
|
38
37
|
|
|
39
38
|
def request(self, method: str, url: str, **kwargs) -> dict:
|
|
40
39
|
retries_remaining = self.options.request_retries + 1
|
|
@@ -72,7 +71,7 @@ class BucketingAPIClient:
|
|
|
72
71
|
if not request_error:
|
|
73
72
|
break
|
|
74
73
|
|
|
75
|
-
logger.
|
|
74
|
+
logger.debug(
|
|
76
75
|
f"DevCycle cloud bucketing request failed (attempt {attempts}): {request_error}"
|
|
77
76
|
)
|
|
78
77
|
retries_remaining -= 1
|
|
@@ -89,17 +88,17 @@ class BucketingAPIClient:
|
|
|
89
88
|
data: dict = res.json()
|
|
90
89
|
return data
|
|
91
90
|
|
|
92
|
-
def variable(self, key: str, user:
|
|
91
|
+
def variable(self, key: str, user: DevCycleUser) -> Variable:
|
|
93
92
|
data = self.request("POST", self._url("variables", key), json=user.to_json())
|
|
94
93
|
|
|
95
94
|
return Variable(
|
|
96
95
|
_id=data.get("_id"),
|
|
97
|
-
key=data.get("key"),
|
|
98
|
-
type=data.get("type"),
|
|
96
|
+
key=data.get("key", ""),
|
|
97
|
+
type=data.get("type", ""),
|
|
99
98
|
value=data.get("value"),
|
|
100
99
|
)
|
|
101
100
|
|
|
102
|
-
def variables(self, user:
|
|
101
|
+
def variables(self, user: DevCycleUser) -> Dict[str, Variable]:
|
|
103
102
|
data = self.request("POST", self._url("variables"), json=user.to_json())
|
|
104
103
|
|
|
105
104
|
result: Dict[str, Variable] = {}
|
|
@@ -114,7 +113,7 @@ class BucketingAPIClient:
|
|
|
114
113
|
|
|
115
114
|
return result
|
|
116
115
|
|
|
117
|
-
def features(self, user:
|
|
116
|
+
def features(self, user: DevCycleUser) -> Dict[str, Feature]:
|
|
118
117
|
data = self.request("POST", self._url("features"), json=user.to_json())
|
|
119
118
|
|
|
120
119
|
result: Dict[str, Feature] = {}
|
|
@@ -131,23 +130,16 @@ class BucketingAPIClient:
|
|
|
131
130
|
|
|
132
131
|
return result
|
|
133
132
|
|
|
134
|
-
def track(self, user:
|
|
133
|
+
def track(self, user: DevCycleUser, events: List[DevCycleEvent]) -> str:
|
|
135
134
|
data = self.request(
|
|
136
135
|
"POST",
|
|
137
136
|
self._url("track"),
|
|
138
137
|
json={
|
|
139
138
|
"user": user.to_json(),
|
|
140
|
-
"events": [
|
|
139
|
+
"events": [
|
|
140
|
+
event.to_json(use_bucketing_api_format=True) for event in events
|
|
141
|
+
],
|
|
141
142
|
},
|
|
142
143
|
)
|
|
143
144
|
message = data.get("message", "")
|
|
144
145
|
return message
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
def exponential_backoff(attempt: int, base_delay: float) -> float:
|
|
148
|
-
"""
|
|
149
|
-
Exponential backoff starting with 200ms +- 0...40ms jitter
|
|
150
|
-
"""
|
|
151
|
-
delay = math.pow(2, attempt) * base_delay / 2.0
|
|
152
|
-
random_sum = delay * 0.1 * random.random()
|
|
153
|
-
return delay + random_sum
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from os.path import join
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
10
|
+
from devcycle_python_sdk.options import DevCycleLocalOptions
|
|
11
|
+
from devcycle_python_sdk.exceptions import (
|
|
12
|
+
APIClientError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
APIClientUnauthorizedError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigAPIClient:
|
|
21
|
+
def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
|
|
22
|
+
self.sdk_key = sdk_key
|
|
23
|
+
self.options = options
|
|
24
|
+
self.session = requests.Session()
|
|
25
|
+
self.session.headers = {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"Accept": "application/json",
|
|
28
|
+
}
|
|
29
|
+
self.session.max_redirects = 0
|
|
30
|
+
self.max_config_retries = 2
|
|
31
|
+
self.config_file_url = (
|
|
32
|
+
join(self.options.config_cdn_uri, "config", "v1", "server", self.sdk_key)
|
|
33
|
+
+ ".json"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def get_config(
|
|
37
|
+
self, config_etag: Optional[str] = None
|
|
38
|
+
) -> Tuple[Optional[dict], Optional[str]]:
|
|
39
|
+
"""
|
|
40
|
+
Get the config from the server. If the config_etag is provided, the server will only return the config if it
|
|
41
|
+
has changed since the last request. If the config hasn't changed, the server will return a 304 Not Modified
|
|
42
|
+
response.
|
|
43
|
+
|
|
44
|
+
:param config_etag: The etag of the last config request
|
|
45
|
+
|
|
46
|
+
:return: A tuple containing the config and the etag of the config. If the config hasn't changed since the last
|
|
47
|
+
request, the config will be None and the etag will be the same as the last request.
|
|
48
|
+
"""
|
|
49
|
+
retries_remaining = self.max_config_retries
|
|
50
|
+
timeout = self.options.config_request_timeout_ms / 1000.0
|
|
51
|
+
|
|
52
|
+
url = self.config_file_url
|
|
53
|
+
|
|
54
|
+
headers = {}
|
|
55
|
+
if config_etag:
|
|
56
|
+
headers["If-None-Match"] = config_etag
|
|
57
|
+
|
|
58
|
+
attempts = 1
|
|
59
|
+
while retries_remaining > 0:
|
|
60
|
+
request_error: Optional[Exception] = None
|
|
61
|
+
try:
|
|
62
|
+
res: requests.Response = self.session.request(
|
|
63
|
+
"GET", url, params={}, timeout=timeout, headers=headers
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
res.status_code == HTTPStatus.UNAUTHORIZED
|
|
68
|
+
or res.status_code == HTTPStatus.FORBIDDEN
|
|
69
|
+
):
|
|
70
|
+
# Not a retryable error
|
|
71
|
+
raise APIClientUnauthorizedError("Invalid SDK Key")
|
|
72
|
+
elif res.status_code == HTTPStatus.NOT_MODIFIED:
|
|
73
|
+
# the config hasn't changed since the last request
|
|
74
|
+
# don't return anything
|
|
75
|
+
return None, config_etag
|
|
76
|
+
elif res.status_code == HTTPStatus.NOT_FOUND:
|
|
77
|
+
# Not a retryable error
|
|
78
|
+
raise NotFoundError(url)
|
|
79
|
+
elif (
|
|
80
|
+
HTTPStatus.BAD_REQUEST
|
|
81
|
+
<= res.status_code
|
|
82
|
+
< HTTPStatus.INTERNAL_SERVER_ERROR
|
|
83
|
+
):
|
|
84
|
+
# Not a retryable error
|
|
85
|
+
raise APIClientError(f"Bad request: HTTP {res.status_code}")
|
|
86
|
+
elif res.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
|
|
87
|
+
# Retryable error
|
|
88
|
+
request_error = APIClientError(
|
|
89
|
+
f"Server error: HTTP {res.status_code}"
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
pass
|
|
93
|
+
except requests.exceptions.RequestException as e:
|
|
94
|
+
request_error = e
|
|
95
|
+
|
|
96
|
+
if not request_error:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
logger.debug(
|
|
100
|
+
f"DevCycle config CDN request failed (attempt {attempts}): {request_error}"
|
|
101
|
+
)
|
|
102
|
+
retries_remaining -= 1
|
|
103
|
+
if retries_remaining:
|
|
104
|
+
retry_delay = exponential_backoff(
|
|
105
|
+
attempts, self.options.config_retry_delay_ms / 1000.0
|
|
106
|
+
)
|
|
107
|
+
time.sleep(retry_delay)
|
|
108
|
+
attempts += 1
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
raise APIClientError(message="Retries exceeded", cause=request_error)
|
|
112
|
+
|
|
113
|
+
new_etag = res.headers.get("ETag", None)
|
|
114
|
+
|
|
115
|
+
data: dict = res.json()
|
|
116
|
+
return data, new_etag
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from os.path import join
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from devcycle_python_sdk.api.backoff import exponential_backoff
|
|
10
|
+
from devcycle_python_sdk.options import DevCycleLocalOptions
|
|
11
|
+
from devcycle_python_sdk.exceptions import (
|
|
12
|
+
APIClientError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
APIClientUnauthorizedError,
|
|
15
|
+
)
|
|
16
|
+
from devcycle_python_sdk.models.event import UserEventsBatchRecord
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EventAPIClient:
|
|
22
|
+
def __init__(self, sdk_key: str, options: DevCycleLocalOptions):
|
|
23
|
+
self.options = options
|
|
24
|
+
self.session = requests.Session()
|
|
25
|
+
self.session.headers = {
|
|
26
|
+
"Content-Type": "application/json",
|
|
27
|
+
"Accept": "application/json",
|
|
28
|
+
"Authorization": sdk_key,
|
|
29
|
+
}
|
|
30
|
+
self.session.max_redirects = 0
|
|
31
|
+
self.max_batch_retries = 0 # we don't retry events batches
|
|
32
|
+
self.batch_url = join(self.options.events_api_uri, "v1/events/batch")
|
|
33
|
+
|
|
34
|
+
def publish_events(self, batch: List[UserEventsBatchRecord]) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Attempts to send a batch of events to the server
|
|
37
|
+
"""
|
|
38
|
+
retries_remaining = self.max_batch_retries + 1
|
|
39
|
+
timeout = self.options.event_request_timeout_ms
|
|
40
|
+
|
|
41
|
+
payload_json = json.dumps(
|
|
42
|
+
{
|
|
43
|
+
"batch": [record.to_json() for record in batch],
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
attempts = 1
|
|
48
|
+
while retries_remaining > 0:
|
|
49
|
+
request_error: Optional[Exception] = None
|
|
50
|
+
try:
|
|
51
|
+
res: requests.Response = self.session.request(
|
|
52
|
+
"POST",
|
|
53
|
+
self.batch_url,
|
|
54
|
+
params={},
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
data=payload_json,
|
|
57
|
+
)
|
|
58
|
+
if res.status_code == 401 or res.status_code == 403:
|
|
59
|
+
# Not a retryable error
|
|
60
|
+
raise APIClientUnauthorizedError("Invalid SDK Key")
|
|
61
|
+
elif res.status_code == 404:
|
|
62
|
+
# Not a retryable error - bad URL
|
|
63
|
+
raise NotFoundError(self.batch_url)
|
|
64
|
+
elif 400 <= res.status_code < 500:
|
|
65
|
+
# Not a retryable error
|
|
66
|
+
raise APIClientError(
|
|
67
|
+
f"Bad request: HTTP {res.status_code} - {res.text}"
|
|
68
|
+
)
|
|
69
|
+
elif res.status_code >= 500:
|
|
70
|
+
# Retryable error
|
|
71
|
+
request_error = APIClientError(
|
|
72
|
+
f"Server error: HTTP {res.status_code}"
|
|
73
|
+
)
|
|
74
|
+
except requests.exceptions.RequestException as e:
|
|
75
|
+
request_error = e
|
|
76
|
+
|
|
77
|
+
if not request_error:
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
logger.debug(
|
|
81
|
+
f"DevCycle event batch request failed (attempt {attempts}): {request_error}"
|
|
82
|
+
)
|
|
83
|
+
retries_remaining -= 1
|
|
84
|
+
if retries_remaining:
|
|
85
|
+
retry_delay = exponential_backoff(
|
|
86
|
+
attempts, self.options.event_retry_delay_ms / 1000.0
|
|
87
|
+
)
|
|
88
|
+
time.sleep(retry_delay)
|
|
89
|
+
attempts += 1
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
raise APIClientError(message="Retries exceeded", cause=request_error)
|
|
93
|
+
|
|
94
|
+
data: dict = res.json()
|
|
95
|
+
return data.get("message", None)
|