status302 1.5.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.
- status302/__init__.py +69 -0
- status302/_base.py +444 -0
- status302/_client.py +100 -0
- status302/_errors.py +125 -0
- status302/_operations.py +9193 -0
- status302/_ratelimit.py +81 -0
- status302/_version.py +4 -0
- status302/py.typed +0 -0
- status302/stream.py +352 -0
- status302/types.py +2898 -0
- status302/webhooks.py +89 -0
- status302-1.5.0.dist-info/METADATA +139 -0
- status302-1.5.0.dist-info/RECORD +14 -0
- status302-1.5.0.dist-info/WHEEL +4 -0
status302/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Official Python SDK for the Status302 API."""
|
|
2
|
+
|
|
3
|
+
from . import types
|
|
4
|
+
from ._base import APIResponse, CachedResponse, ETagCache, MemoryETagCache, NotModified
|
|
5
|
+
from ._client import AsyncStatus302, Status302
|
|
6
|
+
from ._errors import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APIError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
BadRequestError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
PlanRequiredError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
Status302Error,
|
|
18
|
+
)
|
|
19
|
+
from ._ratelimit import RateLimitInfo, RateLimitPolicy, parse_rate_limit, parse_retry_after
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .stream import (
|
|
22
|
+
DataMessage,
|
|
23
|
+
ErrorMessage,
|
|
24
|
+
ResyncMessage,
|
|
25
|
+
StreamClosedError,
|
|
26
|
+
Subscription,
|
|
27
|
+
SubscriptionError,
|
|
28
|
+
Status302Stream,
|
|
29
|
+
stream_url,
|
|
30
|
+
)
|
|
31
|
+
from .webhooks import WebhookVerificationError, sign_webhook, verify_webhook
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"APIConnectionError",
|
|
35
|
+
"APIError",
|
|
36
|
+
"APIResponse",
|
|
37
|
+
"AsyncStatus302",
|
|
38
|
+
"AuthenticationError",
|
|
39
|
+
"BadRequestError",
|
|
40
|
+
"CachedResponse",
|
|
41
|
+
"ConflictError",
|
|
42
|
+
"DataMessage",
|
|
43
|
+
"ETagCache",
|
|
44
|
+
"ErrorMessage",
|
|
45
|
+
"MemoryETagCache",
|
|
46
|
+
"NotFoundError",
|
|
47
|
+
"NotModified",
|
|
48
|
+
"PermissionDeniedError",
|
|
49
|
+
"PlanRequiredError",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"RateLimitInfo",
|
|
52
|
+
"RateLimitPolicy",
|
|
53
|
+
"ResyncMessage",
|
|
54
|
+
"ServerError",
|
|
55
|
+
"StreamClosedError",
|
|
56
|
+
"Subscription",
|
|
57
|
+
"SubscriptionError",
|
|
58
|
+
"Status302",
|
|
59
|
+
"Status302Error",
|
|
60
|
+
"Status302Stream",
|
|
61
|
+
"WebhookVerificationError",
|
|
62
|
+
"__version__",
|
|
63
|
+
"parse_rate_limit",
|
|
64
|
+
"parse_retry_after",
|
|
65
|
+
"sign_webhook",
|
|
66
|
+
"stream_url",
|
|
67
|
+
"types",
|
|
68
|
+
"verify_webhook",
|
|
69
|
+
]
|
status302/_base.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""HTTP transport shared by the sync and async clients: auth, retries, errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Generic, Protocol, TypeVar
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ._errors import APIConnectionError, error_for
|
|
19
|
+
from ._ratelimit import RateLimitInfo, parse_rate_limit, parse_retry_after
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
DEFAULT_BASE_URL = "https://api.status302.com"
|
|
23
|
+
IDEMPOTENT = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS"})
|
|
24
|
+
BASE_DELAY = 0.5
|
|
25
|
+
MAX_DELAY = 30.0
|
|
26
|
+
|
|
27
|
+
T_co = TypeVar("T_co", covariant=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class APIResponse(Generic[T_co]):
|
|
32
|
+
"""A decoded response with its status, headers and rate limit state."""
|
|
33
|
+
|
|
34
|
+
data: T_co
|
|
35
|
+
status: int
|
|
36
|
+
"""HTTP status; 304 when the body was revalidated from the client's ``etag_cache`` or is ``NotModified``."""
|
|
37
|
+
headers: httpx.Headers
|
|
38
|
+
rate_limit: RateLimitInfo | None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def etag(self) -> str | None:
|
|
42
|
+
"""The ``ETag`` response header: pass it as ``if_none_match`` to revalidate."""
|
|
43
|
+
etag: str | None = self.headers.get("etag")
|
|
44
|
+
return etag
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class NotModified:
|
|
49
|
+
"""The answer to a conditional request (``if_none_match``) while the copy with that ETag is still current (HTTP 304)."""
|
|
50
|
+
|
|
51
|
+
etag: str | None
|
|
52
|
+
"""The current ETag, equal to (one of) the ETags sent."""
|
|
53
|
+
headers: httpx.Headers
|
|
54
|
+
rate_limit: RateLimitInfo | None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def status(self) -> int:
|
|
58
|
+
return 304
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class CachedResponse:
|
|
63
|
+
"""A response body stored by an ``ETagCache``, keyed by request URL."""
|
|
64
|
+
|
|
65
|
+
etag: str
|
|
66
|
+
content: bytes
|
|
67
|
+
content_type: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ETagCache(Protocol):
|
|
71
|
+
"""Store of the client's ``etag_cache`` option.
|
|
72
|
+
|
|
73
|
+
Entries never go stale: an ETag hashes its body, so a 304 proves the stored body is current.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def get(self, key: str) -> CachedResponse | None: ...
|
|
77
|
+
|
|
78
|
+
def set(self, key: str, value: CachedResponse) -> None: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MemoryETagCache:
|
|
82
|
+
"""Thread-safe in-memory ``ETagCache`` keeping the ``max_entries`` most recently used responses."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, max_entries: int = 1000) -> None:
|
|
85
|
+
if max_entries < 1:
|
|
86
|
+
raise ValueError("max_entries must be positive")
|
|
87
|
+
self._max_entries = max_entries
|
|
88
|
+
self._entries: OrderedDict[str, CachedResponse] = OrderedDict()
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
|
|
91
|
+
def __len__(self) -> int:
|
|
92
|
+
return len(self._entries)
|
|
93
|
+
|
|
94
|
+
def get(self, key: str) -> CachedResponse | None:
|
|
95
|
+
with self._lock:
|
|
96
|
+
entry = self._entries.get(key)
|
|
97
|
+
if entry is not None:
|
|
98
|
+
self._entries.move_to_end(key)
|
|
99
|
+
return entry
|
|
100
|
+
|
|
101
|
+
def set(self, key: str, value: CachedResponse) -> None:
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._entries[key] = value
|
|
104
|
+
self._entries.move_to_end(key)
|
|
105
|
+
if len(self._entries) > self._max_entries:
|
|
106
|
+
self._entries.popitem(last=False)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def backoff(attempt: int) -> float:
|
|
110
|
+
"""Exponential backoff with full jitter: random in [0, min(30 s, 0.5 s * 2**attempt)]."""
|
|
111
|
+
return random.uniform(0, min(MAX_DELAY, BASE_DELAY * 2**attempt))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
QueryItems = list[tuple[str, str | int | float | bool | None]]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _query_items(query: Mapping[str, Any] | None, exploded: Sequence[str]) -> QueryItems:
|
|
118
|
+
"""Encodes query parameters: arrays are comma-separated unless the spec explodes them."""
|
|
119
|
+
items: QueryItems = []
|
|
120
|
+
for key, value in (query or {}).items():
|
|
121
|
+
if value is None:
|
|
122
|
+
continue
|
|
123
|
+
if isinstance(value, bool):
|
|
124
|
+
items.append((key, "true" if value else "false"))
|
|
125
|
+
elif isinstance(value, (list, tuple)):
|
|
126
|
+
if key in exploded:
|
|
127
|
+
items.extend((key, _scalar(v)) for v in value)
|
|
128
|
+
elif value:
|
|
129
|
+
items.append((key, ",".join(_scalar(v) for v in value)))
|
|
130
|
+
else:
|
|
131
|
+
items.append((key, _scalar(value)))
|
|
132
|
+
return items
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _scalar(value: Any) -> str:
|
|
136
|
+
if isinstance(value, bool):
|
|
137
|
+
return "true" if value else "false"
|
|
138
|
+
return str(value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _problem(response: httpx.Response) -> dict[str, Any]:
|
|
142
|
+
try:
|
|
143
|
+
body = response.json()
|
|
144
|
+
except (ValueError, json.JSONDecodeError):
|
|
145
|
+
body = None
|
|
146
|
+
if isinstance(body, dict) and isinstance(body.get("title"), str):
|
|
147
|
+
return {"type": "about:blank", "status": response.status_code, **body}
|
|
148
|
+
problem: dict[str, Any] = {"type": "about:blank", "title": response.reason_phrase or f"HTTP {response.status_code}", "status": response.status_code}
|
|
149
|
+
if response.text:
|
|
150
|
+
problem["detail"] = response.text[:500]
|
|
151
|
+
return problem
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _decode(content: bytes, content_type: str, kind: str) -> Any:
|
|
155
|
+
if kind == "none":
|
|
156
|
+
return None
|
|
157
|
+
if kind == "bytes":
|
|
158
|
+
return content
|
|
159
|
+
if kind == "text" or "json" not in content_type:
|
|
160
|
+
return content.decode()
|
|
161
|
+
return json.loads(content)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _Attempt:
|
|
165
|
+
"""Request state shared by the sync and async send loops: headers and the ETag cache entry."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, core: _Core, method: str, url: str, params: QueryItems, headers: dict[str, str], if_none_match: str | None) -> None:
|
|
168
|
+
if if_none_match is not None:
|
|
169
|
+
headers["If-None-Match"] = if_none_match
|
|
170
|
+
# The cache revalidates only requests without a caller-chosen condition.
|
|
171
|
+
explicit = any(h.lower() == "if-none-match" for h in headers)
|
|
172
|
+
self.cache = core.etag_cache if method == "GET" and not explicit else None
|
|
173
|
+
self.key = str(httpx.URL(url, params=params or None))
|
|
174
|
+
self.cached = self.cache.get(self.key) if self.cache is not None else None
|
|
175
|
+
if self.cached is not None:
|
|
176
|
+
headers["If-None-Match"] = self.cached.etag
|
|
177
|
+
self.headers = headers
|
|
178
|
+
|
|
179
|
+
def result(self, response: httpx.Response, kind: str, rate_limit: RateLimitInfo | None) -> APIResponse[Any]:
|
|
180
|
+
"""The response of a 304 or a 2xx answer."""
|
|
181
|
+
status = response.status_code
|
|
182
|
+
if status == 304:
|
|
183
|
+
if self.cached is not None:
|
|
184
|
+
data: Any = _decode(self.cached.content, self.cached.content_type, kind)
|
|
185
|
+
else:
|
|
186
|
+
data = NotModified(response.headers.get("etag"), response.headers, rate_limit)
|
|
187
|
+
return APIResponse(data, status, response.headers, rate_limit)
|
|
188
|
+
content_type = response.headers.get("content-type", "")
|
|
189
|
+
etag = response.headers.get("etag")
|
|
190
|
+
if self.cache is not None and etag and status == 200:
|
|
191
|
+
self.cache.set(self.key, CachedResponse(etag, response.content, content_type))
|
|
192
|
+
data = None if status == 204 else _decode(response.content, content_type, kind)
|
|
193
|
+
return APIResponse(data, status, response.headers, rate_limit)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _Core:
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
api_key: str | None,
|
|
200
|
+
base_url: str,
|
|
201
|
+
max_retries: int,
|
|
202
|
+
max_retry_after: float,
|
|
203
|
+
etag_cache: ETagCache | None,
|
|
204
|
+
) -> None:
|
|
205
|
+
self.base_url = base_url.rstrip("/")
|
|
206
|
+
self._api_key = api_key
|
|
207
|
+
self.max_retries = max_retries
|
|
208
|
+
self.max_retry_after = max_retry_after
|
|
209
|
+
self.etag_cache = etag_cache
|
|
210
|
+
|
|
211
|
+
def _headers(self, has_body: bool, extra: Mapping[str, str] | None) -> dict[str, str]:
|
|
212
|
+
headers = {"Accept": "application/json, application/problem+json", "User-Agent": f"status302-python/{__version__}"}
|
|
213
|
+
if self._api_key:
|
|
214
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
215
|
+
if has_body:
|
|
216
|
+
headers["Content-Type"] = "application/json"
|
|
217
|
+
if extra:
|
|
218
|
+
headers.update(extra)
|
|
219
|
+
return headers
|
|
220
|
+
|
|
221
|
+
def _url(self, path: str, path_params: Mapping[str, Any] | None) -> str:
|
|
222
|
+
for name, value in (path_params or {}).items():
|
|
223
|
+
path = path.replace("{" + name + "}", quote(str(value), safe=""))
|
|
224
|
+
if "{" in path:
|
|
225
|
+
raise TypeError(f"missing path parameter in {path}")
|
|
226
|
+
return self.base_url + path
|
|
227
|
+
|
|
228
|
+
def _retry_delay(self, status: int, idempotent: bool, attempt: int, retry_after: int | None) -> float | None:
|
|
229
|
+
"""Seconds to wait before retrying, or None when the error is final."""
|
|
230
|
+
if attempt >= self.max_retries:
|
|
231
|
+
return None
|
|
232
|
+
# 429 means the request was refused before it ran, so any method may retry it.
|
|
233
|
+
retryable = status == 429 or (status >= 500 and idempotent) or (status == 503 and retry_after is not None)
|
|
234
|
+
if not retryable:
|
|
235
|
+
return None
|
|
236
|
+
if retry_after is not None:
|
|
237
|
+
return float(retry_after) if retry_after <= self.max_retry_after else None
|
|
238
|
+
return backoff(attempt)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class SyncTransport(_Core):
|
|
242
|
+
"""Blocking transport over ``httpx.Client``."""
|
|
243
|
+
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
api_key: str | None = None,
|
|
247
|
+
*,
|
|
248
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
249
|
+
max_retries: int = 3,
|
|
250
|
+
max_retry_after: float = 60,
|
|
251
|
+
timeout: float = 30.0,
|
|
252
|
+
http_client: httpx.Client | None = None,
|
|
253
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
254
|
+
etag_cache: ETagCache | None = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
super().__init__(api_key, base_url, max_retries, max_retry_after, etag_cache)
|
|
257
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
258
|
+
self._sleep = sleep
|
|
259
|
+
|
|
260
|
+
def close(self) -> None:
|
|
261
|
+
self._http.close()
|
|
262
|
+
|
|
263
|
+
def request(
|
|
264
|
+
self,
|
|
265
|
+
method: str,
|
|
266
|
+
path: str,
|
|
267
|
+
*,
|
|
268
|
+
path_params: Mapping[str, Any] | None = None,
|
|
269
|
+
query: Mapping[str, Any] | None = None,
|
|
270
|
+
body: Any = None,
|
|
271
|
+
headers: Mapping[str, str] | None = None,
|
|
272
|
+
kind: str = "json",
|
|
273
|
+
exploded: Sequence[str] = (),
|
|
274
|
+
if_none_match: str | None = None,
|
|
275
|
+
) -> APIResponse[Any]:
|
|
276
|
+
"""Sends a request and returns the decoded body with status, headers, ETag and rate limit state.
|
|
277
|
+
|
|
278
|
+
With ``if_none_match`` the data is ``NotModified`` while the representation still has that ETag.
|
|
279
|
+
"""
|
|
280
|
+
return self._send(method.upper(), self._url(path, path_params), _query_items(query, exploded), body, headers, kind, if_none_match)
|
|
281
|
+
|
|
282
|
+
def get_path(self, path_and_query: str) -> Any:
|
|
283
|
+
"""GETs a REST path returned by the API itself (a stream ``snapshot``), including its query string."""
|
|
284
|
+
if not path_and_query.startswith("/"):
|
|
285
|
+
raise ValueError("get_path expects a path starting with /")
|
|
286
|
+
return self._send("GET", self.base_url + path_and_query, [], None, None, "json", None).data
|
|
287
|
+
|
|
288
|
+
def _respond(
|
|
289
|
+
self,
|
|
290
|
+
method: str,
|
|
291
|
+
path: str,
|
|
292
|
+
path_params: Mapping[str, Any] | None,
|
|
293
|
+
query: Mapping[str, Any] | None,
|
|
294
|
+
body: Any,
|
|
295
|
+
kind: str,
|
|
296
|
+
exploded: Sequence[str],
|
|
297
|
+
if_none_match: str | None = None,
|
|
298
|
+
) -> APIResponse[Any]:
|
|
299
|
+
return self.request(
|
|
300
|
+
method, path, path_params=path_params, query=query, body=body, kind=kind, exploded=exploded, if_none_match=if_none_match
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def _call(
|
|
304
|
+
self,
|
|
305
|
+
method: str,
|
|
306
|
+
path: str,
|
|
307
|
+
path_params: Mapping[str, Any] | None,
|
|
308
|
+
query: Mapping[str, Any] | None,
|
|
309
|
+
body: Any,
|
|
310
|
+
kind: str,
|
|
311
|
+
exploded: Sequence[str],
|
|
312
|
+
if_none_match: str | None = None,
|
|
313
|
+
) -> Any:
|
|
314
|
+
return self._respond(method, path, path_params, query, body, kind, exploded, if_none_match).data
|
|
315
|
+
|
|
316
|
+
def _send(
|
|
317
|
+
self, method: str, url: str, params: QueryItems, body: Any, extra: Mapping[str, str] | None, kind: str, if_none_match: str | None
|
|
318
|
+
) -> APIResponse[Any]:
|
|
319
|
+
state = _Attempt(self, method, url, params, self._headers(body is not None, extra), if_none_match)
|
|
320
|
+
content = None if body is None else json.dumps(body).encode()
|
|
321
|
+
idempotent = method in IDEMPOTENT
|
|
322
|
+
attempt = 0
|
|
323
|
+
while True:
|
|
324
|
+
try:
|
|
325
|
+
response = self._http.request(method, url, params=params or None, headers=state.headers, content=content)
|
|
326
|
+
except httpx.TransportError as exc:
|
|
327
|
+
if idempotent and attempt < self.max_retries:
|
|
328
|
+
self._sleep(backoff(attempt))
|
|
329
|
+
attempt += 1
|
|
330
|
+
continue
|
|
331
|
+
raise APIConnectionError(f"{method} {url}: {exc}") from exc
|
|
332
|
+
rate_limit = parse_rate_limit(response.headers)
|
|
333
|
+
if response.is_success or response.status_code == 304:
|
|
334
|
+
return state.result(response, kind, rate_limit)
|
|
335
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
336
|
+
error = error_for(response.status_code, _problem(response), response.headers, rate_limit, retry_after)
|
|
337
|
+
delay = self._retry_delay(response.status_code, idempotent, attempt, retry_after)
|
|
338
|
+
if delay is None:
|
|
339
|
+
raise error
|
|
340
|
+
self._sleep(delay)
|
|
341
|
+
attempt += 1
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class AsyncTransport(_Core):
|
|
345
|
+
"""Asyncio transport over ``httpx.AsyncClient``."""
|
|
346
|
+
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
api_key: str | None = None,
|
|
350
|
+
*,
|
|
351
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
352
|
+
max_retries: int = 3,
|
|
353
|
+
max_retry_after: float = 60,
|
|
354
|
+
timeout: float = 30.0,
|
|
355
|
+
http_client: httpx.AsyncClient | None = None,
|
|
356
|
+
sleep: Callable[[float], Any] = asyncio.sleep,
|
|
357
|
+
etag_cache: ETagCache | None = None,
|
|
358
|
+
) -> None:
|
|
359
|
+
super().__init__(api_key, base_url, max_retries, max_retry_after, etag_cache)
|
|
360
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
361
|
+
self._sleep = sleep
|
|
362
|
+
|
|
363
|
+
async def aclose(self) -> None:
|
|
364
|
+
await self._http.aclose()
|
|
365
|
+
|
|
366
|
+
async def request(
|
|
367
|
+
self,
|
|
368
|
+
method: str,
|
|
369
|
+
path: str,
|
|
370
|
+
*,
|
|
371
|
+
path_params: Mapping[str, Any] | None = None,
|
|
372
|
+
query: Mapping[str, Any] | None = None,
|
|
373
|
+
body: Any = None,
|
|
374
|
+
headers: Mapping[str, str] | None = None,
|
|
375
|
+
kind: str = "json",
|
|
376
|
+
exploded: Sequence[str] = (),
|
|
377
|
+
if_none_match: str | None = None,
|
|
378
|
+
) -> APIResponse[Any]:
|
|
379
|
+
"""Sends a request and returns the decoded body with status, headers, ETag and rate limit state.
|
|
380
|
+
|
|
381
|
+
With ``if_none_match`` the data is ``NotModified`` while the representation still has that ETag.
|
|
382
|
+
"""
|
|
383
|
+
return await self._send(method.upper(), self._url(path, path_params), _query_items(query, exploded), body, headers, kind, if_none_match)
|
|
384
|
+
|
|
385
|
+
async def get_path(self, path_and_query: str) -> Any:
|
|
386
|
+
"""GETs a REST path returned by the API itself (a stream ``snapshot``), including its query string."""
|
|
387
|
+
if not path_and_query.startswith("/"):
|
|
388
|
+
raise ValueError("get_path expects a path starting with /")
|
|
389
|
+
return (await self._send("GET", self.base_url + path_and_query, [], None, None, "json", None)).data
|
|
390
|
+
|
|
391
|
+
async def _arespond(
|
|
392
|
+
self,
|
|
393
|
+
method: str,
|
|
394
|
+
path: str,
|
|
395
|
+
path_params: Mapping[str, Any] | None,
|
|
396
|
+
query: Mapping[str, Any] | None,
|
|
397
|
+
body: Any,
|
|
398
|
+
kind: str,
|
|
399
|
+
exploded: Sequence[str],
|
|
400
|
+
if_none_match: str | None = None,
|
|
401
|
+
) -> APIResponse[Any]:
|
|
402
|
+
return await self.request(
|
|
403
|
+
method, path, path_params=path_params, query=query, body=body, kind=kind, exploded=exploded, if_none_match=if_none_match
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
async def _acall(
|
|
407
|
+
self,
|
|
408
|
+
method: str,
|
|
409
|
+
path: str,
|
|
410
|
+
path_params: Mapping[str, Any] | None,
|
|
411
|
+
query: Mapping[str, Any] | None,
|
|
412
|
+
body: Any,
|
|
413
|
+
kind: str,
|
|
414
|
+
exploded: Sequence[str],
|
|
415
|
+
if_none_match: str | None = None,
|
|
416
|
+
) -> Any:
|
|
417
|
+
return (await self._arespond(method, path, path_params, query, body, kind, exploded, if_none_match)).data
|
|
418
|
+
|
|
419
|
+
async def _send(
|
|
420
|
+
self, method: str, url: str, params: QueryItems, body: Any, extra: Mapping[str, str] | None, kind: str, if_none_match: str | None
|
|
421
|
+
) -> APIResponse[Any]:
|
|
422
|
+
state = _Attempt(self, method, url, params, self._headers(body is not None, extra), if_none_match)
|
|
423
|
+
content = None if body is None else json.dumps(body).encode()
|
|
424
|
+
idempotent = method in IDEMPOTENT
|
|
425
|
+
attempt = 0
|
|
426
|
+
while True:
|
|
427
|
+
try:
|
|
428
|
+
response = await self._http.request(method, url, params=params or None, headers=state.headers, content=content)
|
|
429
|
+
except httpx.TransportError as exc:
|
|
430
|
+
if idempotent and attempt < self.max_retries:
|
|
431
|
+
await self._sleep(backoff(attempt))
|
|
432
|
+
attempt += 1
|
|
433
|
+
continue
|
|
434
|
+
raise APIConnectionError(f"{method} {url}: {exc}") from exc
|
|
435
|
+
rate_limit = parse_rate_limit(response.headers)
|
|
436
|
+
if response.is_success or response.status_code == 304:
|
|
437
|
+
return state.result(response, kind, rate_limit)
|
|
438
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
439
|
+
error = error_for(response.status_code, _problem(response), response.headers, rate_limit, retry_after)
|
|
440
|
+
delay = self._retry_delay(response.status_code, idempotent, attempt, retry_after)
|
|
441
|
+
if delay is None:
|
|
442
|
+
raise error
|
|
443
|
+
await self._sleep(delay)
|
|
444
|
+
attempt += 1
|
status302/_client.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The public clients: ``Status302`` (blocking) and ``AsyncStatus302`` (asyncio)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ._operations import AsyncOperations, SyncOperations
|
|
9
|
+
from .stream import Status302Stream
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _advance(page: Any, kwargs: dict[str, Any]) -> bool:
|
|
13
|
+
"""Sets the next page's ``cursor`` or ``before`` argument; False on the last page."""
|
|
14
|
+
cursor = page.get("nextCursor")
|
|
15
|
+
if cursor:
|
|
16
|
+
kwargs["cursor"] = cursor
|
|
17
|
+
return True
|
|
18
|
+
before = page.get("nextBefore")
|
|
19
|
+
if before is not None:
|
|
20
|
+
kwargs["before"] = before
|
|
21
|
+
return True
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Status302(SyncOperations):
|
|
26
|
+
"""Blocking Status302 API client.
|
|
27
|
+
|
|
28
|
+
>>> status302 = Status302(api_key=os.environ["STATUS302_API_KEY"])
|
|
29
|
+
>>> page = status302.list_events(country="US", limit=10)
|
|
30
|
+
|
|
31
|
+
``etag_cache`` (e.g. ``MemoryETagCache()``) revalidates GET responses that carried an ``ETag``:
|
|
32
|
+
the next GET of the same URL sends ``If-None-Match`` and a 304 returns the stored body.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __enter__(self) -> Status302:
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, *exc: object) -> None:
|
|
39
|
+
self.close()
|
|
40
|
+
|
|
41
|
+
def pages(self, operation: Callable[..., Any], *args: Any, **kwargs: Any) -> Iterator[Any]:
|
|
42
|
+
"""Yields every page of a paginated operation.
|
|
43
|
+
|
|
44
|
+
Follows ``nextCursor`` as ``cursor``, or ``nextBefore`` as ``before`` for the collections
|
|
45
|
+
paged by id (``list_alert_rules``, ``list_alert_events``). ``operation`` is a bound list
|
|
46
|
+
method of this client, e.g. ``status302.list_events``.
|
|
47
|
+
"""
|
|
48
|
+
while True:
|
|
49
|
+
page = operation(*args, **kwargs)
|
|
50
|
+
yield page
|
|
51
|
+
if not _advance(page, kwargs):
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
def paginate(self, operation: Callable[..., Any], *args: Any, **kwargs: Any) -> Iterator[Any]:
|
|
55
|
+
"""Yields every item of a cursor-paginated operation across pages.
|
|
56
|
+
|
|
57
|
+
>>> for event in status302.paginate(status302.list_events, country="US"):
|
|
58
|
+
... print(event["name"])
|
|
59
|
+
"""
|
|
60
|
+
for page in self.pages(operation, *args, **kwargs):
|
|
61
|
+
yield from page["items"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AsyncStatus302(AsyncOperations):
|
|
65
|
+
"""Asyncio Status302 API client; also creates stream connections.
|
|
66
|
+
|
|
67
|
+
>>> async with AsyncStatus302(api_key=os.environ["STATUS302_API_KEY"]) as status302:
|
|
68
|
+
... page = await status302.list_events(limit=10)
|
|
69
|
+
|
|
70
|
+
``etag_cache`` works as on ``Status302``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
async def __aenter__(self) -> AsyncStatus302:
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
77
|
+
await self.aclose()
|
|
78
|
+
|
|
79
|
+
async def pages(self, operation: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
|
|
80
|
+
"""Yields every page of a paginated operation, following ``nextCursor`` or ``nextBefore``."""
|
|
81
|
+
while True:
|
|
82
|
+
page = await operation(*args, **kwargs)
|
|
83
|
+
yield page
|
|
84
|
+
if not _advance(page, kwargs):
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
async def paginate(self, operation: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
|
|
88
|
+
"""Yields every item of a cursor-paginated operation across pages."""
|
|
89
|
+
async for page in self.pages(operation, *args, **kwargs):
|
|
90
|
+
for item in page["items"]:
|
|
91
|
+
yield item
|
|
92
|
+
|
|
93
|
+
async def stream_ticket(self) -> str:
|
|
94
|
+
"""A single-use stream ticket (60 s) for a browser connection."""
|
|
95
|
+
return (await self.create_stream_ticket())["ticket"]
|
|
96
|
+
|
|
97
|
+
def stream(self, **options: Any) -> Status302Stream:
|
|
98
|
+
"""A realtime stream authenticated with this client's API key (see ``Status302Stream``)."""
|
|
99
|
+
options.setdefault("api_key", self._api_key)
|
|
100
|
+
return Status302Stream(client=self, **options)
|