intelion-cloud 0.2.0__py3-none-any.whl
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.
- intelion_cloud/__init__.py +82 -0
- intelion_cloud/_client.py +108 -0
- intelion_cloud/_pagination.py +66 -0
- intelion_cloud/_transport.py +342 -0
- intelion_cloud/constants.py +74 -0
- intelion_cloud/exceptions.py +92 -0
- intelion_cloud/models/__init__.py +33 -0
- intelion_cloud/models/_base.py +39 -0
- intelion_cloud/models/components.py +218 -0
- intelion_cloud/models/flavors.py +44 -0
- intelion_cloud/models/servers.py +247 -0
- intelion_cloud/models/users.py +73 -0
- intelion_cloud/resources/__init__.py +17 -0
- intelion_cloud/resources/_base.py +152 -0
- intelion_cloud/resources/cloud_servers.py +293 -0
- intelion_cloud/resources/flavors.py +42 -0
- intelion_cloud/resources/os_images.py +66 -0
- intelion_cloud/resources/users.py +100 -0
- intelion_cloud-0.2.0.dist-info/METADATA +134 -0
- intelion_cloud-0.2.0.dist-info/RECORD +23 -0
- intelion_cloud-0.2.0.dist-info/WHEEL +5 -0
- intelion_cloud-0.2.0.dist-info/licenses/LICENSE +21 -0
- intelion_cloud-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Intelion Cloud Python client.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from intelion_cloud import IntelionCloud
|
|
6
|
+
|
|
7
|
+
client = IntelionCloud(token="your_api_token")
|
|
8
|
+
servers = client.cloud_servers.list()
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._client import AsyncIntelionCloud, IntelionCloud
|
|
12
|
+
from .constants import BillingPeriod, PricePlan, ServerState
|
|
13
|
+
from .constants import ServerStatus as ServerStatusEnum
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
APIError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
ConnectionError,
|
|
19
|
+
ForbiddenError,
|
|
20
|
+
IntelionCloudError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ServerError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
from .models import (
|
|
27
|
+
CPU,
|
|
28
|
+
GPU,
|
|
29
|
+
OSImage,
|
|
30
|
+
RAM,
|
|
31
|
+
SSD,
|
|
32
|
+
CloudServer,
|
|
33
|
+
DebtInfo,
|
|
34
|
+
Flavor,
|
|
35
|
+
PhysicalServer,
|
|
36
|
+
Promocode,
|
|
37
|
+
ServerStatus,
|
|
38
|
+
SoftwareAddonInstance,
|
|
39
|
+
UsageAct,
|
|
40
|
+
User,
|
|
41
|
+
WhiteIP,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__version__ = "0.2.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Clients
|
|
48
|
+
"IntelionCloud",
|
|
49
|
+
"AsyncIntelionCloud",
|
|
50
|
+
# Models
|
|
51
|
+
"CloudServer",
|
|
52
|
+
"ServerStatus",
|
|
53
|
+
"Flavor",
|
|
54
|
+
"User",
|
|
55
|
+
"GPU",
|
|
56
|
+
"CPU",
|
|
57
|
+
"RAM",
|
|
58
|
+
"SSD",
|
|
59
|
+
"OSImage",
|
|
60
|
+
"UsageAct",
|
|
61
|
+
"DebtInfo",
|
|
62
|
+
"Promocode",
|
|
63
|
+
"WhiteIP",
|
|
64
|
+
"PhysicalServer",
|
|
65
|
+
"SoftwareAddonInstance",
|
|
66
|
+
# Constants
|
|
67
|
+
"ServerStatusEnum",
|
|
68
|
+
"ServerState",
|
|
69
|
+
"PricePlan",
|
|
70
|
+
"BillingPeriod",
|
|
71
|
+
# Exceptions
|
|
72
|
+
"IntelionCloudError",
|
|
73
|
+
"APIError",
|
|
74
|
+
"AuthenticationError",
|
|
75
|
+
"ForbiddenError",
|
|
76
|
+
"NotFoundError",
|
|
77
|
+
"ConflictError",
|
|
78
|
+
"RateLimitError",
|
|
79
|
+
"ValidationError",
|
|
80
|
+
"ServerError",
|
|
81
|
+
"ConnectionError",
|
|
82
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Main client classes: IntelionCloud (sync) and AsyncIntelionCloud (async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .constants import DEFAULT_BASE_URL, DEFAULT_CONNECT_TIMEOUT, DEFAULT_TIMEOUT
|
|
8
|
+
from ._transport import AsyncTransport, SyncTransport
|
|
9
|
+
from .resources.cloud_servers import AsyncCloudServers, CloudServers
|
|
10
|
+
from .resources.flavors import AsyncFlavors, Flavors
|
|
11
|
+
from .resources.os_images import AsyncOSImages, OSImages
|
|
12
|
+
from .resources.users import AsyncUsers, Users
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IntelionCloud:
|
|
16
|
+
"""Synchronous client for the Intelion Cloud API.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
client = IntelionCloud(token="your_api_token")
|
|
21
|
+
servers = client.cloud_servers.list()
|
|
22
|
+
client.close()
|
|
23
|
+
|
|
24
|
+
Or as a context manager::
|
|
25
|
+
|
|
26
|
+
with IntelionCloud(token="your_api_token") as client:
|
|
27
|
+
servers = client.cloud_servers.list()
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
token: API authentication token (``Authorization: Token <token>``).
|
|
31
|
+
base_url: Base URL of the Intelion Cloud instance.
|
|
32
|
+
timeout: Overall request timeout in seconds.
|
|
33
|
+
connect_timeout: Connection establishment timeout in seconds.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
token: str,
|
|
39
|
+
*,
|
|
40
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
41
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
42
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._transport = SyncTransport(
|
|
45
|
+
token=token,
|
|
46
|
+
base_url=base_url,
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
connect_timeout=connect_timeout,
|
|
49
|
+
)
|
|
50
|
+
self.cloud_servers = CloudServers(self._transport)
|
|
51
|
+
self.flavors = Flavors(self._transport)
|
|
52
|
+
self.os_images = OSImages(self._transport)
|
|
53
|
+
self.users = Users(self._transport)
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Close the underlying HTTP connection pool."""
|
|
57
|
+
self._transport.close()
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> IntelionCloud:
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, *args: object) -> None:
|
|
63
|
+
self.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AsyncIntelionCloud:
|
|
67
|
+
"""Asynchronous client for the Intelion Cloud API.
|
|
68
|
+
|
|
69
|
+
Usage::
|
|
70
|
+
|
|
71
|
+
async with AsyncIntelionCloud(token="your_api_token") as client:
|
|
72
|
+
servers = await client.cloud_servers.list()
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
token: API authentication token (``Authorization: Token <token>``).
|
|
76
|
+
base_url: Base URL of the Intelion Cloud instance.
|
|
77
|
+
timeout: Overall request timeout in seconds.
|
|
78
|
+
connect_timeout: Connection establishment timeout in seconds.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
token: str,
|
|
84
|
+
*,
|
|
85
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
86
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
87
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._transport = AsyncTransport(
|
|
90
|
+
token=token,
|
|
91
|
+
base_url=base_url,
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
connect_timeout=connect_timeout,
|
|
94
|
+
)
|
|
95
|
+
self.cloud_servers = AsyncCloudServers(self._transport)
|
|
96
|
+
self.flavors = AsyncFlavors(self._transport)
|
|
97
|
+
self.os_images = AsyncOSImages(self._transport)
|
|
98
|
+
self.users = AsyncUsers(self._transport)
|
|
99
|
+
|
|
100
|
+
async def close(self) -> None:
|
|
101
|
+
"""Close the underlying async HTTP connection pool."""
|
|
102
|
+
await self._transport.close()
|
|
103
|
+
|
|
104
|
+
async def __aenter__(self) -> AsyncIntelionCloud:
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
async def __aexit__(self, *args: object) -> None:
|
|
108
|
+
await self.close()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Pagination helpers for DRF paginated responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar
|
|
7
|
+
from urllib.parse import parse_qs, urlparse
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PaginatedResponse(Generic[T]):
|
|
14
|
+
"""A single page of results from the API."""
|
|
15
|
+
|
|
16
|
+
count: int
|
|
17
|
+
results: List[T]
|
|
18
|
+
next_url: Optional[str] = None
|
|
19
|
+
previous_url: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def has_next(self) -> bool:
|
|
23
|
+
return self.next_url is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_paginated(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
27
|
+
"""Extract pagination metadata from a DRF response.
|
|
28
|
+
|
|
29
|
+
Returns dict with 'count', 'next', 'previous', 'results'.
|
|
30
|
+
If the response is not paginated (plain list), wraps it.
|
|
31
|
+
"""
|
|
32
|
+
if isinstance(data, list):
|
|
33
|
+
return {"count": len(data), "next": None, "previous": None, "results": data}
|
|
34
|
+
return {
|
|
35
|
+
"count": data.get("count", 0),
|
|
36
|
+
"next": data.get("next"),
|
|
37
|
+
"previous": data.get("previous"),
|
|
38
|
+
"results": data.get("results", []),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_next_page(url: Optional[str]) -> Optional[Tuple[str, Dict[str, str]]]:
|
|
43
|
+
"""Convert an absolute next/previous URL to (path, params) for the transport.
|
|
44
|
+
|
|
45
|
+
Returns None if URL is None, otherwise (relative_path, query_params).
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
'https://intelion.cloud/api/v2/flavors/?page=2'
|
|
49
|
+
-> ('flavors/', {'page': '2'})
|
|
50
|
+
"""
|
|
51
|
+
if url is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
parsed = urlparse(url)
|
|
55
|
+
|
|
56
|
+
# Extract relative path after /api/v2/
|
|
57
|
+
path = parsed.path
|
|
58
|
+
marker = "/api/v2/"
|
|
59
|
+
idx = path.find(marker)
|
|
60
|
+
if idx >= 0:
|
|
61
|
+
path = path[idx + len(marker) :]
|
|
62
|
+
|
|
63
|
+
# Parse query string into flat dict (take first value of each key)
|
|
64
|
+
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
|
65
|
+
|
|
66
|
+
return path, params
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""HTTP transport layer with retry logic, error mapping, and logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .constants import DEFAULT_BASE_URL, DEFAULT_CONNECT_TIMEOUT, DEFAULT_TIMEOUT
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
APIError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
ConnectionError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
ForbiddenError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
ServerError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("intelion_cloud")
|
|
25
|
+
|
|
26
|
+
_IDEMPOTENT_METHODS = frozenset({"GET", "PUT", "PATCH", "DELETE"})
|
|
27
|
+
|
|
28
|
+
# Retry configuration
|
|
29
|
+
_MAX_RATE_LIMIT_RETRIES = 3
|
|
30
|
+
_MAX_SERVER_ERROR_RETRIES = 1
|
|
31
|
+
_MAX_CONNECTION_RETRIES = 2
|
|
32
|
+
_RATE_LIMIT_BASE_DELAY = 1.0
|
|
33
|
+
_SERVER_ERROR_DELAY = 1.0
|
|
34
|
+
_CONNECTION_BASE_DELAY = 0.5
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_api_url(base_url: str) -> str:
|
|
38
|
+
return base_url.rstrip("/") + "/api/v2/"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
42
|
+
"""Map HTTP error responses to typed exceptions."""
|
|
43
|
+
status = response.status_code
|
|
44
|
+
if status < 400:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
body = response.json()
|
|
49
|
+
except Exception:
|
|
50
|
+
body = response.text
|
|
51
|
+
|
|
52
|
+
message = _extract_message(body, status)
|
|
53
|
+
|
|
54
|
+
if status == 400:
|
|
55
|
+
field_errors = body if isinstance(body, dict) else {}
|
|
56
|
+
raise ValidationError(
|
|
57
|
+
message,
|
|
58
|
+
status_code=status,
|
|
59
|
+
response_body=body,
|
|
60
|
+
field_errors=field_errors,
|
|
61
|
+
)
|
|
62
|
+
if status == 401:
|
|
63
|
+
raise AuthenticationError(message, status_code=status, response_body=body)
|
|
64
|
+
if status == 403:
|
|
65
|
+
raise ForbiddenError(message, status_code=status, response_body=body)
|
|
66
|
+
if status == 404:
|
|
67
|
+
raise NotFoundError(message, status_code=status, response_body=body)
|
|
68
|
+
if status == 409:
|
|
69
|
+
raise ConflictError(message, status_code=status, response_body=body)
|
|
70
|
+
if status == 429:
|
|
71
|
+
retry_after = _parse_retry_after(response)
|
|
72
|
+
raise RateLimitError(
|
|
73
|
+
message,
|
|
74
|
+
status_code=status,
|
|
75
|
+
response_body=body,
|
|
76
|
+
retry_after=retry_after,
|
|
77
|
+
)
|
|
78
|
+
if status >= 500:
|
|
79
|
+
raise ServerError(message, status_code=status, response_body=body)
|
|
80
|
+
|
|
81
|
+
raise APIError(message, status_code=status, response_body=body)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_message(body: Any, status_code: int) -> str:
|
|
85
|
+
if isinstance(body, dict):
|
|
86
|
+
for key in ("detail", "message", "error", "non_field_errors"):
|
|
87
|
+
if key in body:
|
|
88
|
+
val = body[key]
|
|
89
|
+
if isinstance(val, list):
|
|
90
|
+
return "; ".join(str(v) for v in val)
|
|
91
|
+
return str(val)
|
|
92
|
+
if isinstance(body, str) and body:
|
|
93
|
+
return body
|
|
94
|
+
return f"API error {status_code}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_retry_after(response: httpx.Response) -> Optional[float]:
|
|
98
|
+
header = response.headers.get("retry-after")
|
|
99
|
+
if header is None:
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
return float(header)
|
|
103
|
+
except (ValueError, TypeError):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _should_retry_rate_limit(attempt: int) -> bool:
|
|
108
|
+
return attempt < _MAX_RATE_LIMIT_RETRIES
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _should_retry_server_error(method: str, attempt: int) -> bool:
|
|
112
|
+
return attempt < _MAX_SERVER_ERROR_RETRIES and method in _IDEMPOTENT_METHODS
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _should_retry_connection(method: str, attempt: int) -> bool:
|
|
116
|
+
return attempt < _MAX_CONNECTION_RETRIES and method in _IDEMPOTENT_METHODS
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _rate_limit_delay(attempt: int, retry_after: Optional[float]) -> float:
|
|
120
|
+
if retry_after is not None and retry_after > 0:
|
|
121
|
+
return min(retry_after, 30.0)
|
|
122
|
+
return _RATE_LIMIT_BASE_DELAY * (2**attempt)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class SyncTransport:
|
|
126
|
+
"""Synchronous HTTP transport using httpx.Client."""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
token: str,
|
|
131
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
132
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
133
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._client = httpx.Client(
|
|
136
|
+
base_url=_build_api_url(base_url),
|
|
137
|
+
headers={
|
|
138
|
+
"Authorization": f"Token {token}",
|
|
139
|
+
"Accept": "application/json",
|
|
140
|
+
},
|
|
141
|
+
timeout=httpx.Timeout(timeout, connect=connect_timeout),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def request(
|
|
145
|
+
self,
|
|
146
|
+
method: str,
|
|
147
|
+
path: str,
|
|
148
|
+
*,
|
|
149
|
+
json: Optional[Dict[str, Any]] = None,
|
|
150
|
+
params: Optional[Dict[str, Any]] = None,
|
|
151
|
+
) -> httpx.Response:
|
|
152
|
+
attempt = 0
|
|
153
|
+
while True:
|
|
154
|
+
start = time.monotonic()
|
|
155
|
+
try:
|
|
156
|
+
response = self._client.request(
|
|
157
|
+
method,
|
|
158
|
+
path,
|
|
159
|
+
json=json,
|
|
160
|
+
params=params,
|
|
161
|
+
)
|
|
162
|
+
duration = time.monotonic() - start
|
|
163
|
+
logger.debug(
|
|
164
|
+
"%s %s -> %d (%.2fs)",
|
|
165
|
+
method,
|
|
166
|
+
path,
|
|
167
|
+
response.status_code,
|
|
168
|
+
duration,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
_raise_for_status(response)
|
|
173
|
+
except RateLimitError as exc:
|
|
174
|
+
if _should_retry_rate_limit(attempt):
|
|
175
|
+
delay = _rate_limit_delay(attempt, exc.retry_after)
|
|
176
|
+
logger.warning(
|
|
177
|
+
"Rate limited, retry %d/%d in %.1fs",
|
|
178
|
+
attempt + 1,
|
|
179
|
+
_MAX_RATE_LIMIT_RETRIES,
|
|
180
|
+
delay,
|
|
181
|
+
)
|
|
182
|
+
time.sleep(delay)
|
|
183
|
+
attempt += 1
|
|
184
|
+
continue
|
|
185
|
+
raise
|
|
186
|
+
except ServerError:
|
|
187
|
+
if _should_retry_server_error(method, attempt):
|
|
188
|
+
logger.warning(
|
|
189
|
+
"Server error %d, retry %d/%d in %.1fs",
|
|
190
|
+
response.status_code,
|
|
191
|
+
attempt + 1,
|
|
192
|
+
_MAX_SERVER_ERROR_RETRIES,
|
|
193
|
+
_SERVER_ERROR_DELAY,
|
|
194
|
+
)
|
|
195
|
+
time.sleep(_SERVER_ERROR_DELAY)
|
|
196
|
+
attempt += 1
|
|
197
|
+
continue
|
|
198
|
+
raise
|
|
199
|
+
|
|
200
|
+
return response
|
|
201
|
+
|
|
202
|
+
except (httpx.ConnectError, httpx.ReadError, httpx.WriteError) as exc:
|
|
203
|
+
if _should_retry_connection(method, attempt):
|
|
204
|
+
delay = _CONNECTION_BASE_DELAY * (2**attempt)
|
|
205
|
+
logger.warning(
|
|
206
|
+
"Connection error (%s), retry %d/%d in %.1fs",
|
|
207
|
+
type(exc).__name__,
|
|
208
|
+
attempt + 1,
|
|
209
|
+
_MAX_CONNECTION_RETRIES,
|
|
210
|
+
delay,
|
|
211
|
+
)
|
|
212
|
+
time.sleep(delay)
|
|
213
|
+
attempt += 1
|
|
214
|
+
continue
|
|
215
|
+
raise ConnectionError(str(exc)) from exc
|
|
216
|
+
except httpx.TimeoutException as exc:
|
|
217
|
+
if method == "GET" and attempt < _MAX_CONNECTION_RETRIES:
|
|
218
|
+
delay = _RATE_LIMIT_BASE_DELAY * (2**attempt)
|
|
219
|
+
logger.warning(
|
|
220
|
+
"Timeout on GET, retry %d/%d in %.1fs",
|
|
221
|
+
attempt + 1,
|
|
222
|
+
_MAX_CONNECTION_RETRIES,
|
|
223
|
+
delay,
|
|
224
|
+
)
|
|
225
|
+
time.sleep(delay)
|
|
226
|
+
attempt += 1
|
|
227
|
+
continue
|
|
228
|
+
raise ConnectionError(str(exc)) from exc
|
|
229
|
+
|
|
230
|
+
def close(self) -> None:
|
|
231
|
+
self._client.close()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class AsyncTransport:
|
|
235
|
+
"""Asynchronous HTTP transport using httpx.AsyncClient."""
|
|
236
|
+
|
|
237
|
+
def __init__(
|
|
238
|
+
self,
|
|
239
|
+
token: str,
|
|
240
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
241
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
242
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
243
|
+
) -> None:
|
|
244
|
+
self._client = httpx.AsyncClient(
|
|
245
|
+
base_url=_build_api_url(base_url),
|
|
246
|
+
headers={
|
|
247
|
+
"Authorization": f"Token {token}",
|
|
248
|
+
"Accept": "application/json",
|
|
249
|
+
},
|
|
250
|
+
timeout=httpx.Timeout(timeout, connect=connect_timeout),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
async def request(
|
|
254
|
+
self,
|
|
255
|
+
method: str,
|
|
256
|
+
path: str,
|
|
257
|
+
*,
|
|
258
|
+
json: Optional[Dict[str, Any]] = None,
|
|
259
|
+
params: Optional[Dict[str, Any]] = None,
|
|
260
|
+
) -> httpx.Response:
|
|
261
|
+
import asyncio
|
|
262
|
+
|
|
263
|
+
attempt = 0
|
|
264
|
+
while True:
|
|
265
|
+
start = time.monotonic()
|
|
266
|
+
try:
|
|
267
|
+
response = await self._client.request(
|
|
268
|
+
method,
|
|
269
|
+
path,
|
|
270
|
+
json=json,
|
|
271
|
+
params=params,
|
|
272
|
+
)
|
|
273
|
+
duration = time.monotonic() - start
|
|
274
|
+
logger.debug(
|
|
275
|
+
"%s %s -> %d (%.2fs)",
|
|
276
|
+
method,
|
|
277
|
+
path,
|
|
278
|
+
response.status_code,
|
|
279
|
+
duration,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
_raise_for_status(response)
|
|
284
|
+
except RateLimitError as exc:
|
|
285
|
+
if _should_retry_rate_limit(attempt):
|
|
286
|
+
delay = _rate_limit_delay(attempt, exc.retry_after)
|
|
287
|
+
logger.warning(
|
|
288
|
+
"Rate limited, retry %d/%d in %.1fs",
|
|
289
|
+
attempt + 1,
|
|
290
|
+
_MAX_RATE_LIMIT_RETRIES,
|
|
291
|
+
delay,
|
|
292
|
+
)
|
|
293
|
+
await asyncio.sleep(delay)
|
|
294
|
+
attempt += 1
|
|
295
|
+
continue
|
|
296
|
+
raise
|
|
297
|
+
except ServerError:
|
|
298
|
+
if _should_retry_server_error(method, attempt):
|
|
299
|
+
logger.warning(
|
|
300
|
+
"Server error %d, retry %d/%d in %.1fs",
|
|
301
|
+
response.status_code,
|
|
302
|
+
attempt + 1,
|
|
303
|
+
_MAX_SERVER_ERROR_RETRIES,
|
|
304
|
+
_SERVER_ERROR_DELAY,
|
|
305
|
+
)
|
|
306
|
+
await asyncio.sleep(_SERVER_ERROR_DELAY)
|
|
307
|
+
attempt += 1
|
|
308
|
+
continue
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
return response
|
|
312
|
+
|
|
313
|
+
except (httpx.ConnectError, httpx.ReadError, httpx.WriteError) as exc:
|
|
314
|
+
if _should_retry_connection(method, attempt):
|
|
315
|
+
delay = _CONNECTION_BASE_DELAY * (2**attempt)
|
|
316
|
+
logger.warning(
|
|
317
|
+
"Connection error (%s), retry %d/%d in %.1fs",
|
|
318
|
+
type(exc).__name__,
|
|
319
|
+
attempt + 1,
|
|
320
|
+
_MAX_CONNECTION_RETRIES,
|
|
321
|
+
delay,
|
|
322
|
+
)
|
|
323
|
+
await asyncio.sleep(delay)
|
|
324
|
+
attempt += 1
|
|
325
|
+
continue
|
|
326
|
+
raise ConnectionError(str(exc)) from exc
|
|
327
|
+
except httpx.TimeoutException as exc:
|
|
328
|
+
if method == "GET" and attempt < _MAX_CONNECTION_RETRIES:
|
|
329
|
+
delay = _RATE_LIMIT_BASE_DELAY * (2**attempt)
|
|
330
|
+
logger.warning(
|
|
331
|
+
"Timeout on GET, retry %d/%d in %.1fs",
|
|
332
|
+
attempt + 1,
|
|
333
|
+
_MAX_CONNECTION_RETRIES,
|
|
334
|
+
delay,
|
|
335
|
+
)
|
|
336
|
+
await asyncio.sleep(delay)
|
|
337
|
+
attempt += 1
|
|
338
|
+
continue
|
|
339
|
+
raise ConnectionError(str(exc)) from exc
|
|
340
|
+
|
|
341
|
+
async def close(self) -> None:
|
|
342
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Enumerations and constants matching the Intelion Cloud API."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ServerStatus",
|
|
7
|
+
"ServerState",
|
|
8
|
+
"PricePlan",
|
|
9
|
+
"BillingPeriod",
|
|
10
|
+
"OSType",
|
|
11
|
+
"DEFAULT_BASE_URL",
|
|
12
|
+
"DEFAULT_TIMEOUT",
|
|
13
|
+
"DEFAULT_CONNECT_TIMEOUT",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://intelion.cloud"
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
DEFAULT_CONNECT_TIMEOUT = 10.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ServerStatus(IntEnum):
|
|
22
|
+
"""Server status codes returned by the API."""
|
|
23
|
+
|
|
24
|
+
ERROR = -4
|
|
25
|
+
DELETED = -3
|
|
26
|
+
REQUESTED = -2
|
|
27
|
+
PAUSED = -1
|
|
28
|
+
PAUSING = 0
|
|
29
|
+
START = 1
|
|
30
|
+
ACTIVE = 2
|
|
31
|
+
PREPARING = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ServerState(IntEnum):
|
|
35
|
+
"""Server state codes indicating current operation."""
|
|
36
|
+
|
|
37
|
+
IDLE = 0
|
|
38
|
+
STARTING = 100
|
|
39
|
+
SHELVING = 200
|
|
40
|
+
MIGRATING_SHELVING = 301
|
|
41
|
+
MIGRATING_SNAPSHOTTING = 302
|
|
42
|
+
MIGRATING_DELETING = 303
|
|
43
|
+
MIGRATING_CREATING = 304
|
|
44
|
+
CLONING = 400
|
|
45
|
+
QUEUED = 500
|
|
46
|
+
AWAITING_PASSWORD = 600
|
|
47
|
+
MAINTENANCE = 700
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PricePlan(IntEnum):
|
|
51
|
+
"""Billing plan options for server configurations."""
|
|
52
|
+
|
|
53
|
+
POSTPAID_QUARTER = -3
|
|
54
|
+
POSTPAID_MONTHLY = -1
|
|
55
|
+
HOURLY = 0
|
|
56
|
+
MONTHLY = 1
|
|
57
|
+
QUARTERLY = 3
|
|
58
|
+
SEMIANNUAL = 6
|
|
59
|
+
ANNUAL = 12
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BillingPeriod(IntEnum):
|
|
63
|
+
"""Billing period types used in usage acts."""
|
|
64
|
+
|
|
65
|
+
HOURLY = 0
|
|
66
|
+
MONTHLY = 30
|
|
67
|
+
MONTHLY_ALIGNED = 31
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class OSType:
|
|
71
|
+
"""Operating system type identifiers."""
|
|
72
|
+
|
|
73
|
+
WINDOWS = "win"
|
|
74
|
+
LINUX = "lin"
|