dominusnode 1.0.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.
@@ -0,0 +1,435 @@
1
+ """HTTP client wrappers for sync and async usage.
2
+
3
+ Handles:
4
+ - User-Agent injection
5
+ - Authorization header from token manager
6
+ - Automatic retry on 429 (once)
7
+ - Status-code-to-exception mapping
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import random
14
+ import time
15
+ from typing import Any, Dict, Optional
16
+
17
+ import httpx
18
+
19
+ from .constants import USER_AGENT
20
+ from .errors import (
21
+ AuthenticationError,
22
+ AuthorizationError,
23
+ ConflictError,
24
+ DominusNodeError,
25
+ InsufficientBalanceError,
26
+ NetworkError,
27
+ NotFoundError,
28
+ RateLimitError,
29
+ ServerError,
30
+ ValidationError,
31
+ )
32
+ from .token_manager import AsyncTokenManager, TokenManager
33
+
34
+ # Maximum response body size to prevent OOM from oversized API response
35
+ MAX_RESPONSE_BYTES = 10 * 1024 * 1024 # 10MB
36
+
37
+
38
+ def _extract_error_message(response: httpx.Response) -> str:
39
+ """Try to extract an error message from the JSON body.
40
+
41
+ All extracted messages are truncated to 500 chars to prevent
42
+ unbounded error body content from propagating into exception strings.
43
+ """
44
+ try:
45
+ body = response.json()
46
+ if isinstance(body, dict):
47
+ msg = body.get("error", response.reason_phrase or "Unknown error")
48
+ return str(msg)[:500]
49
+ except Exception:
50
+ pass
51
+ fallback = response.reason_phrase or f"HTTP {response.status_code}"
52
+ return str(fallback)[:500]
53
+
54
+
55
+ def _raise_for_status(response: httpx.Response) -> None:
56
+ """Map HTTP status codes to SDK exceptions."""
57
+ status = response.status_code
58
+ if 200 <= status < 300:
59
+ return
60
+
61
+ msg = _extract_error_message(response)
62
+
63
+ if status == 400:
64
+ raise ValidationError(msg)
65
+ if status == 401:
66
+ raise AuthenticationError(msg)
67
+ if status == 402:
68
+ raise InsufficientBalanceError(msg)
69
+ if status == 403:
70
+ raise AuthorizationError(msg)
71
+ if status == 404:
72
+ raise NotFoundError(msg)
73
+ if status == 409:
74
+ raise ConflictError(msg)
75
+ if status == 429:
76
+ retry_after = 60
77
+ ra_header = response.headers.get("retry-after")
78
+ if ra_header:
79
+ try:
80
+ retry_after = int(ra_header)
81
+ except ValueError:
82
+ pass
83
+ raise RateLimitError(msg, retry_after_seconds=retry_after)
84
+ if status >= 500:
85
+ raise ServerError(msg, status_code=status)
86
+
87
+ raise DominusNodeError(msg, status_code=status)
88
+
89
+
90
+ def _parse_retry_after(response: httpx.Response) -> float:
91
+ """Parse Retry-After header, defaulting to 1 second."""
92
+ ra = response.headers.get("retry-after")
93
+ if ra:
94
+ try:
95
+ val = float(ra)
96
+ # Guard against NaN/Infinity from malformed headers
97
+ if not (val == val) or val == float("inf"): # NaN check + infinity check
98
+ return 1.0
99
+ return max(val, 0.1)
100
+ except ValueError:
101
+ pass
102
+ return 1.0
103
+
104
+
105
+ def _add_jitter(seconds: float) -> float:
106
+ """Add ±10% jitter to prevent thundering-herd on rate-limited endpoints."""
107
+ jitter = seconds * 0.2 * (random.random() - 0.5)
108
+ return max(0.1, seconds + jitter)
109
+
110
+
111
+ # ──────────────────────────────────────────────────────────────────────
112
+ # Sync HTTP Client
113
+ # ──────────────────────────────────────────────────────────────────────
114
+
115
+
116
+ class SyncHttpClient:
117
+ """Synchronous HTTP client wrapping httpx.Client."""
118
+
119
+ def __init__(
120
+ self,
121
+ base_url: str,
122
+ token_manager: TokenManager,
123
+ timeout: float = 30.0,
124
+ ) -> None:
125
+ self._base_url = base_url.rstrip("/")
126
+ self._token_manager = token_manager
127
+ self._client = httpx.Client(
128
+ base_url=self._base_url,
129
+ timeout=timeout,
130
+ headers={"User-Agent": USER_AGENT},
131
+ follow_redirects=False, # Prevent HTTPS→HTTP credential leakage
132
+ )
133
+
134
+ def close(self) -> None:
135
+ self._client.close()
136
+
137
+ def _build_headers(
138
+ self, extra: Optional[Dict[str, str]] = None, *, requires_auth: bool = True,
139
+ ) -> Dict[str, str]:
140
+ headers: Dict[str, str] = {}
141
+ # Only attach Authorization header when auth is required.
142
+ # Public endpoints (slots, waitlist) must not leak credentials.
143
+ if requires_auth:
144
+ token = self._token_manager.get_valid_token()
145
+ if token:
146
+ headers["Authorization"] = f"Bearer {token}"
147
+ if extra:
148
+ headers.update(extra)
149
+ return headers
150
+
151
+ def request(
152
+ self,
153
+ method: str,
154
+ path: str,
155
+ *,
156
+ json: Any = None,
157
+ params: Optional[Dict[str, Any]] = None,
158
+ headers: Optional[Dict[str, str]] = None,
159
+ requires_auth: bool = True,
160
+ ) -> httpx.Response:
161
+ """Execute an HTTP request with auto-retry on 401 (token refresh) and 429."""
162
+ merged_headers = self._build_headers(headers, requires_auth=requires_auth)
163
+ try:
164
+ response = self._client.request(
165
+ method,
166
+ path,
167
+ json=json,
168
+ params=params,
169
+ headers=merged_headers,
170
+ )
171
+ except httpx.HTTPError as exc:
172
+ raise NetworkError(str(exc)) from exc
173
+
174
+ # Response size limit — check Content-Length header if available
175
+ content_length = response.headers.get("content-length")
176
+ if content_length:
177
+ try:
178
+ if int(content_length) > MAX_RESPONSE_BYTES:
179
+ raise NetworkError(f"Response too large: {content_length} bytes")
180
+ except ValueError:
181
+ pass # Malformed Content-Length header — skip size check
182
+
183
+ # Also check actual body size (chunked transfer encoding may omit Content-Length)
184
+ if len(response.content) > MAX_RESPONSE_BYTES:
185
+ raise NetworkError(f"Response body too large: {len(response.content)} bytes")
186
+
187
+ # Auto-retry once on 401 after force-refreshing the token
188
+ if response.status_code == 401 and requires_auth and self._token_manager.has_refresh_token:
189
+ self._token_manager.force_refresh()
190
+ merged_headers = self._build_headers(headers, requires_auth=requires_auth)
191
+ try:
192
+ response = self._client.request(
193
+ method,
194
+ path,
195
+ json=json,
196
+ params=params,
197
+ headers=merged_headers,
198
+ )
199
+ except httpx.HTTPError as exc:
200
+ raise NetworkError(str(exc)) from exc
201
+ # Size check on 401-retry response
202
+ if len(response.content) > MAX_RESPONSE_BYTES:
203
+ raise NetworkError(f"Response body too large: {len(response.content)} bytes")
204
+
205
+ # Auto-retry once on 429
206
+ if response.status_code == 429:
207
+ wait = _parse_retry_after(response)
208
+ time.sleep(_add_jitter(min(wait, 10.0))) # Cap at 10s + jitter
209
+ # Rebuild headers to get fresh token in case it expired during wait
210
+ merged_headers = self._build_headers(headers, requires_auth=requires_auth)
211
+ try:
212
+ response = self._client.request(
213
+ method,
214
+ path,
215
+ json=json,
216
+ params=params,
217
+ headers=merged_headers,
218
+ )
219
+ except httpx.HTTPError as exc:
220
+ raise NetworkError(str(exc)) from exc
221
+ # Size check on 429-retry response
222
+ if len(response.content) > MAX_RESPONSE_BYTES:
223
+ raise NetworkError(f"Response body too large: {len(response.content)} bytes")
224
+
225
+ _raise_for_status(response)
226
+ return response
227
+
228
+ def get(
229
+ self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
230
+ ) -> Any:
231
+ resp = self.request("GET", path, params=params, requires_auth=requires_auth)
232
+ if resp.status_code == 204:
233
+ return None
234
+ return resp.json()
235
+
236
+ def post(
237
+ self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
238
+ ) -> Any:
239
+ resp = self.request("POST", path, json=json, params=params, requires_auth=requires_auth)
240
+ if resp.status_code == 204:
241
+ return None
242
+ return resp.json()
243
+
244
+ def put(
245
+ self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
246
+ ) -> Any:
247
+ resp = self.request("PUT", path, json=json, params=params, requires_auth=requires_auth)
248
+ if resp.status_code == 204:
249
+ return None
250
+ return resp.json()
251
+
252
+ def patch(
253
+ self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
254
+ ) -> Any:
255
+ resp = self.request("PATCH", path, json=json, params=params, requires_auth=requires_auth)
256
+ if resp.status_code == 204:
257
+ return None
258
+ return resp.json()
259
+
260
+ def delete(
261
+ self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
262
+ ) -> Any:
263
+ resp = self.request("DELETE", path, params=params, requires_auth=requires_auth)
264
+ if resp.status_code == 204:
265
+ return None
266
+ return resp.json()
267
+
268
+ def get_raw(
269
+ self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
270
+ ) -> httpx.Response:
271
+ """Return the raw httpx.Response (for CSV export, etc.)."""
272
+ return self.request("GET", path, params=params, requires_auth=requires_auth)
273
+
274
+
275
+ # ──────────────────────────────────────────────────────────────────────
276
+ # Async HTTP Client
277
+ # ──────────────────────────────────────────────────────────────────────
278
+
279
+
280
+ class AsyncHttpClient:
281
+ """Asynchronous HTTP client wrapping httpx.AsyncClient."""
282
+
283
+ def __init__(
284
+ self,
285
+ base_url: str,
286
+ token_manager: AsyncTokenManager,
287
+ timeout: float = 30.0,
288
+ ) -> None:
289
+ self._base_url = base_url.rstrip("/")
290
+ self._token_manager = token_manager
291
+ self._client = httpx.AsyncClient(
292
+ base_url=self._base_url,
293
+ timeout=timeout,
294
+ headers={"User-Agent": USER_AGENT},
295
+ follow_redirects=False, # Prevent HTTPS→HTTP credential leakage
296
+ )
297
+
298
+ async def close(self) -> None:
299
+ await self._client.aclose()
300
+
301
+ async def _build_headers(
302
+ self, extra: Optional[Dict[str, str]] = None, *, requires_auth: bool = True,
303
+ ) -> Dict[str, str]:
304
+ headers: Dict[str, str] = {}
305
+ # Only attach Authorization header when auth is required.
306
+ if requires_auth:
307
+ token = await self._token_manager.get_valid_token()
308
+ if token:
309
+ headers["Authorization"] = f"Bearer {token}"
310
+ if extra:
311
+ headers.update(extra)
312
+ return headers
313
+
314
+ async def request(
315
+ self,
316
+ method: str,
317
+ path: str,
318
+ *,
319
+ json: Any = None,
320
+ params: Optional[Dict[str, Any]] = None,
321
+ headers: Optional[Dict[str, str]] = None,
322
+ requires_auth: bool = True,
323
+ ) -> httpx.Response:
324
+ """Execute an HTTP request with auto-retry on 401 (token refresh) and 429."""
325
+ merged_headers = await self._build_headers(headers, requires_auth=requires_auth)
326
+ try:
327
+ response = await self._client.request(
328
+ method,
329
+ path,
330
+ json=json,
331
+ params=params,
332
+ headers=merged_headers,
333
+ )
334
+ except httpx.HTTPError as exc:
335
+ raise NetworkError(str(exc)) from exc
336
+
337
+ # Response size limit — check Content-Length header if available
338
+ content_length = response.headers.get("content-length")
339
+ if content_length:
340
+ try:
341
+ if int(content_length) > MAX_RESPONSE_BYTES:
342
+ raise NetworkError(f"Response too large: {content_length} bytes")
343
+ except ValueError:
344
+ pass # Malformed Content-Length header — skip size check
345
+
346
+ # Also check actual body size (chunked transfer encoding may omit Content-Length)
347
+ if len(response.content) > MAX_RESPONSE_BYTES:
348
+ raise NetworkError(f"Response body too large: {len(response.content)} bytes")
349
+
350
+ # Auto-retry once on 401 after force-refreshing the token
351
+ if response.status_code == 401 and requires_auth and self._token_manager.has_refresh_token:
352
+ await self._token_manager.force_refresh()
353
+ merged_headers = await self._build_headers(headers, requires_auth=requires_auth)
354
+ try:
355
+ response = await self._client.request(
356
+ method,
357
+ path,
358
+ json=json,
359
+ params=params,
360
+ headers=merged_headers,
361
+ )
362
+ except httpx.HTTPError as exc:
363
+ raise NetworkError(str(exc)) from exc
364
+ # Size check on 401-retry response
365
+ if len(response.content) > MAX_RESPONSE_BYTES:
366
+ raise NetworkError(f"Response body too large: {len(response.content)} bytes")
367
+
368
+ # Auto-retry once on 429
369
+ if response.status_code == 429:
370
+ wait = _parse_retry_after(response)
371
+ await asyncio.sleep(_add_jitter(min(wait, 10.0)))
372
+ # Rebuild headers to get fresh token in case it expired during wait
373
+ merged_headers = await self._build_headers(headers, requires_auth=requires_auth)
374
+ try:
375
+ response = await self._client.request(
376
+ method,
377
+ path,
378
+ json=json,
379
+ params=params,
380
+ headers=merged_headers,
381
+ )
382
+ except httpx.HTTPError as exc:
383
+ raise NetworkError(str(exc)) from exc
384
+ # Size check on 429-retry response
385
+ if len(response.content) > MAX_RESPONSE_BYTES:
386
+ raise NetworkError(f"Response body too large: {len(response.content)} bytes")
387
+
388
+ _raise_for_status(response)
389
+ return response
390
+
391
+ async def get(
392
+ self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
393
+ ) -> Any:
394
+ resp = await self.request("GET", path, params=params, requires_auth=requires_auth)
395
+ if resp.status_code == 204:
396
+ return None
397
+ return resp.json()
398
+
399
+ async def post(
400
+ self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
401
+ ) -> Any:
402
+ resp = await self.request("POST", path, json=json, params=params, requires_auth=requires_auth)
403
+ if resp.status_code == 204:
404
+ return None
405
+ return resp.json()
406
+
407
+ async def put(
408
+ self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
409
+ ) -> Any:
410
+ resp = await self.request("PUT", path, json=json, params=params, requires_auth=requires_auth)
411
+ if resp.status_code == 204:
412
+ return None
413
+ return resp.json()
414
+
415
+ async def patch(
416
+ self, path: str, *, json: Any = None, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
417
+ ) -> Any:
418
+ resp = await self.request("PATCH", path, json=json, params=params, requires_auth=requires_auth)
419
+ if resp.status_code == 204:
420
+ return None
421
+ return resp.json()
422
+
423
+ async def delete(
424
+ self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
425
+ ) -> Any:
426
+ resp = await self.request("DELETE", path, params=params, requires_auth=requires_auth)
427
+ if resp.status_code == 204:
428
+ return None
429
+ return resp.json()
430
+
431
+ async def get_raw(
432
+ self, path: str, *, params: Optional[Dict[str, Any]] = None, requires_auth: bool = True,
433
+ ) -> httpx.Response:
434
+ """Return the raw httpx.Response (for CSV export, etc.)."""
435
+ return await self.request("GET", path, params=params, requires_auth=requires_auth)
dominusnode/keys.py ADDED
@@ -0,0 +1,88 @@
1
+ """API key management resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+ from urllib.parse import quote
7
+
8
+ from .http_client import AsyncHttpClient, SyncHttpClient
9
+ from .types import ApiKey, CreatedApiKey
10
+
11
+
12
+ # ──────────────────────────────────────────────────────────────────────
13
+ # Sync
14
+ # ──────────────────────────────────────────────────────────────────────
15
+
16
+
17
+ class KeysResource:
18
+ """Synchronous API key operations."""
19
+
20
+ def __init__(self, http: SyncHttpClient) -> None:
21
+ self._http = http
22
+
23
+ def create(self, label: str = "Default") -> CreatedApiKey:
24
+ """Create a new API key. The raw key is only returned once."""
25
+ data = self._http.post("/api/keys", json={"label": label})
26
+ return CreatedApiKey(
27
+ key=data["key"],
28
+ id=data["id"],
29
+ prefix=data["prefix"],
30
+ label=data["label"],
31
+ created_at=str(data["created_at"]),
32
+ )
33
+
34
+ def list(self) -> List[ApiKey]:
35
+ """List all API keys for the current user."""
36
+ data = self._http.get("/api/keys")
37
+ return [
38
+ ApiKey(
39
+ id=k["id"],
40
+ prefix=k["prefix"],
41
+ label=k["label"],
42
+ created_at=str(k["created_at"]),
43
+ revoked_at=str(k["revoked_at"]) if k.get("revoked_at") else None,
44
+ )
45
+ for k in data.get("keys", [])
46
+ ]
47
+
48
+ def revoke(self, key_id: str) -> None:
49
+ """Revoke an API key by ID."""
50
+ self._http.delete(f"/api/keys/{quote(key_id, safe='')}")
51
+
52
+
53
+ # ──────────────────────────────────────────────────────────────────────
54
+ # Async
55
+ # ──────────────────────────────────────────────────────────────────────
56
+
57
+
58
+ class AsyncKeysResource:
59
+ """Asynchronous API key operations."""
60
+
61
+ def __init__(self, http: AsyncHttpClient) -> None:
62
+ self._http = http
63
+
64
+ async def create(self, label: str = "Default") -> CreatedApiKey:
65
+ data = await self._http.post("/api/keys", json={"label": label})
66
+ return CreatedApiKey(
67
+ key=data["key"],
68
+ id=data["id"],
69
+ prefix=data["prefix"],
70
+ label=data["label"],
71
+ created_at=str(data["created_at"]),
72
+ )
73
+
74
+ async def list(self) -> List[ApiKey]:
75
+ data = await self._http.get("/api/keys")
76
+ return [
77
+ ApiKey(
78
+ id=k["id"],
79
+ prefix=k["prefix"],
80
+ label=k["label"],
81
+ created_at=str(k["created_at"]),
82
+ revoked_at=str(k["revoked_at"]) if k.get("revoked_at") else None,
83
+ )
84
+ for k in data.get("keys", [])
85
+ ]
86
+
87
+ async def revoke(self, key_id: str) -> None:
88
+ await self._http.delete(f"/api/keys/{quote(key_id, safe='')}")
dominusnode/plans.py ADDED
@@ -0,0 +1,93 @@
1
+ """Plans resource -- list plans, get/change user plan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Optional
6
+
7
+ from .http_client import AsyncHttpClient, SyncHttpClient
8
+ from .types import Plan, UserPlanInfo, UserPlanUsage
9
+
10
+
11
+ def _parse_plan(p: dict) -> Plan:
12
+ return Plan(
13
+ id=p["id"],
14
+ name=p["name"],
15
+ price_per_gb_cents=p["pricePerGbCents"],
16
+ price_per_gb_usd=p["pricePerGbUsd"],
17
+ monthly_bandwidth_bytes=p.get("monthlyBandwidthBytes", 0),
18
+ monthly_bandwidth_gb=p.get("monthlyBandwidthGB"),
19
+ is_default=p.get("isDefault", False),
20
+ )
21
+
22
+
23
+ # ──────────────────────────────────────────────────────────────────────
24
+ # Sync
25
+ # ──────────────────────────────────────────────────────────────────────
26
+
27
+
28
+ class PlansResource:
29
+ """Synchronous plans operations."""
30
+
31
+ def __init__(self, http: SyncHttpClient) -> None:
32
+ self._http = http
33
+
34
+ def list(self) -> List[Plan]:
35
+ """List all available plans."""
36
+ data = self._http.get("/api/plans")
37
+ return [_parse_plan(p) for p in data.get("plans", [])]
38
+
39
+ def get_user_plan(self) -> UserPlanInfo:
40
+ """Get the current user's plan and monthly usage."""
41
+ data = self._http.get("/api/plans/user/plan")
42
+ plan = _parse_plan(data["plan"])
43
+ u = data["usage"]
44
+ usage = UserPlanUsage(
45
+ monthly_usage_bytes=u["monthlyUsageBytes"],
46
+ monthly_usage_gb=u["monthlyUsageGB"],
47
+ limit_bytes=u["limitBytes"],
48
+ limit_gb=u.get("limitGB"),
49
+ percent_used=u.get("percentUsed"),
50
+ )
51
+ return UserPlanInfo(plan=plan, usage=usage)
52
+
53
+ def change_plan(self, plan_id: str) -> Plan:
54
+ """Change the current user's plan.
55
+
56
+ Args:
57
+ plan_id: The plan ID to switch to (e.g. 'payg', 'vol100', 'vol1tb').
58
+ """
59
+ data = self._http.put("/api/plans/user/plan", json={"planId": plan_id})
60
+ return _parse_plan(data["plan"])
61
+
62
+
63
+ # ──────────────────────────────────────────────────────────────────────
64
+ # Async
65
+ # ──────────────────────────────────────────────────────────────────────
66
+
67
+
68
+ class AsyncPlansResource:
69
+ """Asynchronous plans operations."""
70
+
71
+ def __init__(self, http: AsyncHttpClient) -> None:
72
+ self._http = http
73
+
74
+ async def list(self) -> List[Plan]:
75
+ data = await self._http.get("/api/plans")
76
+ return [_parse_plan(p) for p in data.get("plans", [])]
77
+
78
+ async def get_user_plan(self) -> UserPlanInfo:
79
+ data = await self._http.get("/api/plans/user/plan")
80
+ plan = _parse_plan(data["plan"])
81
+ u = data["usage"]
82
+ usage = UserPlanUsage(
83
+ monthly_usage_bytes=u["monthlyUsageBytes"],
84
+ monthly_usage_gb=u["monthlyUsageGB"],
85
+ limit_bytes=u["limitBytes"],
86
+ limit_gb=u.get("limitGB"),
87
+ percent_used=u.get("percentUsed"),
88
+ )
89
+ return UserPlanInfo(plan=plan, usage=usage)
90
+
91
+ async def change_plan(self, plan_id: str) -> Plan:
92
+ data = await self._http.put("/api/plans/user/plan", json={"planId": plan_id})
93
+ return _parse_plan(data["plan"])