omnata-plugin-runtime 0.3.27a80__tar.gz → 0.3.28__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: omnata-plugin-runtime
3
- Version: 0.3.27a80
3
+ Version: 0.3.28
4
4
  Summary: Classes and common runtime components for building and running Omnata Plugins
5
5
  Author: James Weakley
6
6
  Author-email: james.weakley@omnata.com
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "omnata-plugin-runtime"
3
- version = "0.3.27-a80"
3
+ version = "0.3.28"
4
4
  description = "Classes and common runtime components for building and running Omnata Plugins"
5
5
  authors = ["James Weakley <james.weakley@omnata.com>"]
6
6
  readme = "README.md"
@@ -32,6 +32,7 @@ wheel = "<=0.41.2" # latest version available on Snowflake Anaconda
32
32
  [tool.poetry.dev-dependencies]
33
33
  pytest = "^6.2.4"
34
34
  deepdiff = "^6"
35
+ requests-mock = ">=1.9.3"
35
36
 
36
37
  [tool.pytest.ini_options]
37
38
  addopts = ["--import-mode=importlib"]
@@ -77,6 +77,7 @@ from .rate_limiting import (
77
77
  HttpMethodType,
78
78
  InterruptedWhileWaitingException,
79
79
  RateLimitState,
80
+ RateLimitedSession
80
81
  )
81
82
 
82
83
  logger = getLogger(__name__)
@@ -251,6 +252,25 @@ class SyncRequest(ABC):
251
252
  f"thread_cancellation_token: {self._thread_cancellation_token.is_set()}"
252
253
  )
253
254
 
255
+ def get_ratelimit_retrying_http_session(self,
256
+ max_retries: int = 5,
257
+ backoff_factor: int = 1,
258
+ statuses_to_include: List[int] = [429]
259
+ ):
260
+ """
261
+ Returns a requests.Session object which can respond to 429 responses by waiting and retrying.
262
+ Takes into account the run deadline and cancellation status.
263
+ This is an alternative which can be used when the target API does not publish specific rate limits, and instead just asks you to respond to 429s as they are sent.
264
+ """
265
+ return RateLimitedSession(
266
+ run_deadline=self._run_deadline,
267
+ thread_cancellation_token=self._thread_cancellation_token,
268
+ max_retries=max_retries,
269
+ backoff_factor=backoff_factor,
270
+ statuses_to_include=statuses_to_include
271
+ )
272
+
273
+
254
274
  def __apply_results_worker(self, cancellation_token:threading.Event):
