diff --git a/README.md b/README.md index 84ddb36..6b564ad 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,20 @@ the default amount of retries is 0.
Unit initialization with retries: unit = Unit(api_url, token, retries=3) ``` + +## Request Timeouts +Every HTTP request issued by the SDK has a per-attempt timeout (default: 120 seconds). +This prevents calls from hanging indefinitely if a connection stalls during a Unit API +outage. The previous `timeout` argument is still honored — it now controls the **total +retry budget** for the backoff loop, while the new `request_timeout` argument controls +the **per-attempt** HTTP timeout passed to `requests`. + +```python + unit = Unit(api_url, token, retries=3, timeout=120, request_timeout=120) +``` + +`requests.exceptions.Timeout` and `requests.exceptions.ConnectionError` are now retried +the same way 4xx/5xx retryable responses are. The one exception is `post_create` calls: +they will only be retried on network exceptions when an `idempotencyKey` is present in +the request body — otherwise the exception bubbles up immediately to avoid the risk of +double-processing. diff --git a/setup.py b/setup.py index d715a1b..84d8bf7 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name='unit-python-sdk', packages=['unit', 'unit.api', 'unit.models', 'unit.utils'], - version="1.2.0", + version="1.3.0", license='Mozilla Public License 2.0', description='This library provides a python wrapper to http://unit.co API. See https://docs.unit.co/', author='unit.co', diff --git a/unit/__init__.py b/unit/__init__.py index 28d0d73..11880b8 100644 --- a/unit/__init__.py +++ b/unit/__init__.py @@ -33,11 +33,12 @@ class Unit(object): - def __init__(self, api_url=None, token=None, retries=0, timeout=120, configuration: Configuration = None): + def __init__(self, api_url=None, token=None, retries=0, timeout=120, + configuration: Configuration = None, request_timeout=120): if (api_url is not None or token is not None) and configuration is not None: raise Exception("use only configuration") - c = configuration if configuration else Configuration(api_url, token, retries, timeout) + c = configuration if configuration else Configuration(api_url, token, retries, timeout, request_timeout) self.applications = ApplicationResource(c) self.customers = CustomerResource(c) diff --git a/unit/api/base_resource.py b/unit/api/base_resource.py index ff42960..29727b9 100644 --- a/unit/api/base_resource.py +++ b/unit/api/base_resource.py @@ -2,11 +2,15 @@ import requests import backoff from typing import Optional, Dict +from requests.exceptions import Timeout, ConnectionError as RequestsConnectionError from unit.utils.configuration import Configuration from unit.models.codecs import UnitEncoder +RETRYABLE_NETWORK_EXCEPTIONS = (Timeout, RequestsConnectionError) + + def backoff_idempotency_key_handler(e): return backoff_handler(e) and idempotency_key_is_present(e) @@ -36,6 +40,31 @@ def idempotency_key_is_present(e): return body["data"]["attributes"].get("idempotencyKey") is not None +def giveup_unless_idempotent(e): + """`backoff.on_exception` giveup predicate. Returns True to STOP retrying. + + Mirrors `idempotency_key_is_present` but operates on the raised + `requests` exception, defensively handling missing/unparseable bodies + (e.g. a `ConnectTimeout` that occurred before any body was sent).""" + request = getattr(e, "request", None) + body_raw = getattr(request, "body", None) if request is not None else None + if not body_raw: + return True + + try: + body = json.loads(body_raw) + except (ValueError, TypeError): + return True + + if not body: + return True + + try: + return body["data"]["attributes"].get("idempotencyKey") is None + except (KeyError, TypeError, AttributeError): + return True + + class BaseResource(object): def __init__(self, resource: str, configuration: Configuration): self.resource = resource @@ -43,89 +72,132 @@ def __init__(self, resource: str, configuration: Configuration): def get(self, resource: str, params: Dict = None, headers: Optional[Dict[str, str]] = None): + @backoff.on_exception(backoff.expo, + RETRYABLE_NETWORK_EXCEPTIONS, + max_tries=self.configuration.get_tries, + max_time=self.configuration.get_timeout, + jitter=backoff.random_jitter) @backoff.on_predicate(backoff.expo, backoff_handler, max_tries=self.configuration.get_tries, max_time=self.configuration.get_timeout, jitter=backoff.random_jitter) def get_with_backoff(path: str, p: Dict, h: Dict[str, str]): - return requests.get(path, params=p, headers=h) + return requests.get(path, params=p, headers=h, + timeout=self.configuration.get_request_timeout()) return get_with_backoff(f"{self.configuration.api_url}/{resource}", params, self.__merge_headers(headers)) def post(self, resource: str, data: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None): data = json.dumps(data, cls=UnitEncoder) if data is not None else None + @backoff.on_exception(backoff.expo, + RETRYABLE_NETWORK_EXCEPTIONS, + max_tries=self.configuration.get_tries, + max_time=self.configuration.get_timeout, + jitter=backoff.random_jitter) @backoff.on_predicate(backoff.expo, backoff_handler, max_tries=self.configuration.get_tries, max_time=self.configuration.get_timeout, jitter=backoff.random_jitter) def post_with_backoff(path: str, d: Dict, h: Dict[str, str]): - return requests.post(path, data=d, headers=h) + return requests.post(path, data=d, headers=h, + timeout=self.configuration.get_request_timeout()) return post_with_backoff(f"{self.configuration.api_url}/{resource}", data, self.__merge_headers(headers)) def post_create(self, resource: str, data: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None): data = json.dumps(data, cls=UnitEncoder) if data is not None else None + @backoff.on_exception(backoff.expo, + RETRYABLE_NETWORK_EXCEPTIONS, + max_tries=self.configuration.get_tries, + max_time=self.configuration.get_timeout, + giveup=giveup_unless_idempotent, + jitter=backoff.random_jitter) @backoff.on_predicate(backoff.expo, backoff_idempotency_key_handler, max_tries=self.configuration.get_tries, max_time=self.configuration.get_timeout, jitter=backoff.random_jitter) def post_create_with_backoff(path: str, d, h): - return requests.post(path, data=d, headers=h) + return requests.post(path, data=d, headers=h, + timeout=self.configuration.get_request_timeout()) return post_create_with_backoff(f"{self.configuration.api_url}/{resource}", data, self.__merge_headers(headers)) def post_full_path(self, path: str, data: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None): data = json.dumps(data, cls=UnitEncoder) if data is not None else None + @backoff.on_exception(backoff.expo, + RETRYABLE_NETWORK_EXCEPTIONS, + max_tries=self.configuration.get_tries, + max_time=self.configuration.get_timeout, + jitter=backoff.random_jitter) @backoff.on_predicate(backoff.expo, backoff_handler, max_tries=self.configuration.get_tries, max_time=self.configuration.get_timeout, jitter=backoff.random_jitter) def post_full_path_with_backoff(p, d, h): - return requests.post(p, data=d, headers=h) + return requests.post(p, data=d, headers=h, + timeout=self.configuration.get_request_timeout()) return post_full_path_with_backoff(path, data, self.__merge_headers(headers)) def patch(self, resource: str, data: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None): data = json.dumps(data, cls=UnitEncoder) if data is not None else None + @backoff.on_exception(backoff.expo, + RETRYABLE_NETWORK_EXCEPTIONS, + max_tries=self.configuration.get_tries, + max_time=self.configuration.get_timeout, + jitter=backoff.random_jitter) @backoff.on_predicate(backoff.expo, backoff_handler, max_tries=self.configuration.get_tries, max_time=self.configuration.get_timeout, jitter=backoff.random_jitter) def patch_with_backoff(p, d, h): - return requests.patch(p, data=d, headers=h) + return requests.patch(p, data=d, headers=h, + timeout=self.configuration.get_request_timeout()) return patch_with_backoff(f"{self.configuration.api_url}/{resource}", data, self.__merge_headers(headers)) def delete(self, resource: str, data: Dict = None, headers: Optional[Dict[str, str]] = None): data = json.dumps(data, cls=UnitEncoder) if data is not None else None + @backoff.on_exception(backoff.expo, + RETRYABLE_NETWORK_EXCEPTIONS, + max_tries=self.configuration.get_tries, + max_time=self.configuration.get_timeout, + jitter=backoff.random_jitter) @backoff.on_predicate(backoff.expo, backoff_handler, max_tries=self.configuration.get_tries, max_time=self.configuration.get_timeout, jitter=backoff.random_jitter) def delete_with_backoff(p, d, h): - return requests.delete(p, data=d, headers=h) + return requests.delete(p, data=d, headers=h, + timeout=self.configuration.get_request_timeout()) return delete_with_backoff(f"{self.configuration.api_url}/{resource}", data, self.__merge_headers(headers)) def put(self, resource: str, data: Optional[Dict] = None, headers: Optional[Dict[str, str]] = None): + @backoff.on_exception(backoff.expo, + RETRYABLE_NETWORK_EXCEPTIONS, + max_tries=self.configuration.get_tries, + max_time=self.configuration.get_timeout, + jitter=backoff.random_jitter) @backoff.on_predicate(backoff.expo, backoff_handler, max_tries=self.configuration.get_tries, max_time=self.configuration.get_timeout, jitter=backoff.random_jitter) def put_with_backoff(p, d, h): - return requests.put(p, data=d, headers=h) + return requests.put(p, data=d, headers=h, + timeout=self.configuration.get_request_timeout()) return put_with_backoff(f"{self.configuration.api_url}/{resource}", data, self.__merge_headers(headers)) @@ -140,4 +212,3 @@ def __merge_headers(self, headers: Optional[Dict[str, str]] = None): @staticmethod def is_20x(status: int): return status == 200 or status == 201 or status == 204 - diff --git a/unit/utils/configuration.py b/unit/utils/configuration.py index 99d64f6..1aba248 100644 --- a/unit/utils/configuration.py +++ b/unit/utils/configuration.py @@ -1,15 +1,16 @@ class Configuration(object): - def __init__(self, api_url, token, retries=0, timeout=120): + def __init__(self, api_url, token, retries=0, timeout=120, request_timeout=120): self.api_url = self.__check_api_url(api_url) self.token = self.__check_token(token) self.retries = self.__check_retries(retries) self.timeout = self.__check_timeout(timeout) + self.request_timeout = self.__check_request_timeout(request_timeout) def get_headers(self): return { "content-type": "application/vnd.api+json", "authorization": f"Bearer {self.token}", - "X-UNIT-SDK": f"unit-python-sdk@v1.2.0" + "X-UNIT-SDK": f"unit-python-sdk@v1.3.0" } def set_api_url(self, api_url): @@ -21,6 +22,9 @@ def set_token(self, token): def set_timeout(self, timeout): self.timeout = self.__check_timeout(timeout) + def set_request_timeout(self, request_timeout): + self.request_timeout = self.__check_request_timeout(request_timeout) + def set_retries(self, retries): self.retries = self.__check_retries(retries) @@ -36,6 +40,9 @@ def get_token(self): def get_timeout(self): return self.timeout + def get_request_timeout(self): + return self.request_timeout + @staticmethod def __check_timeout(seconds): try: @@ -49,6 +56,23 @@ def __check_timeout(seconds): return i_seconds + @staticmethod + def __check_request_timeout(seconds): + # Per-request HTTP timeout passed to requests. Must be a positive int; + # None is rejected to avoid waiting indefinitely; 0 is rejected to avoid immediate timeouts. + if seconds is None: + raise Exception("request_timeout must be a positive int") + + try: + i_seconds = int(seconds) + except Exception: + raise Exception("request_timeout must be a positive int") + + if i_seconds <= 0: + raise Exception("request_timeout must be a positive int") + + return i_seconds + @staticmethod def __check_retries(retries): try: @@ -75,4 +99,3 @@ def __check_token(token: str): raise Exception("token is missing") return token -