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.
Files changed (75) hide show
  1. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/PKG-INFO +1 -1
  2. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/README.md +36 -14
  3. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/VERSION.txt +1 -0
  4. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/__init__.py +5 -0
  5. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/api/backoff.py +11 -0
  6. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/api/bucketing_client.py +17 -25
  7. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/api/config_client.py +116 -0
  8. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/api/event_client.py +95 -0
  9. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/api/local_bucketing.py +436 -0
  10. 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
  11. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/exceptions.py +43 -0
  12. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/local_client.py +220 -0
  13. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/managers/config_manager.py +95 -0
  14. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/managers/event_queue_manager.py +223 -0
  15. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/models/bucketed_config.py +150 -0
  16. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/models/event.py +123 -0
  17. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/models/feature.py +12 -0
  18. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/models/platform_data.py +33 -0
  19. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/models/user.py +76 -0
  20. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/models/variable.py +17 -5
  21. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/options.py +106 -0
  22. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/protobuf/utils.py +129 -0
  23. devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/protobuf/variableForUserParams_pb2.py +46 -0
  24. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/util/version.py +1 -1
  25. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_server_sdk.egg-info/PKG-INFO +1 -1
  26. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_server_sdk.egg-info/SOURCES.txt +23 -6
  27. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_server_sdk.egg-info/requires.txt +2 -0
  28. devcycle-python-server-sdk-2.0.1/example/example.py → devcycle-python-server-sdk-3.0.0/example/cloud_client_example.py +24 -20
  29. devcycle-python-server-sdk-3.0.0/example/local_bucketing_client_example.py +74 -0
  30. devcycle-python-server-sdk-3.0.0/pyproject.toml +3 -0
  31. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/test/api/test_bucketing_client.py +10 -5
  32. devcycle-python-server-sdk-3.0.0/test/api/test_config_client.py +133 -0
  33. devcycle-python-server-sdk-3.0.0/test/api/test_event_client.py +97 -0
  34. devcycle-python-server-sdk-3.0.0/test/api/test_local_bucketing.py +392 -0
  35. devcycle-python-server-sdk-3.0.0/test/fixture/data.py +57 -0
  36. devcycle-python-server-sdk-3.0.0/test/managers/__init__.py +0 -0
  37. devcycle-python-server-sdk-3.0.0/test/managers/test_config_manager.py +132 -0
  38. devcycle-python-server-sdk-3.0.0/test/managers/test_event_queue_manager.py +271 -0
  39. devcycle-python-server-sdk-3.0.0/test/models/__init__.py +0 -0
  40. devcycle-python-server-sdk-3.0.0/test/models/test_bucketed_config.py +26 -0
  41. 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
  42. devcycle-python-server-sdk-3.0.0/test/test_local_client.py +397 -0
  43. devcycle-python-server-sdk-3.0.0/test/util/__init__.py +0 -0
  44. devcycle-python-server-sdk-3.0.0/test/util/test_utils.py +183 -0
  45. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/test/util/test_version.py +2 -2
  46. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/VERSION.txt +0 -1
  47. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/__init__.py +0 -5
  48. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/api/local_bucketing.py +0 -9
  49. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/dvc_local_client.py +0 -152
  50. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/dvc_options.py +0 -45
  51. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/exceptions.py +0 -21
  52. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/managers/config_manager.py +0 -27
  53. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/managers/event_queue_manager.py +0 -18
  54. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/models/event.py +0 -20
  55. devcycle-python-server-sdk-2.0.1/devcycle_python_sdk/models/user.py +0 -31
  56. devcycle-python-server-sdk-2.0.1/test/api/test_local_bucketing.py +0 -17
  57. devcycle-python-server-sdk-2.0.1/test/managers/test_config_manager.py +0 -17
  58. devcycle-python-server-sdk-2.0.1/test/managers/test_event_queue_manager.py +0 -17
  59. devcycle-python-server-sdk-2.0.1/test/test_dvc_local_client.py +0 -143
  60. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/LICENSE +0 -0
  61. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/api/__init__.py +0 -0
  62. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/managers/__init__.py +0 -0
  63. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/models/__init__.py +0 -0
  64. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/models/error_response.py +0 -0
  65. {devcycle-python-server-sdk-2.0.1/example → devcycle-python-server-sdk-3.0.0/devcycle_python_sdk/protobuf}/__init__.py +0 -0
  66. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/py.typed +0 -0
  67. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/devcycle_python_sdk/util/__init__.py +0 -0
  68. {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
  69. {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
  70. {devcycle-python-server-sdk-2.0.1/test → devcycle-python-server-sdk-3.0.0/example}/__init__.py +0 -0
  71. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/setup.cfg +0 -0
  72. {devcycle-python-server-sdk-2.0.1 → devcycle-python-server-sdk-3.0.0}/setup.py +0 -0
  73. {devcycle-python-server-sdk-2.0.1/test/api → devcycle-python-server-sdk-3.0.0/test}/__init__.py +0 -0
  74. {devcycle-python-server-sdk-2.0.1/test/managers → devcycle-python-server-sdk-3.0.0/test/api}/__init__.py +0 -0
  75. {devcycle-python-server-sdk-2.0.1/test/util → devcycle-python-server-sdk-3.0.0/test/fixture}/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: devcycle-python-server-sdk
3
- Version: 2.0.1
3
+ Version: 3.0.0
4
4
  Summary: DevCycle Python SDK
5
5
  Home-page: https://github.com/devcycleHQ/python-server-sdk
6
6
  Author-email:
@@ -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 DevCycleCloudClient, DevCycleCloudOptions
27
- from devcycle_python_sdk.models.user import User
23
+ from devcycle_python_sdk import DevCycleLocalClient, DevCycleLocalOptions
24
+ from devcycle_python_sdk.models.user import DevCycleUser
28
25
 
29
- options = DevCycleCloudOptions()
26
+ options = DevCycleLocalOptions()
30
27
 
31
28
  # create an instance of the client class
32
- dvc = DevCycleCloudClient('YOUR_DVC_SERVER_SDK_KEY', options)
29
+ client = DevCycleLocalClient('DEVCYCLE_SERVER_SDK_KEY', options)
33
30
 
34
- user = 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 = dvc.variable_value(user, 'feature-key', 'default-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
- python -m unittest -v
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,5 @@
1
+ # Simplify imports for the SDK entry point objects
2
+
3
+ from devcycle_python_sdk.options import DevCycleCloudOptions, DevCycleLocalOptions
4
+ from devcycle_python_sdk.cloud_client import DevCycleCloudClient
5
+ from devcycle_python_sdk.local_client import DevCycleLocalClient
@@ -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.dvc_options import DevCycleCloudOptions
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.bucketing_API_URI, "v1", *path_args)
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.error(
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: User) -> Variable:
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: User) -> Dict[str, Variable]:
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: User) -> Dict[str, Feature]:
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: User, events: List[Event]) -> str:
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": [event.to_json() for event in 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)