255
275
  """
256
276
  Designed to be run in a thread, this method polls the results every 20 seconds and sends them back to Snowflake.
@@ -13,6 +13,9 @@ import requests
13
13
  from pydantic import Field, root_validator
14
14
  from pydantic.json import pydantic_encoder
15
15
  from .configuration import SubscriptableBaseModel
16
+ import pytz
17
+ from requests.adapters import HTTPAdapter
18
+ from urllib3.util.retry import Retry
16
19
 
17
20
  logger = getLogger(__name__)
18
21
 
@@ -350,13 +353,96 @@ class RetryLaterException(Exception):
350
353
  self.message = message
351
354
  super().__init__(self.message)
352
355
 
356
+ class RateLimitedSession(requests.Session):
357
+ """
358
+ Creates a requests session that will automatically handle rate limiting.
359
+ It will retry requests that return a 429 status code, and will wait until the rate limit is reset.
360
+ The thread_cancellation_token is observed when waiting, as well as the overall run deadline.
361
+ In case this is used across threads, the retry count will be tracked per request URL (minus query parameters). It will be cleared when the request is successful.
362
+ """
363
+ def __init__(self, run_deadline:datetime.datetime, thread_cancellation_token:threading.Event, max_retries=5, backoff_factor=1, statuses_to_include:List[int] = [429]):
364
+ super().__init__()
365
+ self.max_retries = max_retries
366
+ self.backoff_factor = backoff_factor
367
+ self.retries:Dict[str,int] = {}
368
+ self._retries_lock = threading.Lock()
369
+ self.run_deadline = run_deadline
370
+ self.thread_cancellation_token = thread_cancellation_token
371
+ self.statuses_to_include = statuses_to_include
372
+
373
+ retry_strategy = Retry(
374
+ total=max_retries,
375
+ backoff_factor=backoff_factor,
376
+ status_forcelist=statuses_to_include,
377
+ allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
378
+ )
379
+ adapter = HTTPAdapter(max_retries=retry_strategy)
380
+ self.mount("https://", adapter)
381
+ self.mount("http://", adapter)
382
+
383
+ def set_retries(self, url:str, retries:int):
384
+ # ensure that the query parameters are not included in the key
385
+ url = re.sub(r'\?.*$', '', url)
386
+ with self._retries_lock:
387
+ self.retries[url] = retries
388
+
389
+ def get_retries(self, url:str):
390
+ # ensure that the query parameters are not included in the key
391
+ url = re.sub(r'\?.*$', '', url)
392
+ with self._retries_lock:
393
+ return self.retries.get(url,0)
394
+
395
+ def increment_retries(self, url:str) -> int:
396
+ # ensure that the query parameters are not included in the key
397
+ url = re.sub(r'\?.*$', '', url)
398
+ with self._retries_lock:
399
+ self.retries[url] = self.retries.get(url,0) + 1
400
+ return self.retries[url]
401
+
402
+ def request(self, method, url, **kwargs):
403
+ while True:
404
+ response = super().request(method, url, **kwargs)
405
+
406
+ if response.status_code in self.statuses_to_include:
407
+ if 'Retry-After' in response.headers:
408
+ retry_after = response.headers['Retry-After']
409
+ if retry_after.isdigit():
410
+ wait_time = int(retry_after)
411
+ else:
412
+ # Retry-After can be a date in the format specified in https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
413
+ # e.g. Fri, 31 Dec 1999 23:59:59 GMT
414
+ # we'll parse it using the standard datetime parser
415
+ retry_datetime = datetime.datetime.strptime(retry_after, "%a, %d %b %Y %H:%M:%S %Z")
416
+ retry_datetime = retry_datetime.replace(tzinfo=pytz.UTC)
417
+ current_datetime = datetime.datetime.now(pytz.UTC)
418
+ wait_time = (retry_datetime - current_datetime).total_seconds()
419
+ # first check that the wait time is not longer than the run deadline
420
+ if datetime.datetime.now(pytz.UTC) + datetime.timedelta(seconds=wait_time) > self.run_deadline:
421
+ raise InterruptedWhileWaitingException(message=f"The rate limiting wait time ({wait_time} seconds) would exceed the run deadline")
422
+ logger.info(f"Waiting for {wait_time} seconds before retrying {method} request to {url}")
423
+ # if wait() returns true, it means that the thread was cancelled
424
+ if self.thread_cancellation_token.wait(wait_time):
425
+ raise InterruptedWhileWaitingException(message="The sync was interrupted while waiting for rate limiting to expire")
426
+ else:
427
+ current_url_retries = self.increment_retries(url)
428
+ if current_url_retries >= self.max_retries:
429
+ raise requests.exceptions.RetryError(f"Maximum retries reached: {self.max_retries}")
430
+ backoff_time = self.backoff_factor * (2 ** (current_url_retries - 1))
431
+ if datetime.datetime.now(pytz.UTC) + datetime.timedelta(seconds=backoff_time) > self.run_deadline:
432
+ raise InterruptedWhileWaitingException(message=f"The rate limiting backoff time ({backoff_time} seconds) would exceed the run deadline")
433
+ logger.info(f"Waiting for {backoff_time} seconds before retrying {method} request to {url}")
434
+ if self.thread_cancellation_token.wait(backoff_time):
435
+ raise InterruptedWhileWaitingException(message="The sync was interrupted while waiting for rate limiting backoff")
436
+ else:
437
+ self.set_retries(url,0) # Reset retries if the request is successful
438
+ return response
439
+
353
440
 
354
441
  class InterruptedWhileWaitingException(Exception):
355
442
  """
356
443
  Indicates that while waiting for rate limiting to expire, the sync was interrupted
357
444
  """
358
- def __init__(self):
359
- message = "The sync was interrupted while waiting"
445
+ def __init__(self, message:str = "The sync was interrupted while waiting"):
360
446
  self.message = message
361
447
  super().__init__(self.message)
362
448