Skip to content

Commit cebab54

Browse files
committed
Add retry 429
1 parent c4649f2 commit cebab54

4 files changed

Lines changed: 250 additions & 44 deletions

File tree

splunk_sdk/base_client.py

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import requests
1818
import json
1919
import logging
20+
import time
2021

2122
from requests import Response
2223

@@ -32,10 +33,15 @@
3233

3334
logger = logging.getLogger(__name__)
3435

36+
"""Default retry count to be used when RetryRequests is True but no other config is supplied"""
37+
DEFAULT_RETRY_COUNT = 10
38+
"""Default retry interval to be used when RetryRequests is True but no other config is supplied"""
39+
DEFAULT_RETRY_INTERVAL = 1000
40+
3541

3642
def log_http(fn):
3743
"""
38-
Decorates requests to log the request and response when debugging is enabled
44+
Decorates requests to log the request and response when debugging is enabled
3945
on the underlying client context.\n
4046
To log requests, set `debug=True` when creating your initial SDK context.
4147
:param fn: TODO DOCS
@@ -80,7 +86,7 @@ def _wrapper(self, *args, **kwargs):
8086

8187
class BaseClient(object):
8288
"""
83-
The BaseClient class encapsulates conventions, authorization, and URL handling
89+
The BaseClient class encapsulates conventions, authorization, and URL handling
8490
to make basic requests against the Splunk Cloud Platform. You can use this class
8591
to access a feature that has not implemented in the SDK.\n
8692
@@ -89,14 +95,15 @@ class BaseClient(object):
8995
bc.get(bc.build_url("/identity/v2/validate")) #=> HTTP response (presuming that v2 of the validate service is deployed)
9096
"""
9197

92-
def __init__(self, context: Context, auth_manager: AuthManager, requests_hooks=None):
98+
def __init__(self, context: Context, auth_manager: AuthManager, retry_config=None, requests_hooks=None):
9399
self.context = context
94100
self._session = requests.Session()
95101
self._session.headers.update({
96102
'Content-Type': 'application/json'})
97103
self._session.headers.update({
98104
'Splunk-Client': 'client-python/{}'.format(__version__)})
99105
self._auth_manager = auth_manager
106+
self._retry_config = retry_config
100107

101108
self._session.hooks[REQUESTS_HOOK_NAME_RESPONSE].extend(requests_hooks or [])
102109

@@ -117,9 +124,12 @@ def get(self, url: str, **kwargs) -> requests.Response:
117124
:param kwargs: TODO DOCS
118125
:return: TODO DOCS
119126
"""
127+
120128
self.update_auth()
121129
# Params are used for querystring vars
122-
return self._session.get(url, **kwargs)
130+
response = self._session.get(url, **kwargs)
131+
132+
return self.handle_error_response("GET", response, url, self._retry_config, **kwargs)
123133

124134
@log_http
125135
def options(self, url: str, **kwargs) -> requests.Response:
@@ -129,8 +139,12 @@ def options(self, url: str, **kwargs) -> requests.Response:
129139
:param kwargs: TODO DOCS
130140
:return: TODO DOCS
131141
"""
142+
132143
self.update_auth()
133-
return self._session.options(url, **kwargs)
144+
145+
response = self._session.options(url, **kwargs)
146+
147+
return self.handle_error_response("OPTIONS", response, url, self._retry_config, **kwargs)
134148

135149
@log_http
136150
def head(self, url, **kwargs) -> requests.Response:
@@ -140,8 +154,12 @@ def head(self, url, **kwargs) -> requests.Response:
140154
:param kwargs: TODO DOCS
141155
:return: TODO DOCS
142156
"""
157+
143158
self.update_auth()
144-
return self._session.head(url, **kwargs)
159+
160+
response = self._session.head(url, **kwargs)
161+
162+
return self.handle_error_response("HEAD", response, url, self._retry_config, **kwargs)
145163

146164
@log_http
147165
@preprocess_body
@@ -154,8 +172,12 @@ def post(self, url, data=None, json=None, **kwargs) -> requests.Response:
154172
:param kwargs: TODO DOCS
155173
:return: TODO DOCS
156174
"""
175+
157176
self.update_auth()
158-
return self._session.post(url, data, json, **kwargs)
177+
178+
response = self._session.post(url, data, json, **kwargs)
179+
180+
return self.handle_error_response("POST", response, url, data, json, self._retry_config, **kwargs)
159181

160182
@log_http
161183
@preprocess_body
@@ -167,8 +189,12 @@ def put(self, url, data=None, **kwargs) -> requests.Response:
167189
:param kwargs: TODO DOCS
168190
:return: TODO DOCS
169191
"""
192+
170193
self.update_auth()
171-
return self._session.put(url, data, **kwargs)
194+
195+
response = self._session.put(url, data, **kwargs)
196+
197+
return self.handle_error_response("PUT", response, url, data, self._retry_config, **kwargs)
172198

