omnata-plugin-runtime 0.3.27a80__py3-none-any.whl → 0.3.28a82__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+ 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,11 @@ 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 time
17
+ import pytz
18
+ import dateparser
19
+ from requests.adapters import HTTPAdapter
20
+ from urllib3.util.retry import Retry
16
21
 
17
22
  logger = getLogger(__name__)
18
23
 
@@ -350,13 +355,93 @@ class RetryLaterException(Exception):
350
355
  self.message = message
351
356
  super().__init__(self.message)
352
357
 
358
+ class RateLimitedSession(requests.Session):
359
+ """
360
+ Creates a requests session that will automatically handle rate limiting.
361
+ It will retry requests that return a 429 status code, and will wait until the rate limit is reset.
362
+ The thread_cancellation_token is observed when waiting, as well as the overall run deadline.
363
+ 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.
364
+ """
365
+ def __init__(self, run_deadline:datetime.datetime, thread_cancellation_token:threading.Event, max_retries=5, backoff_factor=1, statuses_to_include:List[int] = [429]):
366
+ super().__init__()
367
+ self.max_retries = max_retries
368
+ self.backoff_factor = backoff_factor
369
+ self.retries:Dict[str,int] = {}
370
+ self._retries_lock = threading.Lock()
371
+ self.run_deadline = run_deadline
372
+ self.thread_cancellation_token = thread_cancellation_token
373
+ self.statuses_to_include = statuses_to_include
374
+
375
+ retry_strategy = Retry(
376
+ total=max_retries,
377
+ backoff_factor=backoff_factor,
378
+ status_forcelist=statuses_to_include,
379
+ method_whitelist=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"]
380
+ )
381
+ adapter = HTTPAdapter(max_retries=retry_strategy)
382
+ self.mount("https://", adapter)
383
+ self.mount("http://", adapter)
384
+
385
+ def set_retries(self, url:str, retries:int):
386
+ # ensure that the query parameters are not included in the key
387
+ url = re.sub(r'\?.*$', '', url)
388
+ with self._retries_lock:
389
+ self.retries[url] = retries
390
+
391
+ def get_retries(self, url:str):
392
+ # ensure that the query parameters are not included in the key
393
+ url = re.sub(r'\?.*$', '', url)
394
+ with self._retries_lock:
395
+ return self.retries.get(url,0)
396
+
397
+ def increment_retries(self, url:str) -> int:
398
+ # ensure that the query parameters are not included in the key
399
+ url = re.sub(r'\?.*$', '', url)
400
+ with self._retries_lock:
401
+ self.retries[url] = self.retries.get(url,0) + 1
402
+ return self.retries[url]
403
+
404
+ def request(self, method, url, **kwargs):
405
+ while True:
406
+ response = super().request(method, url, **kwargs)
407
+
408
+ if response.status_code in self.statuses_to_include:
409
+ if 'Retry-After' in response.headers:
410
+ retry_after = response.headers['Retry-After']
411
+ if retry_after.isdigit():
412
+ wait_time = int(retry_after)
413
+ else:
414
+ retry_datetime = dateparser.parse(retry_after)
415
+ retry_datetime = retry_datetime.replace(tzinfo=pytz.UTC)
416
+ current_datetime = datetime.datetime.now(pytz.UTC)
417
+ wait_time = (retry_datetime - current_datetime).total_seconds()
418
+ # first check that the wait time is not longer than the run deadline
419
+ if datetime.datetime.now(pytz.UTC) + datetime.timedelta(seconds=wait_time) > self.run_deadline:
420
+ raise InterruptedWhileWaitingException(message=f"The rate limiting wait time ({wait_time} seconds) would exceed the run deadline")
421
+ logger.info(f"Waiting for {wait_time} seconds before retrying {method} request to {url}")
422
+ # if wait() returns true, it means that the thread was cancelled
423
+ if self.thread_cancellation_token.wait(wait_time):
424
+ raise InterruptedWhileWaitingException(message="The sync was interrupted while waiting for rate limiting to expire")
425
+ else:
426
+ current_url_retries = self.increment_retries(url)
427
+ if current_url_retries >= self.max_retries:
428
+ raise requests.exceptions.RetryError(f"Maximum retries reached: {self.max_retries}")
429
+ backoff_time = self.backoff_factor * (2 ** (current_url_retries - 1))
430
+ if datetime.datetime.now(pytz.UTC) + datetime.timedelta(seconds=backoff_time) > self.run_deadline:
431
+ raise InterruptedWhileWaitingException(message=f"The rate limiting backoff time ({backoff_time} seconds) would exceed the run deadline")
432
+ logger.info(f"Waiting for {backoff_time} seconds before retrying {method} request to {url}")
433
+ if self.thread_cancellation_token.wait(backoff_time):
434
+ raise InterruptedWhileWaitingException(message="The sync was interrupted while waiting for rate limiting backoff")
435
+ else:
436
+ self.set_retries(url,0) # Reset retries if the request is successful
437
+ return response
438
+
353
439
 
