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.
Files changed (46) hide show
  1. irusdk/__about__.py +2 -0
  2. irusdk/__init__.py +35 -0
  3. irusdk/_core/__init__.py +0 -0
  4. irusdk/_core/config.py +52 -0
  5. irusdk/_core/errors.py +155 -0
  6. irusdk/_core/pagination.py +247 -0
  7. irusdk/_core/ratelimit.py +81 -0
  8. irusdk/_core/retry.py +107 -0
  9. irusdk/_core/spec.py +98 -0
  10. irusdk/_core/urls.py +56 -0
  11. irusdk/_endpoints/__init__.py +0 -0
  12. irusdk/_endpoints/blueprints.py +86 -0
  13. irusdk/_endpoints/devices.py +87 -0
  14. irusdk/_endpoints/tags.py +38 -0
  15. irusdk/_endpoints/users.py +47 -0
  16. irusdk/_transport/__init__.py +0 -0
  17. irusdk/_transport/_common.py +181 -0
  18. irusdk/_transport/async_transport.py +243 -0
  19. irusdk/_transport/sync_transport.py +225 -0
  20. irusdk/cli/__init__.py +44 -0
  21. irusdk/cli/_console.py +114 -0
  22. irusdk/cli/_helpers.py +60 -0
  23. irusdk/cli/blueprints.py +66 -0
  24. irusdk/cli/devices.py +88 -0
  25. irusdk/cli/tags.py +57 -0
  26. irusdk/cli/users.py +55 -0
  27. irusdk/client.py +154 -0
  28. irusdk/models/__init__.py +35 -0
  29. irusdk/models/base.py +63 -0
  30. irusdk/models/blueprints.py +76 -0
  31. irusdk/models/common.py +31 -0
  32. irusdk/models/devices.py +115 -0
  33. irusdk/models/library.py +24 -0
  34. irusdk/models/tags.py +15 -0
  35. irusdk/models/users.py +51 -0
  36. irusdk/py.typed +0 -0
  37. irusdk/services/__init__.py +28 -0
  38. irusdk/services/blueprints.py +209 -0
  39. irusdk/services/devices.py +220 -0
  40. irusdk/services/tags.py +96 -0
  41. irusdk/services/users.py +117 -0
  42. irusdk-0.1.0.dist-info/METADATA +199 -0
  43. irusdk-0.1.0.dist-info/RECORD +46 -0
  44. irusdk-0.1.0.dist-info/WHEEL +4 -0
  45. irusdk-0.1.0.dist-info/entry_points.txt +2 -0
  46. irusdk-0.1.0.dist-info/licenses/LICENSE +201 -0
irusdk/__about__.py ADDED
@@ -0,0 +1,2 @@
1
+ __title__ = "irusdk"
2
+ __version__ = "0.1.0"
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
+ ]
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)