irusdk 0.1.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.
- irusdk/__about__.py +2 -0
- irusdk/__init__.py +35 -0
- irusdk/_core/__init__.py +0 -0
- irusdk/_core/config.py +52 -0
- irusdk/_core/errors.py +155 -0
- irusdk/_core/pagination.py +247 -0
- irusdk/_core/ratelimit.py +81 -0
- irusdk/_core/retry.py +107 -0
- irusdk/_core/spec.py +98 -0
- irusdk/_core/urls.py +56 -0
- irusdk/_endpoints/__init__.py +0 -0
- irusdk/_endpoints/blueprints.py +86 -0
- irusdk/_endpoints/devices.py +87 -0
- irusdk/_endpoints/tags.py +38 -0
- irusdk/_endpoints/users.py +47 -0
- irusdk/_transport/__init__.py +0 -0
- irusdk/_transport/_common.py +181 -0
- irusdk/_transport/async_transport.py +243 -0
- irusdk/_transport/sync_transport.py +225 -0
- irusdk/cli/__init__.py +44 -0
- irusdk/cli/_console.py +114 -0
- irusdk/cli/_helpers.py +60 -0
- irusdk/cli/blueprints.py +66 -0
- irusdk/cli/devices.py +88 -0
- irusdk/cli/tags.py +57 -0
- irusdk/cli/users.py +55 -0
- irusdk/client.py +154 -0
- irusdk/models/__init__.py +35 -0
- irusdk/models/base.py +63 -0
- irusdk/models/blueprints.py +76 -0
- irusdk/models/common.py +31 -0
- irusdk/models/devices.py +115 -0
- irusdk/models/library.py +24 -0
- irusdk/models/tags.py +15 -0
- irusdk/models/users.py +51 -0
- irusdk/py.typed +0 -0
- irusdk/services/__init__.py +28 -0
- irusdk/services/blueprints.py +209 -0
- irusdk/services/devices.py +220 -0
- irusdk/services/tags.py +96 -0
- irusdk/services/users.py +117 -0
- irusdk-0.1.0.dist-info/METADATA +199 -0
- irusdk-0.1.0.dist-info/RECORD +46 -0
- irusdk-0.1.0.dist-info/WHEEL +4 -0
- irusdk-0.1.0.dist-info/entry_points.txt +2 -0
- irusdk-0.1.0.dist-info/licenses/LICENSE +201 -0
irusdk/__about__.py
ADDED
irusdk/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""A Python SDK for the Iru (formerly Kandji) Endpoint Management API."""
|
|
2
|
+
|
|
3
|
+
from .__about__ import __title__, __version__
|
|
4
|
+
from ._core.config import IruConfig
|
|
5
|
+
from ._core.errors import (
|
|
6
|
+
APIResponseError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ConfigurationError,
|
|
9
|
+
IruError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
PaginationError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
ServerError,
|
|
14
|
+
)
|
|
15
|
+
from ._core.pagination import Page
|
|
16
|
+
from ._core.urls import Region
|
|
17
|
+
from .client import AsyncIruClient, IruClient
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"APIResponseError",
|
|
21
|
+
"AsyncIruClient",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"ConfigurationError",
|
|
24
|
+
"IruClient",
|
|
25
|
+
"IruConfig",
|
|
26
|
+
"IruError",
|
|
27
|
+
"NotFoundError",
|
|
28
|
+
"Page",
|
|
29
|
+
"PaginationError",
|
|
30
|
+
"RateLimitError",
|
|
31
|
+
"Region",
|
|
32
|
+
"ServerError",
|
|
33
|
+
"__title__",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
irusdk/_core/__init__.py
ADDED
|
File without changes
|
irusdk/_core/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Client-wide tuning knobs."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from ..__about__ import __version__
|
|
9
|
+
|
|
10
|
+
# Iru's published tenant limits. The defaults below sit under these deliberately:
|
|
11
|
+
# retries and redirects count against the vendor's tally but not always against ours.
|
|
12
|
+
API_LIMIT_PER_SECOND = 50
|
|
13
|
+
API_LIMIT_PER_HOUR = 10_000
|
|
14
|
+
|
|
15
|
+
DEFAULT_USER_AGENT = (
|
|
16
|
+
f"irusdk/{__version__} "
|
|
17
|
+
f"{platform.system()}/{platform.release()} "
|
|
18
|
+
f"Python/{sys.version_info.major}.{sys.version_info.minor}"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IruConfig(BaseModel):
|
|
23
|
+
"""
|
|
24
|
+
Tuning for transport, concurrency, rate limiting, and retries.
|
|
25
|
+
|
|
26
|
+
:ivar timeout: Per-request timeout in seconds.
|
|
27
|
+
:ivar max_concurrency: Ceiling on in-flight requests, used for page prefetch and the httpx
|
|
28
|
+
connection pool.
|
|
29
|
+
:ivar requests_per_second: Client-side throttle. Defaults below Iru's published 50/sec.
|
|
30
|
+
:ivar requests_per_hour: Client-side hourly budget. Defaults below Iru's published 10,000/hr.
|
|
31
|
+
:ivar max_retries: Retry attempts after the initial request, for 429 and 5xx responses.
|
|
32
|
+
:ivar backoff_factor: Base for the exponential backoff between retries, in seconds.
|
|
33
|
+
:ivar max_backoff: Ceiling on any single backoff sleep, in seconds.
|
|
34
|
+
:ivar max_pages: Safety valve for endpoints that return no total, guarding against an
|
|
35
|
+
endpoint that ignores its offset parameter and paginates forever.
|
|
36
|
+
:ivar user_agent: The ``User-Agent`` header sent on every request.
|
|
37
|
+
:ivar verify: TLS verification. ``True`` uses the OS trust store via truststore; a string is
|
|
38
|
+
treated as a path to a CA bundle.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
|
|
43
|
+
timeout: float = Field(default=30.0, gt=0)
|
|
44
|
+
max_concurrency: int = Field(default=5, ge=1, le=50)
|
|
45
|
+
requests_per_second: float = Field(default=45.0, gt=0)
|
|
46
|
+
requests_per_hour: int = Field(default=9_500, gt=0)
|
|
47
|
+
max_retries: int = Field(default=3, ge=0)
|
|
48
|
+
backoff_factor: float = Field(default=0.5, gt=0)
|
|
49
|
+
max_backoff: float = Field(default=60.0, gt=0)
|
|
50
|
+
max_pages: int = Field(default=10_000, ge=1)
|
|
51
|
+
user_agent: str = DEFAULT_USER_AGENT
|
|
52
|
+
verify: bool | str = True
|
irusdk/_core/errors.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The irusdk exception hierarchy and the shared status-code mapping."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping
|
|
4
|
+
|
|
5
|
+
# Status codes that indicate the tenant's request quota is exhausted.
|
|
6
|
+
RATE_LIMIT_STATUS = 429
|
|
7
|
+
|
|
8
|
+
# Keys pulled from an error body, in priority order. The Iru API is inconsistent
|
|
9
|
+
# about which one it uses.
|
|
10
|
+
_ERROR_BODY_KEYS = ("detail", "error", "errors", "message")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IruError(Exception):
|
|
14
|
+
"""
|
|
15
|
+
Base exception for every error raised by this SDK.
|
|
16
|
+
|
|
17
|
+
Carries arbitrary keyword context (``status_code=401``, ``url=...``) and renders it into the
|
|
18
|
+
formatted message as ``message (key1: val1 | key2: val2)``. Each keyword is also set as an
|
|
19
|
+
instance attribute so callers can branch on it.
|
|
20
|
+
|
|
21
|
+
:param message: The human-readable error message. Falls back to :attr:`default_message`.
|
|
22
|
+
:type message: str | None
|
|
23
|
+
:param kwargs: Arbitrary context attached to the exception.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
default_message = "An error occurred"
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str | None = None, **kwargs: Any) -> None:
|
|
29
|
+
self.message = message or self.default_message
|
|
30
|
+
self.context = kwargs
|
|
31
|
+
for key, value in kwargs.items():
|
|
32
|
+
if not hasattr(self, key):
|
|
33
|
+
setattr(self, key, value)
|
|
34
|
+
self.formatted_message = self.format_message()
|
|
35
|
+
super().__init__(self.formatted_message)
|
|
36
|
+
|
|
37
|
+
def format_message(self) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Render the message with its context appended.
|
|
40
|
+
|
|
41
|
+
:return: The message, followed by ``(key: value | ...)`` when context is present.
|
|
42
|
+
:rtype: str
|
|
43
|
+
"""
|
|
44
|
+
details = " | ".join(f"{k}: {v}" for k, v in self.context.items() if v is not None)
|
|
45
|
+
return f"{self.message} ({details})" if details else self.message
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
return self.formatted_message
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConfigurationError(IruError):
|
|
52
|
+
"""Raised when the client is constructed with invalid or missing configuration."""
|
|
53
|
+
|
|
54
|
+
default_message = "Invalid client configuration"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class APIResponseError(IruError):
|
|
58
|
+
"""Raised when the API returns an unsuccessful status code."""
|
|
59
|
+
|
|
60
|
+
default_message = "The Iru API returned an error"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AuthenticationError(APIResponseError):
|
|
64
|
+
"""Raised on ``401`` or ``403`` — the API token is missing, invalid, or lacks permission."""
|
|
65
|
+
|
|
66
|
+
default_message = "Authentication failed"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class NotFoundError(APIResponseError):
|
|
70
|
+
"""Raised on ``404`` — the requested resource does not exist."""
|
|
71
|
+
|
|
72
|
+
default_message = "Requested resource was not found"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RateLimitError(APIResponseError):
|
|
76
|
+
"""
|
|
77
|
+
Raised on ``429`` once the retry policy has given up.
|
|
78
|
+
|
|
79
|
+
Iru enforces 50 requests/second and 10,000 requests/hour per tenant. Seeing this means the
|
|
80
|
+
client exhausted its retries; ``retry_after`` carries the server's hint when one was sent.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
default_message = "Rate limit exceeded"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ServerError(APIResponseError):
|
|
87
|
+
"""Raised on ``5xx`` — the API failed to process an otherwise valid request."""
|
|
88
|
+
|
|
89
|
+
default_message = "The Iru API encountered a server error"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PaginationError(IruError):
|
|
93
|
+
"""
|
|
94
|
+
Raised when pagination cannot terminate safely.
|
|
95
|
+
|
|
96
|
+
Guards against an endpoint that ignores its offset parameter, which would otherwise loop
|
|
97
|
+
forever re-fetching the first page.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
default_message = "Pagination did not terminate"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_error_detail(body: Any) -> str | None:
|
|
104
|
+
"""
|
|
105
|
+
Pull a human-readable detail string out of an error response body.
|
|
106
|
+
|
|
107
|
+
:param body: The decoded JSON body, or ``None`` when the body was not JSON.
|
|
108
|
+
:return: The first populated known error key, or ``None``.
|
|
109
|
+
:rtype: str | None
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(body, Mapping):
|
|
112
|
+
return None
|
|
113
|
+
for key in _ERROR_BODY_KEYS:
|
|
114
|
+
if value := body.get(key):
|
|
115
|
+
return str(value)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def raise_for_response(
|
|
120
|
+
status_code: int,
|
|
121
|
+
*,
|
|
122
|
+
url: str | None = None,
|
|
123
|
+
body: Any = None,
|
|
124
|
+
retry_after: float | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Map an HTTP status code onto the exception hierarchy. A 2xx status is a no-op.
|
|
128
|
+
|
|
129
|
+
This is the single status handler shared by both transports, so the sync and async clients
|
|
130
|
+
cannot diverge in what they raise.
|
|
131
|
+
|
|
132
|
+
:param status_code: The HTTP status code of the response.
|
|
133
|
+
:type status_code: int
|
|
134
|
+
:param url: The request URL, attached to the exception as context.
|
|
135
|
+
:type url: str | None
|
|
136
|
+
:param body: The decoded JSON body, used to extract an error detail.
|
|
137
|
+
:param retry_after: Seconds the server asked the client to wait, for ``429`` responses.
|
|
138
|
+
:type retry_after: float | None
|
|
139
|
+
:raises APIResponseError: Or one of its subclasses, for any non-2xx status.
|
|
140
|
+
"""
|
|
141
|
+
if 200 <= status_code < 300:
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
detail = extract_error_detail(body)
|
|
145
|
+
context: dict[str, Any] = {"status_code": status_code, "url": url, "detail": detail}
|
|
146
|
+
|
|
147
|
+
if status_code in (401, 403):
|
|
148
|
+
raise AuthenticationError(**context)
|
|
149
|
+
if status_code == 404:
|
|
150
|
+
raise NotFoundError(**context)
|
|
151
|
+
if status_code == RATE_LIMIT_STATUS:
|
|
152
|
+
raise RateLimitError(retry_after=retry_after, **context)
|
|
153
|
+
if 500 <= status_code < 600:
|
|
154
|
+
raise ServerError(**context)
|
|
155
|
+
raise APIResponseError(**context)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Pagination strategies.
|
|
2
|
+
|
|
3
|
+
The Iru API pages several different ways, so each strategy owns the "what are the next page's
|
|
4
|
+
parameters, are we done, and can the remaining pages be fetched at once" logic. The strategies are
|
|
5
|
+
pure — no I/O — and both transports consume them unchanged.
|
|
6
|
+
|
|
7
|
+
Envelope shapes in the wild:
|
|
8
|
+
|
|
9
|
+
=============================================== ================== ==========================
|
|
10
|
+
Envelope Parameters Endpoints
|
|
11
|
+
=============================================== ================== ==========================
|
|
12
|
+
bare JSON array ``limit``/``offset`` ``/devices``
|
|
13
|
+
``{count, next, previous, results}`` ``limit``/``offset`` blueprints, library activity
|
|
14
|
+
``{count, next, previous, results}`` ``page`` custom apps, scripts, ADE
|
|
15
|
+
``{offset, limit, total, cursor, data}`` ``limit``/``offset`` ``/prism/*``
|
|
16
|
+
``{total, page, size, results}`` ``page``/``size`` vulnerability management
|
|
17
|
+
``{next, previous, results}`` ``cursor`` users, admins, audit events
|
|
18
|
+
=============================================== ================== ==========================
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Generic, Protocol, TypeVar
|
|
24
|
+
from urllib.parse import parse_qsl, urlparse
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PageResult:
|
|
31
|
+
"""
|
|
32
|
+
One decoded page, before its records are validated into models.
|
|
33
|
+
|
|
34
|
+
:ivar items: The raw records on this page.
|
|
35
|
+
:ivar total: The total record count when the envelope reports one, else ``None``.
|
|
36
|
+
:ivar next_params: Query parameters for the following page, extracted from a ``next`` URL.
|
|
37
|
+
:ivar raw: The full decoded body, kept so callers can reach envelope extras.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
items: list[Any]
|
|
41
|
+
total: int | None = None
|
|
42
|
+
next_params: dict[str, Any] | None = None
|
|
43
|
+
raw: Any = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Page(Generic[T]):
|
|
48
|
+
"""
|
|
49
|
+
A page of validated records, as handed to callers by a paginator.
|
|
50
|
+
|
|
51
|
+
:ivar index: Zero-based position of this page in the sequence.
|
|
52
|
+
:ivar items: The validated records.
|
|
53
|
+
:ivar total: The total record count when the API reports one, else ``None``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
index: int
|
|
57
|
+
items: list[T]
|
|
58
|
+
total: int | None = None
|
|
59
|
+
raw: Any = field(default=None, repr=False)
|
|
60
|
+
|
|
61
|
+
def __len__(self) -> int:
|
|
62
|
+
return len(self.items)
|
|
63
|
+
|
|
64
|
+
def __iter__(self):
|
|
65
|
+
return iter(self.items)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PaginationStrategy(Protocol):
|
|
69
|
+
"""The contract every pagination strategy implements."""
|
|
70
|
+
|
|
71
|
+
def first_params(self, page_size: int) -> dict[str, Any]:
|
|
72
|
+
"""Query parameters for the first page."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def parse(self, body: Any) -> PageResult:
|
|
76
|
+
"""Decode a response body into a :class:`PageResult`."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def next_params(self, prev: PageResult, page_size: int, fetched: int) -> dict[str, Any] | None:
|
|
80
|
+
"""Parameters for the page after ``prev``, or ``None`` when the sequence is exhausted."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def plan(self, first: PageResult, page_size: int) -> list[dict[str, Any]] | None:
|
|
84
|
+
"""
|
|
85
|
+
Parameters for every page after the first, when they can be computed up front.
|
|
86
|
+
|
|
87
|
+
Returning ``None`` means the endpoint must be walked serially.
|
|
88
|
+
"""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_next_params(body: Any) -> dict[str, Any] | None:
|
|
93
|
+
"""Pull the query parameters out of an envelope's ``next`` URL, if it has one."""
|
|
94
|
+
if not isinstance(body, dict):
|
|
95
|
+
return None
|
|
96
|
+
if not (nxt := body.get("next")):
|
|
97
|
+
return None
|
|
98
|
+
return dict(parse_qsl(urlparse(str(nxt)).query)) or None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OffsetPagination:
|
|
102
|
+
"""
|
|
103
|
+
``limit`` / ``offset`` paging.
|
|
104
|
+
|
|
105
|
+
Covers the bare-array device list, the ``{count, ..., results}`` envelope, and Prism's
|
|
106
|
+
``{total, ..., data}`` envelope, which differ only in where the records and the total live.
|
|
107
|
+
|
|
108
|
+
:param results_key: Envelope key holding the records. ``None`` means the body is a bare array.
|
|
109
|
+
:type results_key: str | None
|
|
110
|
+
:param total_key: Envelope key holding the record count, when the API reports one.
|
|
111
|
+
:type total_key: str | None
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, results_key: str | None = None, total_key: str | None = None) -> None:
|
|
115
|
+
self.results_key = results_key
|
|
116
|
+
self.total_key = total_key
|
|
117
|
+
|
|
118
|
+
def first_params(self, page_size: int) -> dict[str, Any]:
|
|
119
|
+
return {"limit": page_size, "offset": 0}
|
|
120
|
+
|
|
121
|
+
def parse(self, body: Any) -> PageResult:
|
|
122
|
+
if self.results_key is None:
|
|
123
|
+
items = body if isinstance(body, list) else []
|
|
124
|
+
return PageResult(items=list(items), raw=body)
|
|
125
|
+
|
|
126
|
+
envelope = body if isinstance(body, dict) else {}
|
|
127
|
+
total = envelope.get(self.total_key) if self.total_key else None
|
|
128
|
+
return PageResult(
|
|
129
|
+
items=list(envelope.get(self.results_key) or []),
|
|
130
|
+
total=total if isinstance(total, int) else None,
|
|
131
|
+
next_params=_extract_next_params(envelope),
|
|
132
|
+
raw=body,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def next_params(self, prev: PageResult, page_size: int, fetched: int) -> dict[str, Any] | None:
|
|
136
|
+
# A short page is the only end-of-sequence signal when no total is reported.
|
|
137
|
+
if not prev.items or len(prev.items) < page_size:
|
|
138
|
+
return None
|
|
139
|
+
if prev.total is not None and fetched >= prev.total:
|
|
140
|
+
return None
|
|
141
|
+
return {"limit": page_size, "offset": fetched}
|
|
142
|
+
|
|
143
|
+
def plan(self, first: PageResult, page_size: int) -> list[dict[str, Any]] | None:
|
|
144
|
+
if first.total is None or not first.items:
|
|
145
|
+
return None
|
|
146
|
+
stride = len(first.items)
|
|
147
|
+
return [
|
|
148
|
+
{"limit": stride, "offset": offset} for offset in range(stride, first.total, stride)
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class PagePagination:
|
|
153
|
+
"""
|
|
154
|
+
One-based ``page`` paging, optionally with an explicit page-size parameter.
|
|
155
|
+
|
|
156
|
+
:param results_key: Envelope key holding the records.
|
|
157
|
+
:type results_key: str
|
|
158
|
+
:param total_key: Envelope key holding the record count.
|
|
159
|
+
:type total_key: str
|
|
160
|
+
:param size_param: Name of the page-size parameter, or ``None`` when the endpoint has none.
|
|
161
|
+
:type size_param: str | None
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
results_key: str = "results",
|
|
167
|
+
total_key: str = "count",
|
|
168
|
+
size_param: str | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
self.results_key = results_key
|
|
171
|
+
self.total_key = total_key
|
|
172
|
+
self.size_param = size_param
|
|
173
|
+
|
|
174
|
+
def _page_params(self, page: int, page_size: int) -> dict[str, Any]:
|
|
175
|
+
params: dict[str, Any] = {"page": page}
|
|
176
|
+
if self.size_param:
|
|
177
|
+
params[self.size_param] = page_size
|
|
178
|
+
return params
|
|
179
|
+
|
|
180
|
+
def first_params(self, page_size: int) -> dict[str, Any]:
|
|
181
|
+
return self._page_params(1, page_size)
|
|
182
|
+
|
|
183
|
+
def parse(self, body: Any) -> PageResult:
|
|
184
|
+
envelope = body if isinstance(body, dict) else {}
|
|
185
|
+
total = envelope.get(self.total_key)
|
|
186
|
+
return PageResult(
|
|
187
|
+
items=list(envelope.get(self.results_key) or []),
|
|
188
|
+
total=total if isinstance(total, int) else None,
|
|
189
|
+
next_params=_extract_next_params(envelope),
|
|
190
|
+
raw=body,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def next_params(self, prev: PageResult, page_size: int, fetched: int) -> dict[str, Any] | None:
|
|
194
|
+
if not prev.items:
|
|
195
|
+
return None
|
|
196
|
+
if prev.total is not None and fetched >= prev.total:
|
|
197
|
+
return None
|
|
198
|
+
if prev.next_params:
|
|
199
|
+
return prev.next_params
|
|
200
|
+
if len(prev.items) < page_size:
|
|
201
|
+
return None
|
|
202
|
+
return self._page_params(fetched // len(prev.items) + 1, page_size)
|
|
203
|
+
|
|
204
|
+
def plan(self, first: PageResult, page_size: int) -> list[dict[str, Any]] | None:
|
|
205
|
+
if first.total is None or not first.items:
|
|
206
|
+
return None
|
|
207
|
+
size = len(first.items)
|
|
208
|
+
last_page = math.ceil(first.total / size)
|
|
209
|
+
return [self._page_params(page, size) for page in range(2, last_page + 1)]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class CursorPagination:
|
|
213
|
+
"""
|
|
214
|
+
Opaque-cursor paging, where the envelope carries a ``next`` URL and no total.
|
|
215
|
+
|
|
216
|
+
Inherently serial: the next cursor is only known once the current page has been fetched.
|
|
217
|
+
|
|
218
|
+
Also the right choice for an endpoint that returns a ``next`` URL but documents no page
|
|
219
|
+
parameter — following the server's own link is safer than guessing one it may ignore.
|
|
220
|
+
|
|
221
|
+
:param results_key: Envelope key holding the records.
|
|
222
|
+
:type results_key: str
|
|
223
|
+
:param size_param: Name of the page-size parameter. The Iru API is inconsistent here:
|
|
224
|
+
``/users`` takes ``sizePerPage`` while ``/tags`` documents none at all.
|
|
225
|
+
:type size_param: str | None
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(self, results_key: str = "results", size_param: str | None = "limit") -> None:
|
|
229
|
+
self.results_key = results_key
|
|
230
|
+
self.size_param = size_param
|
|
231
|
+
|
|
232
|
+
def first_params(self, page_size: int) -> dict[str, Any]:
|
|
233
|
+
return {self.size_param: page_size} if self.size_param else {}
|
|
234
|
+
|
|
235
|
+
def parse(self, body: Any) -> PageResult:
|
|
236
|
+
envelope = body if isinstance(body, dict) else {}
|
|
237
|
+
return PageResult(
|
|
238
|
+
items=list(envelope.get(self.results_key) or []),
|
|
239
|
+
next_params=_extract_next_params(envelope),
|
|
240
|
+
raw=body,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def next_params(self, prev: PageResult, page_size: int, fetched: int) -> dict[str, Any] | None:
|
|
244
|
+
return prev.next_params
|
|
245
|
+
|
|
246
|
+
def plan(self, first: PageResult, page_size: int) -> list[dict[str, Any]] | None:
|
|
247
|
+
return None
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Client-side rate limiting.
|
|
2
|
+
|
|
3
|
+
Pure arithmetic with an injectable clock: no locks, no sleeping. The transports wrap this in
|
|
4
|
+
their own lock and sleep with their own primitive, so one implementation serves both.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
SECONDS_PER_HOUR = 3600.0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RateLimitPolicy:
|
|
11
|
+
"""
|
|
12
|
+
A dual-window limiter covering Iru's per-second and per-hour tenant quotas.
|
|
13
|
+
|
|
14
|
+
The per-second window paces requests evenly rather than allowing a burst followed by a stall.
|
|
15
|
+
The per-hour window is a token bucket that refills continuously.
|
|
16
|
+
|
|
17
|
+
:param per_second: Sustained requests per second.
|
|
18
|
+
:type per_second: float
|
|
19
|
+
:param per_hour: Requests permitted per rolling hour.
|
|
20
|
+
:type per_hour: int
|
|
21
|
+
:param now: The current timestamp, from a monotonic clock.
|
|
22
|
+
:type now: float
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, per_second: float, per_hour: int, *, now: float = 0.0) -> None:
|
|
26
|
+
if per_second <= 0 or per_hour <= 0:
|
|
27
|
+
raise ValueError("Rate limits must be positive")
|
|
28
|
+
|
|
29
|
+
self.per_second = per_second
|
|
30
|
+
self.per_hour = per_hour
|
|
31
|
+
self._spacing = 1.0 / per_second
|
|
32
|
+
self._refill_rate = per_hour / SECONDS_PER_HOUR
|
|
33
|
+
self._next_slot = now
|
|
34
|
+
self._hour_tokens = float(per_hour)
|
|
35
|
+
self._hour_updated = now
|
|
36
|
+
|
|
37
|
+
def acquire_at(self, now: float) -> float:
|
|
38
|
+
"""
|
|
39
|
+
Reserve the next request slot and report when it may be sent.
|
|
40
|
+
|
|
41
|
+
Mutates the internal counters, so every call must be followed by an actual request.
|
|
42
|
+
|
|
43
|
+
:param now: The current timestamp, from a monotonic clock.
|
|
44
|
+
:type now: float
|
|
45
|
+
:return: The timestamp at which the caller may send. May be in the past.
|
|
46
|
+
:rtype: float
|
|
47
|
+
"""
|
|
48
|
+
send_at = max(now, self._next_slot, self._hour_available_at(now))
|
|
49
|
+
self._next_slot = send_at + self._spacing
|
|
50
|
+
self._spend_hour_token(send_at)
|
|
51
|
+
return send_at
|
|
52
|
+
|
|
53
|
+
def penalize(self, retry_after: float, now: float) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Stall the whole bucket after a ``429``.
|
|
56
|
+
|
|
57
|
+
Applied to the shared limiter rather than the one worker that was rejected, so concurrent
|
|
58
|
+
workers back off together instead of retrying into the same wall.
|
|
59
|
+
|
|
60
|
+
:param retry_after: Seconds the server asked the client to wait.
|
|
61
|
+
:type retry_after: float
|
|
62
|
+
:param now: The current timestamp, from a monotonic clock.
|
|
63
|
+
:type now: float
|
|
64
|
+
"""
|
|
65
|
+
self._next_slot = max(self._next_slot, now + max(0.0, retry_after))
|
|
66
|
+
|
|
67
|
+
def _hour_available_at(self, now: float) -> float:
|
|
68
|
+
"""Earliest timestamp at which the hourly bucket holds a whole token."""
|
|
69
|
+
tokens = self._tokens_at(now)
|
|
70
|
+
if tokens >= 1.0:
|
|
71
|
+
return now
|
|
72
|
+
return now + (1.0 - tokens) / self._refill_rate
|
|
73
|
+
|
|
74
|
+
def _tokens_at(self, now: float) -> float:
|
|
75
|
+
"""Hourly tokens available at ``now``, without committing the refill."""
|
|
76
|
+
elapsed = max(0.0, now - self._hour_updated)
|
|
77
|
+
return min(float(self.per_hour), self._hour_tokens + elapsed * self._refill_rate)
|
|
78
|
+
|
|
79
|
+
def _spend_hour_token(self, at: float) -> None:
|
|
80
|
+
self._hour_tokens = max(0.0, self._tokens_at(at) - 1.0)
|
|
81
|
+
self._hour_updated = at
|
irusdk/_core/retry.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Transport-agnostic retry policy.
|
|
2
|
+
|
|
3
|
+
The policy decides *how long* to wait; the transports own the sleeping, so the sync and async
|
|
4
|
+
paths share one set of rules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import random
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
from typing import Mapping
|
|
11
|
+
|
|
12
|
+
# Statuses worth retrying: the tenant quota, and the transient server-side failures.
|
|
13
|
+
RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_retry_after(value: str | None, *, now: float | None = None) -> float | None:
|
|
17
|
+
"""
|
|
18
|
+
Parse a ``Retry-After`` header, which may be a delay in seconds or an HTTP date.
|
|
19
|
+
|
|
20
|
+
:param value: The raw header value.
|
|
21
|
+
:type value: str | None
|
|
22
|
+
:param now: Unix timestamp to measure an HTTP-date form against. Defaults to the current time.
|
|
23
|
+
:type now: float | None
|
|
24
|
+
:return: Seconds to wait, or ``None`` if the header was absent or unparseable.
|
|
25
|
+
:rtype: float | None
|
|
26
|
+
"""
|
|
27
|
+
if not value:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
return max(0.0, float(value))
|
|
32
|
+
except ValueError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
when = parsedate_to_datetime(value)
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return None
|
|
39
|
+
if when is None:
|
|
40
|
+
return None
|
|
41
|
+
if when.tzinfo is None:
|
|
42
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
43
|
+
|
|
44
|
+
reference = now if now is not None else datetime.now(timezone.utc).timestamp()
|
|
45
|
+
return max(0.0, when.timestamp() - reference)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RetryPolicy:
|
|
49
|
+
"""
|
|
50
|
+
Decides whether and how long to wait before replaying a failed request.
|
|
51
|
+
|
|
52
|
+
:param max_retries: Attempts after the initial request.
|
|
53
|
+
:type max_retries: int
|
|
54
|
+
:param backoff_factor: Base for the exponential backoff, in seconds.
|
|
55
|
+
:type backoff_factor: float
|
|
56
|
+
:param max_backoff: Ceiling on any single wait, in seconds.
|
|
57
|
+
:type max_backoff: float
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
max_retries: int = 3,
|
|
63
|
+
backoff_factor: float = 0.5,
|
|
64
|
+
max_backoff: float = 60.0,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.max_retries = max_retries
|
|
67
|
+
self.backoff_factor = backoff_factor
|
|
68
|
+
self.max_backoff = max_backoff
|
|
69
|
+
|
|
70
|
+
def delay_for(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
attempt: int,
|
|
74
|
+
idempotent: bool,
|
|
75
|
+
status: int | None = None,
|
|
76
|
+
headers: Mapping[str, str] | None = None,
|
|
77
|
+
exc: Exception | None = None,
|
|
78
|
+
) -> float | None:
|
|
79
|
+
"""
|
|
80
|
+
Compute the wait before the next attempt.
|
|
81
|
+
|
|
82
|
+
:param attempt: How many attempts have already been made, starting at 1.
|
|
83
|
+
:type attempt: int
|
|
84
|
+
:param idempotent: Whether the request is safe to replay.
|
|
85
|
+
:type idempotent: bool
|
|
86
|
+
:param status: The response status code, if a response was received.
|
|
87
|
+
:type status: int | None
|
|
88
|
+
:param headers: The response headers, consulted for ``Retry-After``.
|
|
89
|
+
:param exc: The transport exception raised, if the request never completed.
|
|
90
|
+
:type exc: Exception | None
|
|
91
|
+
:return: Seconds to wait, or ``None`` to give up and raise.
|
|
92
|
+
:rtype: float | None
|
|
93
|
+
"""
|
|
94
|
+
if not idempotent or attempt > self.max_retries:
|
|
95
|
+
return None
|
|
96
|
+
if status is not None and status not in RETRYABLE_STATUS:
|
|
97
|
+
return None
|
|
98
|
+
if status is None and exc is None:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
retry_after = parse_retry_after((headers or {}).get("Retry-After"))
|
|
102
|
+
if retry_after is not None:
|
|
103
|
+
return min(retry_after, self.max_backoff)
|
|
104
|
+
|
|
105
|
+
# Full jitter: spreads a thundering herd of concurrent workers across the window.
|
|
106
|
+
ceiling = min(self.backoff_factor * (2 ** (attempt - 1)), self.max_backoff)
|
|
107
|
+
return random.uniform(0.0, ceiling)
|