354
440
  class InterruptedWhileWaitingException(Exception):
355
441
  """
356
442
  Indicates that while waiting for rate limiting to expire, the sync was interrupted
357
443
  """
358
- def __init__(self):
359
- message = "The sync was interrupted while waiting"
444
+ def __init__(self, message:str = "The sync was interrupted while waiting"):
360
445
  self.message = message
361
446
  super().__init__(self.message)
362
447
 
@@ -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.28a82
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
@@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3.9
11
11
  Classifier: Programming Language :: Python :: 3.10
12
12
  Requires-Dist: certifi (<=2023.11.17)
13
13
  Requires-Dist: charset-normalizer (<=2.0.4)
14
+ Requires-Dist: dateparser (<=1.1.8)
14
15
  Requires-Dist: idna (<=3.4)
15
16
  Requires-Dist: jinja2 (>=3.1.2)
16
17
  Requires-Dist: markupsafe (<=2.1.3)
@@ -22,6 +23,7 @@ Requires-Dist: pydantic (>=1,<=1.10.12)
22
23
  Requires-Dist: pyjwt (<=2.4.0)
23
24
  Requires-Dist: pyopenssl (<=23.2.0)
24
25
  Requires-Dist: pytz (<=2023.3.post1)
26
+ Requires-Dist: regex (<=2023.10.3)
25
27
  Requires-Dist: requests (>=2,<=2.31.0)
26
28
  Requires-Dist: setuptools (>=68.2.2)
27
29
  Requires-Dist: snowflake-snowpark-python (>=1,<2)
@@ -3,10 +3,10 @@ omnata_plugin_runtime/api.py,sha256=_N5ok5LN7GDO4J9n3yduXp3tpjmhpySY__U2baiygrs,
3
3
  omnata_plugin_runtime/configuration.py,sha256=7cMekoY8CeZAJHpASU6tCMidF55Hzfr7CD74jtebqIY,35742
4
4
  omnata_plugin_runtime/forms.py,sha256=pw_aKVsXSz47EP8PFBI3VDwdSN5IjvZxp8JTjO1V130,18421
5
5
  omnata_plugin_runtime/logging.py,sha256=bn7eKoNWvtuyTk7RTwBS9UARMtqkiICtgMtzq3KA2V0,3272
6
- omnata_plugin_runtime/omnata_plugin.py,sha256=OqGbc5bmEppXXcwBKQzG3T91RnnzQAjFWn7fAnTWVHE,104289
6
+ omnata_plugin_runtime/omnata_plugin.py,sha256=Ptf0wPdgC1HPbfxIIzhwRWAXmH9kYQT4UXWhhuCCt2Q,105241
7
7
  omnata_plugin_runtime/plugin_entrypoints.py,sha256=D2f0Qih7KTJNXIDYkA-E55RTEK_7Jtv9ySqspWxERVA,28272
8
- omnata_plugin_runtime/rate_limiting.py,sha256=se6MftQI5NrVHaLb1hByPCgAESPQhkAgIG7KIU1clDU,16562
9
- omnata_plugin_runtime-0.3.27a80.dist-info/LICENSE,sha256=IMF9i4xIpgCADf0U-V1cuf9HBmqWQd3qtI3FSuyW4zE,26526
10
- omnata_plugin_runtime-0.3.27a80.dist-info/METADATA,sha256=EgQGifiA2LJplQ6z4VMBRBmdqUbvbWPK0epN7yXRw2E,1604
11
- omnata_plugin_runtime-0.3.27a80.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
12
- omnata_plugin_runtime-0.3.27a80.dist-info/RECORD,,
8
+ omnata_plugin_runtime/rate_limiting.py,sha256=izUOQluYERhlerUbPNnpNLTWUfKRPHTyAb_Sx3xYVJk,21492
9
+ omnata_plugin_runtime-0.3.28a82.dist-info/LICENSE,sha256=IMF9i4xIpgCADf0U-V1cuf9HBmqWQd3qtI3FSuyW4zE,26526
10
+ omnata_plugin_runtime-0.3.28a82.dist-info/METADATA,sha256=evqJ7mt2hSh9mJO7DWPtFomI5RK3rqrfEm2GxJsWago,1675
11
+ omnata_plugin_runtime-0.3.28a82.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
12
+ omnata_plugin_runtime-0.3.28a82.dist-info/RECORD,,