Skip to content

Commit 96fd9ce

Browse files
authored
feat(auth): add retries to aws boto calls (#88)
* feat(auth): add retries to aws boto calls the standard boto client defaults to 'legacy' which retries certain HTTP status codes and a limited set of service errors. update retry config to 'standard' which has a broader set of errors/exceptions. in particular it will retry 'TooManyRequestsException' which can be throttled in high burst situations. max retries can be configured by setting the 'STAX_API_AUTH_MAX_RETRIES' environment var. * feat(api): add default retry configuration to stax api calls add default retries for stax api calls. allow these configuration to be modified by consumers. amended prior boto3 configuration to opt for storing in the Stax config object. marked existing token threshold ENV configuration as deprecated. * chore(sdk): update comment with deprecated notice
1 parent 344f761 commit 96fd9ce

File tree

11 files changed

+214
-50
lines changed

11 files changed

+214
-50
lines changed

README.md

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,50 @@ export STAX_SECRET_KEY=<your_secret_key>
2424
```
2525

2626
##### Client Auth Configuration
27-
You can configure each client individually by passing in a config on init.
28-
When a client is created it's configuration will be locked in and any change to the configurations will not affect the client.
27+
The Stax SDK can configure each client individually by passing in a config on init.
28+
When a client is created its configuration will be locked in and any change to the configurations will not affect the client.
2929

3030
This can be seen in our [guide](https://github.com/stax-labs/lib-stax-python-sdk/blob/master/examples/auth.py).
3131

3232
*Optional configuration:*
3333

34-
##### Authentication token expiry
34+
##### Token expiry
3535

36-
Allows configuration of the threshold to when the Auth library should re-cache the credentials
36+
The Stax SDK can be configured to refresh the API Token prior to expiry.
3737
*Suggested use when running within CI/CD tools to reduce overall auth calls*
38-
~~~bash
38+
39+
```python
40+
from staxapp.config import Config, StaxAuthRetryConfig
41+
42+
auth_retry_config = StaxAuthRetryConfig
43+
auth_retry_config.token_expiry_threshold = 2
44+
Config.api_auth_retry_config = auth_retry_config
45+
```
46+
47+
(Deprecated): This value can also be set via the following Environment Var `TOKEN_EXPIRY_THRESHOLD_IN_MINS`
48+
```bash
3949
export TOKEN_EXPIRY_THRESHOLD_IN_MINS=2 # Type: Integer representing minutes
40-
~~~
50+
```
51+
52+
##### Retries
53+
54+
The Stax SDK has configured safe defaults for Auth and API retries.
55+
This behaviour can be adjusted via the SDK config: [example](https://github.com/stax-labs/lib-stax-python-sdk/blob/master/examples/retry.py).
56+
57+
```python
58+
from staxapp.config import Config, StaxAPIRetryConfig, StaxAuthRetryConfig
59+
60+
retry_config = StaxAPIRetryConfig
61+
retry_config.retry_methods = ('GET', 'POST', 'PUT', 'DELETE', 'OPTIONS')
62+
retry_config.status_codes = (429, 500, 502, 504)
63+
retry_config.backoff_factor = 1.2
64+
retry_config.max_attempts = 3
65+
Config.api_retry_config = retry_config
66+
67+
auth_retry_config = StaxAuthRetryConfig
68+
auth_retry_config.max_attempts = 3
69+
Config.api_auth_retry_config = auth_retry_config
70+
```
4171

4272
##### Logging levels
4373

examples/retry.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import json
2+
import os
3+
4+
from staxapp.config import Config, StaxAPIRetryConfig, StaxAuthRetryConfig
5+
from staxapp.openapi import StaxClient
6+
7+
Config.access_key = os.getenv("STAX_ACCESS_KEY")
8+
Config.secret_key = os.getenv("STAX_SECRET_KEY")
9+
10+
# Retry Config for Stax API calls
11+
retry_config = StaxAPIRetryConfig
12+
retry_config.retry_methods = ('GET', 'POST', 'PUT', 'DELETE', 'OPTIONS')
13+
retry_config.status_codes = (429, 500)
14+
15+
Config.api_retry_config = retry_config
16+
17+
# Retry config for Stax Authentication calls
18+
auth_retry_config = StaxAuthRetryConfig
19+
auth_retry_config.max_attempts = 3
20+
21+
Config.api_auth_retry_config = auth_retry_config
22+
23+
# Read all accounts within your Stax Organisation
24+
accounts = StaxClient("accounts")
25+
response = accounts.ReadAccounts()
26+
print(json.dumps(response, indent=4, sort_keys=True))
27+

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pylint
88
pytest
99
pytest-cov
1010
responses
11+
requests
1112
pyjwt==2.4.0
1213
boto3
1314
aws_requests_auth

staxapp/api.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
import json
1+
"""
2+
This module contains the http api handlers.
3+
"""
4+
import logging
25

36
import requests
47

5-
from staxapp.config import Config
8+
from staxapp.config import Config, StaxAPIRetryConfig
69
from staxapp.exceptions import ApiException
10+
from staxapp.retry import requests_retry_session
711

812

913
class Api:
@@ -22,19 +26,32 @@ def _headers(cls, custom_headers) -> dict:
2226
}
2327
return headers
2428

29+
@classmethod
30+
def request_session(cls, config: StaxAPIRetryConfig):
31+
"""Requests retry session with backoff"""
32+
print(config.retry_methods)
33+
return requests_retry_session(
34+
retries=config.max_attempts,
35+
status_list=config.status_codes,
36+
allowed_methods=config.retry_methods,
37+
backoff_factor=config.backoff_factor,
38+
)
39+
2540
@staticmethod
2641
def handle_api_response(response):
2742
try:
2843
response.raise_for_status()
2944
except requests.exceptions.HTTPError as e:
45+
# logging.debug(f"request retried {len(response.raw.retries.history)} times") ## Useful to prove working
3046
raise ApiException(str(e), response)
3147

3248
@classmethod
3349
def get(cls, url_frag, params={}, config=None, **kwargs):
3450
config = cls.get_config(config)
3551
url_frag = url_frag.replace(f"/{config.API_VERSION}", "")
3652
url = f"{config.api_base_url()}/{url_frag.lstrip('/')}"
37-
response = requests.get(
53+
54+
response = cls.request_session(config.api_retry_config).get(
3855
url,
3956
auth=config._auth(),
4057
params=params,
@@ -50,7 +67,7 @@ def post(cls, url_frag, payload={}, config=None, **kwargs):
5067
url_frag = url_frag.replace(f"/{config.API_VERSION}", "")
5168
url = f"{config.api_base_url()}/{url_frag.lstrip('/')}"
5269

53-
response = requests.post(
70+
response = cls.request_session(config.api_retry_config).post(
5471
url,
5572
json=payload,
5673
auth=config._auth(),
@@ -66,7 +83,7 @@ def put(cls, url_frag, payload={}, config=None, **kwargs):
6683
url_frag = url_frag.replace(f"/{config.API_VERSION}", "")
6784
url = f"{config.api_base_url()}/{url_frag.lstrip('/')}"
6885

69-
response = requests.put(
86+
response = cls.request_session(config.api_retry_config).put(
7087
url,
7188
json=payload,
7289
auth=config._auth(),
@@ -82,7 +99,7 @@ def delete(cls, url_frag, params={}, config=None, **kwargs):
8299
url_frag = url_frag.replace(f"/{config.API_VERSION}", "")
83100
url = f"{config.api_base_url()}/{url_frag.lstrip('/')}"
84101

85-
response = requests.delete(
102+
response = cls.request_session(config.api_retry_config).delete(
86103
url,
87104
auth=config._auth(),
88105
params=params,

staxapp/auth.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
#!/usr/local/bin/python3
22
from datetime import datetime, timedelta, timezone
3-
from os import environ
43

54
import boto3
65
from aws_requests_auth.aws_auth import AWSRequestsAuth
@@ -14,7 +13,7 @@
1413

1514

1615
class StaxAuth:
17-
def __init__(self, config_branch: str, config: StaxConfig, max_retries: int = 3):
16+
def __init__(self, config_branch: str, config: StaxConfig, max_retries: int = 5):
1817
self.config = config
1918
api_config = self.config.api_config
2019
self.identity_pool = api_config.get(config_branch).get("identityPoolId")
@@ -52,7 +51,10 @@ def id_token_from_cognito(
5251
srp_client = boto3.client(
5352
"cognito-idp",
5453
region_name=self.aws_region,
55-
config=BotoConfig(signature_version=UNSIGNED),
54+
config=BotoConfig(
55+
signature_version=UNSIGNED,
56+
retries={"max_attempts": self.max_retries, "mode": "standard"},
57+
),
5658
)
5759
aws = AWSSRP(
5860
username=username,
@@ -86,7 +88,10 @@ def sts_from_cognito_identity_pool(self, token, cognito_client=None, **kwargs):
8688
cognito_client = boto3.client(
8789
"cognito-identity",
8890
region_name=self.aws_region,
89-
config=BotoConfig(signature_version=UNSIGNED),
91+
config=BotoConfig(
92+
signature_version=UNSIGNED,
93+
retries={"max_attempts": self.max_retries, "mode": "standard"},
94+
),
9095
)
9196

9297
for i in range(self.max_retries):
@@ -105,7 +110,7 @@ def sts_from_cognito_identity_pool(self, token, cognito_client=None, **kwargs):
105110
)
106111
break
107112
except ClientError as e:
108-
# AWS eventual consistency, attempt to retry up to 3 times
113+
# AWS eventual consistency, attempt to retry up to n (max_retries) times
109114
if "Couldn't verify signed token" in str(e):
110115
continue
111116
else:
@@ -146,10 +151,14 @@ def requests_auth(username, password, **kwargs):
146151
class ApiTokenAuth:
147152
@staticmethod
148153
def requests_auth(config: StaxConfig, **kwargs):
149-
# Minimize the potentical for token to expire while still being used for auth (say within a lambda function)
154+
# Minimize the potential for token to expire while still being used for auth (say within a lambda function)
155+
print(config.api_auth_retry_config.token_expiry_threshold)
150156
if config.expiration and config.expiration - timedelta(
151-
minutes=int(environ.get("TOKEN_EXPIRY_THRESHOLD_IN_MINS", 1))
157+
minutes=config.api_auth_retry_config.token_expiry_threshold
152158
) > datetime.now(timezone.utc):
153159
return config.auth
154-
155-
return StaxAuth("ApiAuth", config).requests_auth(**kwargs)
160+
return StaxAuth(
161+
"ApiAuth",
162+
config,
163+
max_retries=config.api_auth_retry_config.max_attempts,
164+
).requests_auth(**kwargs)

staxapp/aws_srp.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
import boto3
2929
import six
30+
from botocore.config import Config as BotoConfig
3031

3132

3233
class WarrantException(Exception):
@@ -153,7 +154,13 @@ def __init__(
153154
self.client_id = client_id
154155
self.client_secret = client_secret
155156
self.client = (
156-
client if client else boto3.client("cognito-idp", region_name=pool_region)
157+
client
158+
if client
159+
else boto3.client(
160+
"cognito-idp",
161+
region_name=pool_region,
162+
config=BotoConfig(retries={"max_attempts": 5, "mode": "standard"}),
163+
)
157164
)
158165
self.big_n = hex_to_long(n_hex)
159166
self.g = hex_to_long(g_hex)

staxapp/config.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import logging
22
import os
33
import platform as sysinfo
4-
from distutils.command.config import config
5-
from email.policy import default
4+
from typing import NamedTuple
65

76
import requests
87

@@ -12,9 +11,39 @@
1211
logging.getLogger().setLevel(os.environ.get("LOG_LEVEL", logging.INFO))
1312

1413

14+
class StaxAuthRetryConfig(NamedTuple):
15+
"""
16+
Configuration options for the Stax API Auth retry
17+
max_attempts: int: number of attempts to make
18+
token_expiry_threshold: int: number of minutes before expiry to refresh
19+
"""
20+
21+
max_attempts = 5
22+
token_expiry_threshold = int(
23+
# Env Var for backwards compatability, deprecated since 1.3.0
24+
os.getenv("TOKEN_EXPIRY_THRESHOLD_IN_MINS", 1)
25+
)
26+
27+
28+
class StaxAPIRetryConfig(NamedTuple):
29+
"""
30+
Configuration options for the Stax API Auth retry
31+
32+
max_attempts: int: number of attempts to make
33+
backoff_factor: float: exponential backoff factor
34+
status_codes: Tuple[int]: number of attempts to make
35+
retry_methods: Tuple[str]: http methods to perform retries on
36+
"""
37+
38+
max_attempts = 5
39+
backoff_factor = 1.0
40+
status_codes = (429, 500, 502, 504)
41+
retry_methods = ("GET", "PUT", "DELETE", "OPTIONS")
42+
43+
1544
class Config:
1645
"""
17-
Insert doco here
46+
Stax SDK Config
1847
"""
1948

2049
STAX_REGION = os.getenv("STAX_REGION", "au1.staxapp.cloud")
@@ -38,6 +67,9 @@ class Config:
3867
python_version = sysinfo.python_version()
3968
sdk_version = staxapp.__version__
4069

70+
api_auth_retry_config = StaxAuthRetryConfig
71+
api_retry_config = StaxAPIRetryConfig
72+
4173
def set_config(self):
4274
self.base_url = f"https://{self.hostname}/{self.API_VERSION}"
4375
config_url = f"{self.api_base_url()}/public/config"
@@ -60,11 +92,20 @@ def get_api_config(cls, config_url):
6092
cls.cached_api_config["caching"] = config_url
6193
return config_response.json()
6294

63-
def __init__(self, hostname=None, access_key=None, secret_key=None):
95+
def __init__(
96+
self,
97+
hostname=None,
98+
access_key=None,
99+
secret_key=None,
100+
api_auth_retry_config=StaxAuthRetryConfig,
101+
api_retry_config=StaxAPIRetryConfig,
102+
):
64103
if hostname is not None:
65104
self.hostname = hostname
66105
self.access_key = access_key
67106
self.secret_key = secret_key
107+
self.api_auth_retry_config = api_auth_retry_config
108+
self.api_retry_config = api_retry_config
68109

69110
def init(self):
70111
if self._initialized:

staxapp/openapi.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from staxapp.auth import ApiTokenAuth
88
from staxapp.config import Config
99
from staxapp.contract import StaxContract
10-
from staxapp.exceptions import ApiException, ValidationException
10+
from staxapp.exceptions import ValidationException
1111

1212

1313
class StaxClient:
@@ -24,6 +24,8 @@ def __init__(self, classname, force=False, config=None):
2424
hostname=config.hostname,
2525
access_key=config.access_key,
2626
secret_key=config.secret_key,
27+
api_auth_retry_config=config.api_auth_retry_config,
28+
api_retry_config=config.api_retry_config,
2729
)
2830
if not self._config._initialized:
2931
self._config.init()
@@ -111,7 +113,7 @@ def stax_wrapper(*args, **kwargs):
111113
]
112114
# Sort the operation map parameters
113115
parameter_index = -1
114-
# Check if the any of the parameter schemas match parameters provided
116+
# Check if any of the parameter schemas match parameters provided
115117
for index in range(0, len(operation_parameters)):
116118
# Get any parameters from the keyword args and remove them from the payload
117119
if set(operation_parameters[index]).issubset(payload.keys()):

0 commit comments

Comments
 (0)