173199
@log_http
174200
@preprocess_body
@@ -180,8 +206,12 @@ def patch(self, url, data=None, **kwargs) -> requests.Response:
180206
:param kwargs:
181207
:return:
182208
"""
209+
183210
self.update_auth()
184-
return self._session.patch(url, data, **kwargs)
211+
212+
response = self._session.patch(url, data, **kwargs)
213+
214+
return self.handle_error_response("PATCH", response, url, data, self._retry_config, **kwargs)
185215

186216
@log_http
187217
def delete(self, url: str, **kwargs) -> requests.Response:
@@ -192,14 +222,17 @@ def delete(self, url: str, **kwargs) -> requests.Response:
192222
:return: TODO DOCS
193223
"""
194224
self.update_auth()
195-
return self._session.delete(url, **kwargs)
225+
226+
response = self._session.delete(url, **kwargs)
227+
228+
return self.handle_error_response("DELETE", response, url, self._retry_config, **kwargs)
196229

197230
def build_url(self, route: str, **kwargs) -> str:
198231
"""
199232
Builds a full URL from the specified path template by adding the current
200-
tenant (if the path does not start with '/system') and the configured host,
233+
tenant (if the path does not start with '/system') and the configured host,
201234
and applying any `kwargs` to the path template. \n
202-
You can pass the returned URL to GET, PUT, POST, PATCH, DELETE, OPTIONS,
235+
You can pass the returned URL to GET, PUT, POST, PATCH, DELETE, OPTIONS,
203236
and HEAD requests.
204237
:param route: TODO DOCS
205238
:param kwargs: TODO DOCS
@@ -227,6 +260,20 @@ def get_tenant(self) -> str:
227260
"""
228261
return self.context.tenant
229262

263+
def handle_error_response(self, method: str, response: Response, url, data=None, json_data=None, retry_config=None, **kwargs) -> requests.Response:
264+
265+
if response.status_code != 429 or retry_config is None or (retry_config is not None and retry_config.retry_requests_enabled is not True):
266+
return response
267+
268+
retry_count = 0
269+
success_response = self._retry_config.handle_response(self, method, url, retry_count, data, json_data, **kwargs)
270+
while (success_response is not None and success_response.status_code == 429) and retry_count < self._retry_config.retry_count:
271+
retry_count += 1
272+
success_response = self._retry_config.handle_response(self, method, url, retry_count, data, json_data, **kwargs)
273+
if success_response is not None and success_response.status_code != 429:
274+
response = success_response
275+
276+
return response
230277

231278
def inflate(data, model, is_collection: bool):
232279
""" Handles deserializing responses from services into model objects."""
@@ -246,8 +293,8 @@ def inflate(data, model, is_collection: bool):
246293

