novastack-utils 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- novastack_utils-1.0.0/.gitignore +85 -0
- novastack_utils-1.0.0/PKG-INFO +26 -0
- novastack_utils-1.0.0/README.md +9 -0
- novastack_utils-1.0.0/novastack_utils/__init__.py +6 -0
- novastack_utils-1.0.0/novastack_utils/http/__init__.py +5 -0
- novastack_utils-1.0.0/novastack_utils/http/authenticators/__init__.py +21 -0
- novastack_utils-1.0.0/novastack_utils/http/authenticators/base.py +22 -0
- novastack_utils-1.0.0/novastack_utils/http/authenticators/basic_authenticator.py +37 -0
- novastack_utils-1.0.0/novastack_utils/http/authenticators/ibm_iam_authenticator.py +99 -0
- novastack_utils-1.0.0/novastack_utils/http/authenticators/no_auth_authenticator.py +11 -0
- novastack_utils-1.0.0/novastack_utils/http/authenticators/oauth2_authenticator.py +200 -0
- novastack_utils-1.0.0/novastack_utils/http/base.py +330 -0
- novastack_utils-1.0.0/novastack_utils/http/exceptions.py +14 -0
- novastack_utils-1.0.0/novastack_utils/http/types.py +36 -0
- novastack_utils-1.0.0/novastack_utils/retry/__init__.py +19 -0
- novastack_utils-1.0.0/novastack_utils/retry/decorator.py +108 -0
- novastack_utils-1.0.0/novastack_utils/retry/strategies.py +115 -0
- novastack_utils-1.0.0/novastack_utils/retry/types.py +27 -0
- novastack_utils-1.0.0/novastack_utils/utils.py +39 -0
- novastack_utils-1.0.0/pyproject.toml +33 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
#IDE
|
|
10
|
+
.DS_Store
|
|
11
|
+
.idea
|
|
12
|
+
.vscode
|
|
13
|
+
|
|
14
|
+
# Distribution / packaging
|
|
15
|
+
.Python
|
|
16
|
+
build/
|
|
17
|
+
develop-eggs/
|
|
18
|
+
dist/
|
|
19
|
+
downloads/
|
|
20
|
+
eggs/
|
|
21
|
+
.eggs/
|
|
22
|
+
lib/
|
|
23
|
+
lib64/
|
|
24
|
+
parts/
|
|
25
|
+
sdist/
|
|
26
|
+
var/
|
|
27
|
+
wheels/
|
|
28
|
+
share/python-wheels/
|
|
29
|
+
*.egg-info/
|
|
30
|
+
.installed.cfg
|
|
31
|
+
*.egg
|
|
32
|
+
MANIFEST
|
|
33
|
+
|
|
34
|
+
# PyInstaller
|
|
35
|
+
# Usually these files are written by a python script from a template
|
|
36
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
37
|
+
*.manifest
|
|
38
|
+
*.spec
|
|
39
|
+
|
|
40
|
+
# Installer logs
|
|
41
|
+
pip-log.txt
|
|
42
|
+
pip-delete-this-directory.txt
|
|
43
|
+
|
|
44
|
+
# Unit test / coverage reports
|
|
45
|
+
htmlcov/
|
|
46
|
+
.tox/
|
|
47
|
+
.nox/
|
|
48
|
+
.coverage
|
|
49
|
+
.coverage.*
|
|
50
|
+
.cache
|
|
51
|
+
nosetests.xml
|
|
52
|
+
coverage.xml
|
|
53
|
+
*.cover
|
|
54
|
+
*.py,cover
|
|
55
|
+
.hypothesis/
|
|
56
|
+
.pytest_cache/
|
|
57
|
+
cover/
|
|
58
|
+
|
|
59
|
+
# Mkdocs documentation
|
|
60
|
+
docs/_build/
|
|
61
|
+
docs/api_reference/site/
|
|
62
|
+
|
|
63
|
+
# Ruff
|
|
64
|
+
.ruff_cache/
|
|
65
|
+
|
|
66
|
+
# PyBuilder
|
|
67
|
+
.pybuilder/
|
|
68
|
+
target/
|
|
69
|
+
|
|
70
|
+
# Jupyter Notebook
|
|
71
|
+
.ipynb_checkpoints
|
|
72
|
+
|
|
73
|
+
# pyenv
|
|
74
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
75
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
76
|
+
.python-version
|
|
77
|
+
|
|
78
|
+
# Environments
|
|
79
|
+
.env
|
|
80
|
+
.venv
|
|
81
|
+
env/
|
|
82
|
+
venv/
|
|
83
|
+
ENV/
|
|
84
|
+
env.bak/
|
|
85
|
+
venv.bak/
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: novastack-utils
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: This library contains utilities used across various packages.
|
|
5
|
+
Project-URL: Repository, https://github.com/novastack-project/novastack/tree/main/novastack-utils
|
|
6
|
+
Author-email: Leonardo Furnielis <leonardofurnielis@outlook.com>
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Requires-Python: <3.14,>=3.11
|
|
9
|
+
Requires-Dist: httpx<1.0.0,>=0.28.1
|
|
10
|
+
Requires-Dist: pydantic<3.0.0,>=2.12.5
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio<2.0.0,>=1.3.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-mock<4.0.0,>=3.15.1; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest<10.0.0,>=9.0.3; extra == 'dev'
|
|
15
|
+
Requires-Dist: ruff<1.0.0,>=0.15.8; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# Novastack utils
|
|
19
|
+
|
|
20
|
+
This library contains utils utilities used across various packages.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install novastack-utils
|
|
26
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from novastack_utils.http.authenticators.basic_authenticator import (
|
|
2
|
+
BasicAuthenticator,
|
|
3
|
+
)
|
|
4
|
+
from novastack_utils.http.authenticators.ibm_iam_authenticator import (
|
|
5
|
+
IBMIAMAuthenticator,
|
|
6
|
+
)
|
|
7
|
+
from novastack_utils.http.authenticators.no_auth_authenticator import (
|
|
8
|
+
NoAuthAuthenticator,
|
|
9
|
+
)
|
|
10
|
+
from novastack_utils.http.authenticators.oauth2_authenticator import (
|
|
11
|
+
OAuth2Authenticator,
|
|
12
|
+
OAuth2GrantType,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BasicAuthenticator",
|
|
17
|
+
"IBMIAMAuthenticator",
|
|
18
|
+
"NoAuthAuthenticator",
|
|
19
|
+
"OAuth2Authenticator",
|
|
20
|
+
"OAuth2GrantType",
|
|
21
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseAuthenticator(BaseModel, ABC):
|
|
7
|
+
"""
|
|
8
|
+
Abstract base class for authentication.
|
|
9
|
+
|
|
10
|
+
All authentication strategies must implement the authenticate method
|
|
11
|
+
to provide authentication headers for HTTP requests.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
model_config = {
|
|
15
|
+
"arbitrary_types_allowed": True,
|
|
16
|
+
"validate_assignment": True,
|
|
17
|
+
"validate_default": True,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def authenticate(self) -> dict[str, str]:
|
|
22
|
+
"""Authenticate and return headers to be added to requests."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, SecretStr
|
|
4
|
+
|
|
5
|
+
from novastack_utils.http.authenticators.base import BaseAuthenticator
|
|
6
|
+
from novastack_utils.http.exceptions import HttpAuthenticationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BasicAuthenticator(BaseAuthenticator):
|
|
10
|
+
"""
|
|
11
|
+
HTTP Basic Authentication.
|
|
12
|
+
|
|
13
|
+
Implements standard HTTP Basic authentication using Base64 encoding
|
|
14
|
+
of username and password credentials.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
username: str = Field(..., min_length=1, description="Username for authentication")
|
|
18
|
+
password: SecretStr = Field(..., description="Password for authentication")
|
|
19
|
+
|
|
20
|
+
def authenticate(self) -> dict[str, str]:
|
|
21
|
+
"""Generate Basic authentication header."""
|
|
22
|
+
try:
|
|
23
|
+
password_value = self.password.get_secret_value()
|
|
24
|
+
|
|
25
|
+
# Combine username and password
|
|
26
|
+
credentials = f"{self.username}:{password_value}"
|
|
27
|
+
|
|
28
|
+
# Encode to Base64
|
|
29
|
+
encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
|
|
30
|
+
|
|
31
|
+
# Return Authorization header
|
|
32
|
+
return {"Authorization": f"Basic {encoded}"}
|
|
33
|
+
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise HttpAuthenticationError(
|
|
36
|
+
f"Failed to generate BasicAuthenticator header: {e}"
|
|
37
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import Field, PrivateAttr, SecretStr
|
|
5
|
+
|
|
6
|
+
from novastack_utils.http.authenticators.base import BaseAuthenticator
|
|
7
|
+
from novastack_utils.http.exceptions import HttpAuthenticationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IBMIAMAuthenticator(BaseAuthenticator):
|
|
11
|
+
"""
|
|
12
|
+
IBM Cloud IAM authentication.
|
|
13
|
+
|
|
14
|
+
Uses IBM Cloud API Key to obtain access tokens from IBM IAM service.
|
|
15
|
+
Automatically manages token lifecycle including expiration tracking and refresh.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
api_key: SecretStr = Field(..., description="IBM Cloud API Key")
|
|
19
|
+
token_url: str = Field(
|
|
20
|
+
default="https://iam.cloud.ibm.com/identity/token",
|
|
21
|
+
description="IBM IAM token endpoint URL",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_access_token: str | None = PrivateAttr(default=None)
|
|
25
|
+
_token_type: str = PrivateAttr(default="Bearer")
|
|
26
|
+
_expires_at: datetime | None = PrivateAttr(default=None)
|
|
27
|
+
_refresh_token: str | None = PrivateAttr(default=None)
|
|
28
|
+
|
|
29
|
+
def authenticate(self) -> dict[str, str]:
|
|
30
|
+
"""
|
|
31
|
+
Get authentication headers with valid access token.
|
|
32
|
+
|
|
33
|
+
Automatically refreshes token if expired.
|
|
34
|
+
"""
|
|
35
|
+
# Check if token needs refresh
|
|
36
|
+
if self.is_expired():
|
|
37
|
+
self._get_access_token()
|
|
38
|
+
|
|
39
|
+
return {"Authorization": f"{self._token_type} {self._access_token}"}
|
|
40
|
+
|
|
41
|
+
def is_expired(self) -> bool:
|
|
42
|
+
"""Check if access token is expired."""
|
|
43
|
+
if not self._access_token or not self._expires_at:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
# Consider token expired if it expires within 60 seconds
|
|
47
|
+
buffer = timedelta(seconds=60)
|
|
48
|
+
return datetime.now() >= (self._expires_at - buffer)
|
|
49
|
+
|
|
50
|
+
def _get_access_token(self) -> None:
|
|
51
|
+
"""Get IBM IAM access token."""
|
|
52
|
+
try:
|
|
53
|
+
data = {
|
|
54
|
+
"grant_type": "urn:ibm:params:oauth:grant-type:apikey",
|
|
55
|
+
"apikey": self.api_key.get_secret_value(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Make token request
|
|
59
|
+
response = httpx.post(
|
|
60
|
+
self.token_url,
|
|
61
|
+
data=data,
|
|
62
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Raise for any HTTP error status
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
|
|
68
|
+
token_data = response.json()
|
|
69
|
+
|
|
70
|
+
self._access_token = token_data.get("access_token")
|
|
71
|
+
if not self._access_token:
|
|
72
|
+
raise HttpAuthenticationError(
|
|
73
|
+
"No 'access_token' in IBM IAM token response"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self._token_type = token_data.get("token_type", "Bearer")
|
|
77
|
+
self._refresh_token = token_data.get("refresh_token")
|
|
78
|
+
|
|
79
|
+
# Calculate expiration time
|
|
80
|
+
expires_in = token_data.get("expires_in")
|
|
81
|
+
if expires_in:
|
|
82
|
+
self._expires_at = datetime.now() + timedelta(seconds=int(expires_in))
|
|
83
|
+
else:
|
|
84
|
+
# Default to 1 hour if not provided
|
|
85
|
+
self._expires_at = datetime.now() + timedelta(hours=1)
|
|
86
|
+
|
|
87
|
+
except httpx.HTTPStatusError as e:
|
|
88
|
+
error_detail = e.response.text
|
|
89
|
+
raise HttpAuthenticationError(
|
|
90
|
+
f"IBM IAM token request failed with status {e.response.status_code}: {error_detail}"
|
|
91
|
+
)
|
|
92
|
+
except httpx.TimeoutException as e:
|
|
93
|
+
raise HttpAuthenticationError(f"IBM IAM token request timed out: {e}")
|
|
94
|
+
except httpx.ConnectError as e:
|
|
95
|
+
raise HttpAuthenticationError(f"Failed to connect to IBM IAM endpoint: {e}")
|
|
96
|
+
except HttpAuthenticationError:
|
|
97
|
+
raise
|
|
98
|
+
except Exception as e:
|
|
99
|
+
raise HttpAuthenticationError(f"Failed to obtain IBM IAM token: {e}")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from novastack_utils.http.authenticators.base import BaseAuthenticator
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NoAuthAuthenticator(BaseAuthenticator):
|
|
5
|
+
"""
|
|
6
|
+
This is the default authentication when no authentication is required.
|
|
7
|
+
It returns empty dict, adding no authentication headers to requests.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def authenticate(self) -> dict[str, str]:
|
|
11
|
+
return {}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from pydantic import Field, PrivateAttr, SecretStr, field_validator
|
|
6
|
+
|
|
7
|
+
from novastack_utils import validate_enum
|
|
8
|
+
from novastack_utils.http.authenticators.base import BaseAuthenticator
|
|
9
|
+
from novastack_utils.http.exceptions import HttpAuthenticationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OAuth2GrantType:
|
|
13
|
+
"""
|
|
14
|
+
Supported OAuth2 grant type.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
CLIENT_CREDENTIALS (str): "client_credentials"
|
|
18
|
+
PASSWORD (str):"password"
|
|
19
|
+
AUTHORIZATION_CODE (str): "authorization_code"
|
|
20
|
+
REFRESH_TOKEN (str): "refresh_token"
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
CLIENT_CREDENTIALS = "client_credentials"
|
|
24
|
+
PASSWORD = "password"
|
|
25
|
+
AUTHORIZATION_CODE = "authorization_code"
|
|
26
|
+
REFRESH_TOKEN = "refresh_token"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OAuth2Authenticator(BaseAuthenticator):
|
|
30
|
+
"""
|
|
31
|
+
OAuth 2.0 authentication.
|
|
32
|
+
|
|
33
|
+
Automatically manages token lifecycle including expiration tracking and refresh.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
token_url: str = Field(..., description="OAuth2 token endpoint URL")
|
|
37
|
+
client_id: str = Field(..., min_length=1, description="OAuth2 client ID")
|
|
38
|
+
client_secret: SecretStr = Field(..., description="OAuth2 client secret")
|
|
39
|
+
grant_type: str = Field(
|
|
40
|
+
default=OAuth2GrantType.CLIENT_CREDENTIALS, description="OAuth2 grant type"
|
|
41
|
+
)
|
|
42
|
+
username: str | None = Field(
|
|
43
|
+
default=None, description="Username for password grant_type"
|
|
44
|
+
)
|
|
45
|
+
password: SecretStr | None = Field(
|
|
46
|
+
default=None, description="Password for password grant_type"
|
|
47
|
+
)
|
|
48
|
+
scope: str | None = Field(
|
|
49
|
+
default=None, description="Optional OAuth scope to request"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_access_token: str | None = PrivateAttr(default=None)
|
|
53
|
+
_token_type: str = PrivateAttr(default="Bearer")
|
|
54
|
+
_expires_at: datetime | None = PrivateAttr(default=None)
|
|
55
|
+
_refresh_token: str | None = PrivateAttr(default=None)
|
|
56
|
+
|
|
57
|
+
@field_validator("grant_type")
|
|
58
|
+
def _validate_grant_type(cls, v):
|
|
59
|
+
validate_enum(el=v, el_name="grant_type", expected_enum=OAuth2GrantType)
|
|
60
|
+
return v
|
|
61
|
+
|
|
62
|
+
def model_post_init(self, __context: Any) -> None: # noqa: PYI063
|
|
63
|
+
if self.grant_type == OAuth2GrantType.PASSWORD:
|
|
64
|
+
if not self.username or not self.password:
|
|
65
|
+
raise HttpAuthenticationError(
|
|
66
|
+
"'username' and 'password' are required for PASSWORD grant_type."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def authenticate(self) -> dict[str, str]:
|
|
70
|
+
"""
|
|
71
|
+
Get authentication headers with valid access token.
|
|
72
|
+
|
|
73
|
+
Automatically refreshes token if expired.
|
|
74
|
+
"""
|
|
75
|
+
# Check if token needs refresh
|
|
76
|
+
if self.is_expired():
|
|
77
|
+
self.refresh_token()
|
|
78
|
+
|
|
79
|
+
# If still no token, get a new one
|
|
80
|
+
if not self._access_token:
|
|
81
|
+
self._get_access_token()
|
|
82
|
+
|
|
83
|
+
return {"Authorization": f"{self._token_type} {self._access_token}"}
|
|
84
|
+
|
|
85
|
+
def refresh_token(self) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Refresh OAuth2 token.
|
|
88
|
+
|
|
89
|
+
Attempts to use refresh token if available, otherwise obtains new token.
|
|
90
|
+
"""
|
|
91
|
+
# If we have a refresh token, try to use it
|
|
92
|
+
if self._refresh_token:
|
|
93
|
+
try:
|
|
94
|
+
self._get_access_token(use_refresh_token=True)
|
|
95
|
+
return
|
|
96
|
+
except HttpAuthenticationError:
|
|
97
|
+
# If refresh fails, fall through to get new token
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
self._get_access_token()
|
|
101
|
+
|
|
102
|
+
def is_expired(self) -> bool:
|
|
103
|
+
"""Check if access token is expired."""
|
|
104
|
+
if not self._access_token or not self._expires_at:
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
# Consider token expired if it expires within 60 seconds
|
|
108
|
+
buffer = timedelta(seconds=60)
|
|
109
|
+
return datetime.now() >= (self._expires_at - buffer)
|
|
110
|
+
|
|
111
|
+
def _get_access_token(self, use_refresh_token: bool = False) -> None:
|
|
112
|
+
"""
|
|
113
|
+
Get OAuth2 access token from token endpoint.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
use_refresh_token: Whether to use refresh token grant
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
# Build request payload
|
|
120
|
+
data = self._build_token_request_data(use_refresh_token)
|
|
121
|
+
|
|
122
|
+
# Make token request
|
|
123
|
+
response = httpx.post(
|
|
124
|
+
self.token_url,
|
|
125
|
+
data=data,
|
|
126
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Raise for any HTTP error status
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
|
|
132
|
+
token_data = response.json()
|
|
133
|
+
|
|
134
|
+
self._access_token = token_data.get("access_token")
|
|
135
|
+
if not self._access_token:
|
|
136
|
+
raise HttpAuthenticationError(
|
|
137
|
+
"No 'access_token' in authenticator response"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self._token_type = token_data.get("token_type", "Bearer")
|
|
141
|
+
self._refresh_token = token_data.get("refresh_token")
|
|
142
|
+
|
|
143
|
+
# Calculate expiration time
|
|
144
|
+
expires_in = token_data.get("expires_in")
|
|
145
|
+
if expires_in:
|
|
146
|
+
self._expires_at = datetime.now() + timedelta(seconds=int(expires_in))
|
|
147
|
+
else:
|
|
148
|
+
# Default to 1 hour if not provided
|
|
149
|
+
self._expires_at = datetime.now() + timedelta(hours=1)
|
|
150
|
+
|
|
151
|
+
except httpx.HTTPStatusError as e:
|
|
152
|
+
error_detail = e.response.text
|
|
153
|
+
raise HttpAuthenticationError(
|
|
154
|
+
f"Token request failed with status {e.response.status_code}: {error_detail}"
|
|
155
|
+
)
|
|
156
|
+
except httpx.TimeoutException as e:
|
|
157
|
+
raise HttpAuthenticationError(f"Token request timed out: {e}")
|
|
158
|
+
except httpx.ConnectError as e:
|
|
159
|
+
raise HttpAuthenticationError(f"Failed to connect to token endpoint: {e}")
|
|
160
|
+
except HttpAuthenticationError:
|
|
161
|
+
raise
|
|
162
|
+
except Exception as e:
|
|
163
|
+
raise HttpAuthenticationError(f"Failed to get OAuth2 token: {e}")
|
|
164
|
+
|
|
165
|
+
def _build_token_request_data(self, use_refresh_token: bool) -> dict[str, str]:
|
|
166
|
+
"""
|
|
167
|
+
Build OAuth2 token request payload.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
use_refresh_token: Whether to use refresh token grant
|
|
171
|
+
"""
|
|
172
|
+
base_data = {
|
|
173
|
+
"client_id": self.client_id,
|
|
174
|
+
"client_secret": self.client_secret.get_secret_value(),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if use_refresh_token and self._refresh_token:
|
|
178
|
+
data = {
|
|
179
|
+
"grant_type": OAuth2GrantType.REFRESH_TOKEN,
|
|
180
|
+
"refresh_token": self._refresh_token,
|
|
181
|
+
**base_data,
|
|
182
|
+
}
|
|
183
|
+
elif self.grant_type == OAuth2GrantType.PASSWORD:
|
|
184
|
+
data = {
|
|
185
|
+
"grant_type": self.grant_type,
|
|
186
|
+
"username": self.username,
|
|
187
|
+
"password": self.password.get_secret_value() if self.password else None,
|
|
188
|
+
**base_data,
|
|
189
|
+
}
|
|
190
|
+
else:
|
|
191
|
+
data = {
|
|
192
|
+
"grant_type": self.grant_type,
|
|
193
|
+
**base_data,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# Add optional scope
|
|
197
|
+
if self.scope:
|
|
198
|
+
data["scope"] = self.scope
|
|
199
|
+
|
|
200
|
+
return data
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
5
|
+
|
|
6
|
+
from novastack_utils.http.authenticators import NoAuthAuthenticator
|
|
7
|
+
from novastack_utils.http.authenticators.base import BaseAuthenticator
|
|
8
|
+
from novastack_utils.http.exceptions import (
|
|
9
|
+
HttpConnectionError,
|
|
10
|
+
HttpRequestError,
|
|
11
|
+
HttpRequestTimeoutError,
|
|
12
|
+
)
|
|
13
|
+
from novastack_utils.http.types import HttpResponse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HttpService(BaseModel):
|
|
17
|
+
"""
|
|
18
|
+
Enterprise-grade HTTP service with authentication and connection pooling.
|
|
19
|
+
|
|
20
|
+
Provides both synchronous and asynchronous HTTP methods with pluggable
|
|
21
|
+
authentication strategies.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = {
|
|
25
|
+
"arbitrary_types_allowed": True,
|
|
26
|
+
"validate_assignment": True,
|
|
27
|
+
"validate_default": True,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
base_url: str = Field(..., description="Base URL for all requests")
|
|
31
|
+
timeout: float = Field(default=30.0, gt=0, description="Request timeout in seconds")
|
|
32
|
+
verify_ssl: bool = Field(
|
|
33
|
+
default=True, description="Whether to verify SSL certificates"
|
|
34
|
+
)
|
|
35
|
+
headers: dict[str, str] = Field(
|
|
36
|
+
default_factory=dict, description="Default headers for all requests"
|
|
37
|
+
)
|
|
38
|
+
authenticator: BaseAuthenticator = Field(
|
|
39
|
+
default_factory=NoAuthAuthenticator, description="Authentication strategy."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_client: httpx.Client = PrivateAttr()
|
|
43
|
+
_async_client: httpx.AsyncClient = PrivateAttr()
|
|
44
|
+
|
|
45
|
+
def model_post_init(self, __context: Any) -> None: # noqa: PYI063
|
|
46
|
+
"""Initialize HTTP clients after model creation."""
|
|
47
|
+
# Initialize sync client
|
|
48
|
+
self._client = httpx.Client(
|
|
49
|
+
base_url=self.base_url,
|
|
50
|
+
timeout=self.timeout,
|
|
51
|
+
verify=self.verify_ssl,
|
|
52
|
+
headers=self.headers,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Initialize async client
|
|
56
|
+
self._async_client = httpx.AsyncClient(
|
|
57
|
+
base_url=self.base_url,
|
|
58
|
+
timeout=self.timeout,
|
|
59
|
+
verify=self.verify_ssl,
|
|
60
|
+
headers=self.headers,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _prepare_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
|
|
64
|
+
"""
|
|
65
|
+
Prepare request headers with authentication.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
headers: Additional headers to include
|
|
69
|
+
"""
|
|
70
|
+
combined_headers = self.headers.copy()
|
|
71
|
+
if headers:
|
|
72
|
+
combined_headers.update(headers)
|
|
73
|
+
|
|
74
|
+
# Apply authentication if strategy is provided
|
|
75
|
+
if self.authenticator:
|
|
76
|
+
auth_headers = self.authenticator.authenticate()
|
|
77
|
+
combined_headers.update(auth_headers)
|
|
78
|
+
|
|
79
|
+
return combined_headers
|
|
80
|
+
|
|
81
|
+
def _handle_response(self, response: httpx.Response) -> HttpResponse:
|
|
82
|
+
"""
|
|
83
|
+
Handle HTTP response and convert to HttpResponse object.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
response: httpx response object
|
|
87
|
+
"""
|
|
88
|
+
return HttpResponse(
|
|
89
|
+
status_code=response.status_code,
|
|
90
|
+
headers=dict(response.headers),
|
|
91
|
+
content=response.content,
|
|
92
|
+
url=str(response.url),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def get(
|
|
96
|
+
self,
|
|
97
|
+
url: str,
|
|
98
|
+
params: dict[str, Any] | None = None,
|
|
99
|
+
headers: dict[str, str] | None = None,
|
|
100
|
+
) -> HttpResponse:
|
|
101
|
+
"""
|
|
102
|
+
Perform synchronous GET request.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
url: Request url
|
|
106
|
+
params: Query parameters
|
|
107
|
+
headers: Additional headers
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
prepared_headers = self._prepare_headers(headers)
|
|
111
|
+
response = self._client.get(url, params=params, headers=prepared_headers)
|
|
112
|
+
return self._handle_response(response)
|
|
113
|
+
|
|
114
|
+
except httpx.TimeoutException as e:
|
|
115
|
+
raise HttpRequestTimeoutError(str(e))
|
|
116
|
+
except httpx.ConnectError as e:
|
|
117
|
+
raise HttpConnectionError(f"Failed to connect: {e}")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
raise HttpRequestError(f"GET request failed: {e}")
|
|
120
|
+
|
|
121
|
+
def post(
|
|
122
|
+
self,
|
|
123
|
+
url: str,
|
|
124
|
+
data: dict[str, Any] | None = None,
|
|
125
|
+
json: dict[str, Any] | None = None,
|
|
126
|
+
params: dict[str, Any] | None = None,
|
|
127
|
+
headers: dict[str, str] | None = None,
|
|
128
|
+
) -> HttpResponse:
|
|
129
|
+
"""
|
|
130
|
+
Perform synchronous POST request.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
url: Request url (relative to base_url)
|
|
134
|
+
data: Form data
|
|
135
|
+
json: JSON data
|
|
136
|
+
params: Query parameters
|
|
137
|
+
headers: Additional headers
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
prepared_headers = self._prepare_headers(headers)
|
|
141
|
+
response = self._client.post(
|
|
142
|
+
url, data=data, json=json, params=params, headers=prepared_headers
|
|
143
|
+
)
|
|
144
|
+
return self._handle_response(response)
|
|
145
|
+
except httpx.TimeoutException as e:
|
|
146
|
+
raise HttpRequestTimeoutError(str(e))
|
|
147
|
+
except httpx.ConnectError as e:
|
|
148
|
+
raise HttpConnectionError(f"Failed to connect: {e}")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
raise HttpRequestError(f"POST request failed: {e}")
|
|
151
|
+
|
|
152
|
+
def put(
|
|
153
|
+
self,
|
|
154
|
+
url: str,
|
|
155
|
+
data: dict[str, Any] | None = None,
|
|
156
|
+
json: dict[str, Any] | None = None,
|
|
157
|
+
params: dict[str, Any] | None = None,
|
|
158
|
+
headers: dict[str, str] | None = None,
|
|
159
|
+
) -> HttpResponse:
|
|
160
|
+
"""
|
|
161
|
+
Perform synchronous PUT request.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
url: Request url (relative to base_url)
|
|
165
|
+
data: Form data
|
|
166
|
+
json: JSON data
|
|
167
|
+
params: Query parameters
|
|
168
|
+
headers: Additional headers
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
prepared_headers = self._prepare_headers(headers)
|
|
172
|
+
response = self._client.put(
|
|
173
|
+
url, data=data, json=json, params=params, headers=prepared_headers
|
|
174
|
+
)
|
|
175
|
+
return self._handle_response(response)
|
|
176
|
+
except httpx.TimeoutException as e:
|
|
177
|
+
raise HttpRequestTimeoutError(str(e))
|
|
178
|
+
except httpx.ConnectError as e:
|
|
179
|
+
raise HttpConnectionError(f"Failed to connect: {e}")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
raise HttpRequestError(f"PUT request failed: {e}")
|
|
182
|
+
|
|
183
|
+
def delete(
|
|
184
|
+
self,
|
|
185
|
+
url: str,
|
|
186
|
+
params: dict[str, Any] | None = None,
|
|
187
|
+
headers: dict[str, str] | None = None,
|
|
188
|
+
) -> HttpResponse:
|
|
189
|
+
"""
|
|
190
|
+
Perform synchronous DELETE request.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
url: Request url (relative to base_url)
|
|
194
|
+
params: Query parameters
|
|
195
|
+
headers: Additional headers
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
prepared_headers = self._prepare_headers(headers)
|
|
199
|
+
response = self._client.delete(url, params=params, headers=prepared_headers)
|
|
200
|
+
return self._handle_response(response)
|
|
201
|
+
except httpx.TimeoutException as e:
|
|
202
|
+
raise HttpRequestTimeoutError(str(e))
|
|
203
|
+
except httpx.ConnectError as e:
|
|
204
|
+
raise HttpConnectionError(f"Failed to connect: {e}")
|
|
205
|
+
except Exception as e:
|
|
206
|
+
raise HttpRequestError(f"DELETE request failed: {e}")
|
|
207
|
+
|
|
208
|
+
async def aget(
|
|
209
|
+
self,
|
|
210
|
+
url: str,
|
|
211
|
+
params: dict[str, Any] | None = None,
|
|
212
|
+
headers: dict[str, str] | None = None,
|
|
213
|
+
) -> HttpResponse:
|
|
214
|
+
"""
|
|
215
|
+
Perform asynchronous GET request.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
url: Request url (relative to base_url)
|
|
219
|
+
params: Query parameters
|
|
220
|
+
headers: Additional headers
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
prepared_headers = self._prepare_headers(headers)
|
|
224
|
+
response = await self._async_client.get(
|
|
225
|
+
url, params=params, headers=prepared_headers
|
|
226
|
+
)
|
|
227
|
+
return self._handle_response(response)
|
|
228
|
+
except httpx.TimeoutException as e:
|
|
229
|
+
raise HttpRequestTimeoutError(str(e))
|
|
230
|
+
except httpx.ConnectError as e:
|
|
231
|
+
raise HttpConnectionError(f"Failed to connect: {e}")
|
|
232
|
+
except Exception as e:
|
|
233
|
+
raise HttpRequestError(f"GET request failed: {e}")
|
|
234
|
+
|
|
235
|
+
async def apost(
|
|
236
|
+
self,
|
|
237
|
+
url: str,
|
|
238
|
+
data: dict[str, Any] | None = None,
|
|
239
|
+
json: dict[str, Any] | None = None,
|
|
240
|
+
params: dict[str, Any] | None = None,
|
|
241
|
+
headers: dict[str, str] | None = None,
|
|
242
|
+
) -> HttpResponse:
|
|
243
|
+
"""
|
|
244
|
+
Perform asynchronous POST request.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
url: Request url (relative to base_url)
|
|
248
|
+
data: Form data
|
|
249
|
+
json: JSON data
|
|
250
|
+
params: Query parameters
|
|
251
|
+
headers: Additional headers
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
prepared_headers = self._prepare_headers(headers)
|
|
255
|
+
response = await self._async_client.post(
|
|
256
|
+
url, data=data, json=json, params=params, headers=prepared_headers
|
|
257
|
+
)
|
|
258
|
+
return self._handle_response(response)
|
|
259
|
+
except httpx.TimeoutException as e:
|
|
260
|
+
raise HttpRequestTimeoutError(str(e))
|
|
261
|
+
except httpx.ConnectError as e:
|
|
262
|
+
raise HttpConnectionError(f"Failed to connect: {e}")
|
|
263
|
+
except Exception as e:
|
|
264
|
+
raise HttpRequestError(f"POST request failed: {e}")
|
|
265
|
+
|
|
266
|
+
async def aput(
|
|
267
|
+
self,
|
|
268
|
+
url: str,
|
|
269
|
+
data: dict[str, Any] | None = None,
|
|
270
|
+
json: dict[str, Any] | None = None,
|
|
271
|
+
params: dict[str, Any] | None = None,
|
|
272
|
+
headers: dict[str, str] | None = None,
|
|
273
|
+
) -> HttpResponse:
|
|
274
|
+
"""
|
|
275
|
+
Perform asynchronous PUT request.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
url: Request url (relative to base_url)
|
|
279
|
+
data: Form data
|
|
280
|
+
json: JSON data
|
|
281
|
+
params: Query parameters
|
|
282
|
+
headers: Additional headers
|
|
283
|
+
"""
|
|
284
|
+
try:
|
|
285
|
+
prepared_headers = self._prepare_headers(headers)
|
|
286
|
+
response = await self._async_client.put(
|
|
287
|
+
url, data=data, json=json, params=params, headers=prepared_headers
|
|
288
|
+
)
|
|
289
|
+
return self._handle_response(response)
|
|
290
|
+
except httpx.TimeoutException as e:
|
|
291
|
+
raise HttpRequestTimeoutError(str(e))
|
|
292
|
+
except httpx.ConnectError as e:
|
|
293
|
+
raise HttpConnectionError(f"Failed to connect: {e}")
|
|
294
|
+
except Exception as e:
|
|
295
|
+
raise HttpRequestError(f"PUT request failed: {e}")
|
|
296
|
+
|
|
297
|
+
async def adelete(
|
|
298
|
+
self,
|
|
299
|
+
url: str,
|
|
300
|
+
params: dict[str, Any] | None = None,
|
|
301
|
+
headers: dict[str, str] | None = None,
|
|
302
|
+
) -> HttpResponse:
|
|
303
|
+
"""
|
|
304
|
+
Perform asynchronous DELETE request.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
url: Request url (relative to base_url)
|
|
308
|
+
params: Query parameters
|
|
309
|
+
headers: Additional headers
|
|
310
|
+
"""
|
|
311
|
+
try:
|
|
312
|
+
prepared_headers = self._prepare_headers(headers)
|
|
313
|
+
response = await self._async_client.delete(
|
|
314
|
+
url, params=params, headers=prepared_headers
|
|
315
|
+
)
|
|
316
|
+
return self._handle_response(response)
|
|
317
|
+
except httpx.TimeoutException as e:
|
|
318
|
+
raise HttpRequestTimeoutError(str(e))
|
|
319
|
+
except httpx.ConnectError as e:
|
|
320
|
+
raise HttpConnectionError(f"Failed to connect: {e}")
|
|
321
|
+
except Exception as e:
|
|
322
|
+
raise HttpRequestError(f"DELETE request failed: {e}")
|
|
323
|
+
|
|
324
|
+
def close(self) -> None:
|
|
325
|
+
if self._client:
|
|
326
|
+
self._client.close()
|
|
327
|
+
|
|
328
|
+
async def aclose(self) -> None:
|
|
329
|
+
if self._async_client:
|
|
330
|
+
await self._async_client.aclose()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class HttpRequestError(Exception):
|
|
2
|
+
"""Generic exception for HTTP service errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HttpAuthenticationError(Exception):
|
|
6
|
+
"""Raised when authentication fails."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HttpRequestTimeoutError(HttpRequestError):
|
|
10
|
+
"""Raised when a request times out."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HttpConnectionError(HttpRequestError):
|
|
14
|
+
"""Raised when connection to server fails."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpRetryConfig(BaseModel):
|
|
7
|
+
"""Configuration for retry behavior."""
|
|
8
|
+
|
|
9
|
+
max_retries: int = Field(
|
|
10
|
+
default=3, ge=0, description="Maximum number of retry attempts"
|
|
11
|
+
)
|
|
12
|
+
retry_delay: float = Field(
|
|
13
|
+
default=1.0, gt=0, description="Delay between retries in seconds"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HttpResponse(BaseModel):
|
|
18
|
+
"""HTTP Response wrapper."""
|
|
19
|
+
|
|
20
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
21
|
+
|
|
22
|
+
status_code: int = Field(..., description="HTTP status code")
|
|
23
|
+
headers: dict[str, str] = Field(
|
|
24
|
+
default_factory=dict, description="HTTP Response Headers"
|
|
25
|
+
)
|
|
26
|
+
content: bytes = Field(default=b"", description="Raw Response Content")
|
|
27
|
+
url: str = Field(...)
|
|
28
|
+
|
|
29
|
+
def json_dump(self) -> Any:
|
|
30
|
+
"""Parse response content as JSON."""
|
|
31
|
+
import json
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(self.content.decode("utf-8"))
|
|
35
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
36
|
+
raise ValueError(f"Failed to parse response as JSON: {e}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from novastack_utils.retry.decorator import retry
|
|
2
|
+
from novastack_utils.retry.strategies import (
|
|
3
|
+
retry_if_exception,
|
|
4
|
+
stop_after_attempt,
|
|
5
|
+
stop_after_delay,
|
|
6
|
+
wait_exponential,
|
|
7
|
+
wait_fixed,
|
|
8
|
+
wait_random,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"retry",
|
|
13
|
+
"retry_if_exception",
|
|
14
|
+
"stop_after_attempt",
|
|
15
|
+
"stop_after_delay",
|
|
16
|
+
"wait_exponential",
|
|
17
|
+
"wait_fixed",
|
|
18
|
+
"wait_random",
|
|
19
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Callable, TypeVar
|
|
5
|
+
|
|
6
|
+
from novastack_utils.retry.strategies import (
|
|
7
|
+
retry_if_exception,
|
|
8
|
+
stop_after_attempt,
|
|
9
|
+
wait_fixed,
|
|
10
|
+
)
|
|
11
|
+
from novastack_utils.retry.types import (
|
|
12
|
+
RetryCondition,
|
|
13
|
+
RetryState,
|
|
14
|
+
StopCondition,
|
|
15
|
+
WaitStrategy,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_retry_state(
|
|
22
|
+
retry_number: int,
|
|
23
|
+
elapsed_time: float,
|
|
24
|
+
last_exception: Exception | None = None,
|
|
25
|
+
) -> RetryState:
|
|
26
|
+
return RetryState(
|
|
27
|
+
retry_number=retry_number - 1,
|
|
28
|
+
elapsed_time=elapsed_time,
|
|
29
|
+
last_exception=last_exception,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def retry(
|
|
34
|
+
stop: StopCondition | None = None,
|
|
35
|
+
when: RetryCondition | None = None,
|
|
36
|
+
wait: WaitStrategy | None = None,
|
|
37
|
+
reraise: bool = True,
|
|
38
|
+
) -> Callable[[Callable[..., T]], Callable[..., T | None]]:
|
|
39
|
+
stop_condition = stop or stop_after_attempt(3)
|
|
40
|
+
retry_condition = when or retry_if_exception()
|
|
41
|
+
wait_strategy = wait or wait_fixed(1)
|
|
42
|
+
|
|
43
|
+
def decorator(func: Callable[..., T]) -> Callable[..., T | None]:
|
|
44
|
+
if asyncio.iscoroutinefunction(func):
|
|
45
|
+
|
|
46
|
+
@functools.wraps(func)
|
|
47
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> T | None:
|
|
48
|
+
start_time = time.monotonic()
|
|
49
|
+
retry_number = 0
|
|
50
|
+
|
|
51
|
+
while True:
|
|
52
|
+
retry_number += 1
|
|
53
|
+
try:
|
|
54
|
+
return await func(*args, **kwargs)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
retry_state = _build_retry_state(
|
|
57
|
+
retry_number=retry_number,
|
|
58
|
+
elapsed_time=time.monotonic() - start_time,
|
|
59
|
+
last_exception=exc,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if not retry_condition(exc):
|
|
63
|
+
if reraise:
|
|
64
|
+
raise
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
if stop_condition(retry_state):
|
|
68
|
+
if reraise:
|
|
69
|
+
raise
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
sleep_time = wait_strategy(retry_state)
|
|
73
|
+
await asyncio.sleep(sleep_time)
|
|
74
|
+
|
|
75
|
+
return async_wrapper # type: ignore[return-value]
|
|
76
|
+
|
|
77
|
+
@functools.wraps(func)
|
|
78
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> T | None:
|
|
79
|
+
start_time = time.monotonic()
|
|
80
|
+
retry_number = 0
|
|
81
|
+
|
|
82
|
+
while True:
|
|
83
|
+
retry_number += 1
|
|
84
|
+
try:
|
|
85
|
+
return func(*args, **kwargs)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
retry_state = _build_retry_state(
|
|
88
|
+
retry_number=retry_number,
|
|
89
|
+
elapsed_time=time.monotonic() - start_time,
|
|
90
|
+
last_exception=exc,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if not retry_condition(exc):
|
|
94
|
+
if reraise:
|
|
95
|
+
raise
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
if stop_condition(retry_state):
|
|
99
|
+
if reraise:
|
|
100
|
+
raise
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
sleep_time = wait_strategy(retry_state)
|
|
104
|
+
time.sleep(sleep_time)
|
|
105
|
+
|
|
106
|
+
return sync_wrapper # type: ignore[return-value]
|
|
107
|
+
|
|
108
|
+
return decorator
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import random
|
|
2
|
+
|
|
3
|
+
from novastack_utils.retry.types import RetryState
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class _BaseRetryCondition:
|
|
7
|
+
"""Base class for retry predicates."""
|
|
8
|
+
|
|
9
|
+
def __call__(self, exception: Exception) -> bool:
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _BaseStopConditionBase:
|
|
14
|
+
"""Base class for stop conditions."""
|
|
15
|
+
|
|
16
|
+
def __call__(self, retry_state: RetryState) -> bool:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _BaseWaitStrategy:
|
|
21
|
+
"""Base class for wait strategies."""
|
|
22
|
+
|
|
23
|
+
def __call__(self, retry_state: RetryState) -> float:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class retry_if_exception(_BaseRetryCondition):
|
|
28
|
+
def __call__(self, exception: Exception) -> bool:
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class stop_after_attempt(_BaseStopConditionBase):
|
|
33
|
+
def __init__(self, max_retries: int) -> None:
|
|
34
|
+
if max_retries < 1:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Invalid max_retries '{max_retries}' Input should be: greater than or equal to 1."
|
|
37
|
+
)
|
|
38
|
+
self.max_retries = max_retries
|
|
39
|
+
|
|
40
|
+
def __call__(self, retry_state: RetryState) -> bool:
|
|
41
|
+
return retry_state.retry_number >= self.max_retries
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class stop_after_delay(_BaseStopConditionBase):
|
|
45
|
+
def __init__(self, max_delay: float) -> None:
|
|
46
|
+
if max_delay < 0:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Invalid max_delay '{max_delay}' Input should be: greater than or equal to 0."
|
|
49
|
+
)
|
|
50
|
+
self.max_delay = max_delay
|
|
51
|
+
|
|
52
|
+
def __call__(self, retry_state: RetryState) -> bool:
|
|
53
|
+
return retry_state.elapsed_time >= self.max_delay
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class wait_fixed(_BaseWaitStrategy):
|
|
57
|
+
def __init__(self, wait: float) -> None:
|
|
58
|
+
if wait < 0:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Invalid wait '{wait}' Input should be: greater than or equal to 0."
|
|
61
|
+
)
|
|
62
|
+
self.wait = wait
|
|
63
|
+
|
|
64
|
+
def __call__(self, retry_state: RetryState) -> float:
|
|
65
|
+
return self.wait
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class wait_exponential(_BaseWaitStrategy):
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
multiplier: float = 1,
|
|
72
|
+
min: float = 0,
|
|
73
|
+
max: float | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
if multiplier < 0:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Invalid multiplier '{multiplier}' Input should be: greater than or equal to 0."
|
|
78
|
+
)
|
|
79
|
+
if min < 0:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"Invalid min '{min}' Input should be: greater than or equal to 0."
|
|
82
|
+
)
|
|
83
|
+
if max is not None and max < min:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Invalid max '{max}' Input should be: greater than or equal to min."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self.multiplier = multiplier
|
|
89
|
+
self.min = min
|
|
90
|
+
self.max = max
|
|
91
|
+
|
|
92
|
+
def __call__(self, retry_state: RetryState) -> float:
|
|
93
|
+
delay = self.multiplier * (2 ** (retry_state.retry_number - 1))
|
|
94
|
+
delay = delay if delay > self.min else self.min
|
|
95
|
+
if self.max is not None and delay > self.max:
|
|
96
|
+
delay = self.max
|
|
97
|
+
return delay
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class wait_random(_BaseWaitStrategy):
|
|
101
|
+
def __init__(self, min: float, max: float) -> None:
|
|
102
|
+
if min < 0:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"Invalid min '{min}' Input should be: greater than or equal to 0."
|
|
105
|
+
)
|
|
106
|
+
if max < min:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"Invalid max '{max}' Input should be: greater than or equal to min."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self.min = min
|
|
112
|
+
self.max = max
|
|
113
|
+
|
|
114
|
+
def __call__(self, retry_state: RetryState) -> float:
|
|
115
|
+
return random.uniform(self.min, self.max)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(slots=True)
|
|
6
|
+
class RetryState:
|
|
7
|
+
retry_number: int
|
|
8
|
+
elapsed_time: float
|
|
9
|
+
last_exception: Exception | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RetryCondition(Protocol):
|
|
13
|
+
"""Protocol for retry predicates that decide whether an exception is retryable."""
|
|
14
|
+
|
|
15
|
+
def __call__(self, exception: Exception) -> bool: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StopCondition(Protocol):
|
|
19
|
+
"""Protocol for stop conditions that decide when to stop retrying."""
|
|
20
|
+
|
|
21
|
+
def __call__(self, retry_state: RetryState) -> bool: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WaitStrategy(Protocol):
|
|
25
|
+
"""Protocol for wait strategies that determine how long to wait between retries."""
|
|
26
|
+
|
|
27
|
+
def __call__(self, retry_state: RetryState) -> float: ...
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
def validate_enum(el: str, el_name: str, expected_enum) -> str:
|
|
2
|
+
allowed = {
|
|
3
|
+
getattr(expected_enum, attr) for attr in vars(expected_enum) if attr.isupper()
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if el not in allowed:
|
|
7
|
+
raise ValueError(
|
|
8
|
+
"Invalid {} '{}'. Input should be: {}.".format(el_name, el, sorted(allowed))
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
return el
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_type(el, el_name: str, expected_type: type | list[type]):
|
|
15
|
+
if type(expected_type) is not type and type(expected_type) is not list:
|
|
16
|
+
raise TypeError(
|
|
17
|
+
"Invalid expected_type type '{}'. Input should be: ['type', 'list[type]'].".format(
|
|
18
|
+
expected_type
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if type(expected_type) is list:
|
|
23
|
+
if not any(isinstance(el, t) for t in expected_type):
|
|
24
|
+
allowed = ", ".join(t.__name__ for t in expected_type)
|
|
25
|
+
raise TypeError(
|
|
26
|
+
"Invalid {} type '{}'. Input should be: [{}].".format(
|
|
27
|
+
el_name, type(el).__name__, allowed
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
return el
|
|
31
|
+
else:
|
|
32
|
+
if not isinstance(el, expected_type): # type: ignore
|
|
33
|
+
raise TypeError(
|
|
34
|
+
"Invalid {} type '{}'. Input should be: '{}'.".format(
|
|
35
|
+
el_name, type(el).__name__, expected_type.__name__
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return el
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "novastack-utils"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "This library contains utilities used across various packages."
|
|
9
|
+
authors = [{ name = "Leonardo Furnielis", email = "leonardofurnielis@outlook.com" }]
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.11,<3.14"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"httpx>=0.28.1,<1.0.0",
|
|
15
|
+
"pydantic>=2.12.5,<3.0.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=9.0.3,<10.0.0",
|
|
21
|
+
"pytest-asyncio>=1.3.0,<2.0.0",
|
|
22
|
+
"pytest-mock>=3.15.1,<4.0.0",
|
|
23
|
+
"ruff>=0.15.8,<1.0.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Repository = "https://github.com/novastack-project/novastack/tree/main/novastack-utils"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = ["novastack_utils/"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
include = ["novastack_utils/"]
|