Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,20 @@ the default amount of retries is 0. <br>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.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions unit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
87 changes: 79 additions & 8 deletions unit/api/base_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -36,96 +40,164 @@ 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
self.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))

Expand All @@ -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

29 changes: 26 additions & 3 deletions unit/utils/configuration.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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)

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -75,4 +99,3 @@ def __check_token(token: str):
raise Exception("token is missing")

return token

Loading