247294
def dictify(obj):
248295
"""
249-
Private. Serializes the model into JSON. The naming conventions for the services
250-
differ from Python naming conventions, so serialization involves changing from
296+
Private. Serializes the model into JSON. The naming conventions for the services
297+
differ from Python naming conventions, so serialization involves changing from
251298
Python conventions to those defined by the Splunk Cloud services.
252299
:param obj: TODO DOCS
253300
:return: TODO DOCS
@@ -262,9 +309,11 @@ def dictify(obj):
262309
return obj
263310

264311

265-
def get_client(context, auth_manager, requests_hooks=None):
312+
def get_client(context, auth_manager, retry_config=None, requests_hooks=None):
266313
"""Returns a Service Client object for the specified authorization manager."""
267-
return BaseClient(context, auth_manager, requests_hooks=requests_hooks)
314+
client = BaseClient(context, auth_manager, retry_config=retry_config, requests_hooks=requests_hooks)
315+
client.update_auth()
316+
return client
268317

269318

270319
# TODO: refactor this helper away and make handle_response cleaner
@@ -323,6 +372,69 @@ def handle_response(response: Response, klass=None, key=None):
323372
raise HTTPError(response.status_code, response.text)
324373

325374

375+
class RetryConfig(object):
376+
"""The RetryConfig class wraps around the configuration values for retrying requests that fail
377+
when a 429 is encountered at the server."""
378+
379+
def __init__(self, retry_requests_enabled: bool, retry_count=None, retry_interval=None):
380+
self._retry_requests_enabled = retry_requests_enabled
381+
if retry_count is not None:
382+
self._retry_count = retry_count
383+
else:
384+
self._retry_count = DEFAULT_RETRY_COUNT
385+
386+
if retry_interval is not None:
387+
self._retry_interval = retry_interval
388+
else:
389+
self._retry_interval = DEFAULT_RETRY_INTERVAL
390+
391+
@property
392+
def retry_requests_enabled(self) -> bool:
393+
return self._retry_requests_enabled
394+
395+
@retry_requests_enabled.setter
396+
def retry_requests_enabled(self, retry_requests_enabled: bool):
397+
self._retry_requests_enabled = retry_requests_enabled
398+
399+
@property
400+
def retry_count(self) -> int:
401+
return self._retry_count
402+
403+
@retry_count.setter
404+
def retry_count(self, retry_count: int):
405+
self._retry_count = retry_count
406+
407+
@property
408+
def retry_interval(self) -> bool:
409+
return self._retry_interval
410+
411+
@retry_interval.setter
412+
def retry_interval(self, retry_interval: bool):
413+
self._retry_interval = retry_interval
414+
415+
# implement exponential back off by increasing the waiting time between retries after each retry failure.
416+
def handle_response(self, client, method, url, retry_count, data=None, json_data=None, **kwargs) -> requests.Response:
417+
response = None
418+
backOffSeconds = ((1 << retry_count) * self._retry_interval) / 1000
419+
time.sleep(backOffSeconds)
420+
421+
if method == "POST":
422+
response = client._session.post(url, data, json_data, **kwargs)
423+
elif method == "GET":
424+
response = self._session.get(url, **kwargs)
425+
elif method == "DELETE":
426+
response = self._session.delete(url, **kwargs)
427+
elif method == "OPTIONS":
428+
response = self._session.options(url, **kwargs)
429+
elif method == "HEAD":
430+
response = self._session.head(url, **kwargs)
431+
elif method == "PUT":
432+
response = self._session.put(url, data, **kwargs)
433+
elif method == "PATCH":
434+
response = self._session.patch(url, data, **kwargs)
435+
436+
return response
437+
326438
class HTTPError(Exception):
327439
"""The HTTPError class provides an exception wrapper for HTTP error responses."""
328440

test/fixtures.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from splunk_sdk.common.context import Context
1818
from splunk_sdk.base_client import get_client
1919

20+
from splunk_sdk.base_client import RetryConfig
21+
2022
# create logger
2123
logger = logging.getLogger()
2224
logger.setLevel(logging.DEBUG)
@@ -36,6 +38,51 @@
3638
logger.addHandler(ch)
3739

3840

41+
@pytest.fixture(scope='session')
42+
def get_test_client_default_retry():
43+
context = Context(host=os.environ.get('SPLUNK_HOST'),
44+
api_host=os.environ.get('SPLUNK_HOST'),
45+
tenant=os.environ.get('SPLUNK_TENANT'),
46+
debug=os.environ.get(
47+
'SPLUNK_DEBUG', 'false').lower().strip() == 'true')
48+
49+
retry_config = RetryConfig(retry_requests_enabled=True)
50+
51+
# integration tests use pkce by default
52+
service_client = get_client(context, _get_pkce_manager(), retry_config)
53+
assert (service_client is not None)
54+
return service_client
55+
56+
@pytest.fixture(scope='session')
57+
def get_test_client_retry_false():
58+
context = Context(host=os.environ.get('SPLUNK_HOST'),
59+
api_host=os.environ.get('SPLUNK_HOST'),
60+
tenant=os.environ.get('SPLUNK_TENANT'),
61+
debug=os.environ.get(
62+
'SPLUNK_DEBUG', 'false').lower().strip() == 'true')
63+
64+
retry_config = RetryConfig(retry_requests_enabled=False)
65+
66+
# integration tests use pkce by default
67+
service_client = get_client(context, _get_pkce_manager(), retry_config)
68+
assert (service_client is not None)
69+
return service_client
70+
71+
@pytest.fixture(scope='session')
72+
def get_test_client_custom_retry():
73+
context = Context(host=os.environ.get('SPLUNK_HOST'),
74+
api_host=os.environ.get('SPLUNK_HOST'),
75+
tenant=os.environ.get('SPLUNK_TENANT'),
76+
debug=os.environ.get(
77+
'SPLUNK_DEBUG', 'false').lower().strip() == 'true')
78+
79+
retry_config = RetryConfig(retry_requests_enabled=True, retry_count=12, retry_interval=1200)
80+
81+
# integration tests use pkce by default
82+
service_client = get_client(context, _get_pkce_manager(), retry_config)
83+
assert (service_client is not None)
84+
return service_client
85+
3986
@pytest.fixture(scope='session')
4087
def get_test_client():
4188
context = Context(host=os.environ.get('SPLUNK_HOST'),
@@ -49,7 +96,6 @@ def get_test_client():
4996
assert (service_client is not None)
5097
return service_client
5198

52-
5399
@pytest.fixture(scope='session')
54100
def get_test_client_ml():
55101
context = Context(host=os.environ.get('SPLUNK_HOST'),

0 commit comments

Comments
 (0)