checkanysite 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.
- checkanysite/__init__.py +48 -0
- checkanysite/_base_client.py +268 -0
- checkanysite/_client.py +130 -0
- checkanysite/_constants.py +49 -0
- checkanysite/_retries.py +60 -0
- checkanysite/_version.py +3 -0
- checkanysite/errors.py +228 -0
- checkanysite/models/__init__.py +33 -0
- checkanysite/models/_base.py +37 -0
- checkanysite/models/_generated.py +1329 -0
- checkanysite/models/batch.py +82 -0
- checkanysite/models/meta.py +119 -0
- checkanysite/py.typed +0 -0
- checkanysite/resources/__init__.py +19 -0
- checkanysite/resources/_params.py +38 -0
- checkanysite/resources/scans.py +338 -0
- checkanysite/resources/sites.py +120 -0
- checkanysite/resources/usage.py +28 -0
- checkanysite-0.1.0.dist-info/METADATA +245 -0
- checkanysite-0.1.0.dist-info/RECORD +22 -0
- checkanysite-0.1.0.dist-info/WHEEL +4 -0
- checkanysite-0.1.0.dist-info/licenses/LICENSE +21 -0
checkanysite/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The official Python library for the CheckAnySite API: what is this website, with evidence.
|
|
2
|
+
|
|
3
|
+
from checkanysite import CheckAnySite
|
|
4
|
+
|
|
5
|
+
client = CheckAnySite() # reads CHECKANYSITE_API_KEY
|
|
6
|
+
site = client.sites.retrieve("stripe.com", checks=["ai", "tech"])
|
|
7
|
+
|
|
8
|
+
Docs: https://docs.checkanysite.com. The answers' types are in `checkanysite.models`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import models
|
|
12
|
+
from ._client import AsyncCheckAnySite, CheckAnySite
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .errors import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APIError,
|
|
17
|
+
APIStatusError,
|
|
18
|
+
APITimeoutError,
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
BadRequestError,
|
|
21
|
+
ConflictError,
|
|
22
|
+
InternalServerError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
PermissionDeniedError,
|
|
25
|
+
RateLimitError,
|
|
26
|
+
ScanTimeoutError,
|
|
27
|
+
UnprocessableEntityError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"APIConnectionError",
|
|
32
|
+
"APIError",
|
|
33
|
+
"APIStatusError",
|
|
34
|
+
"APITimeoutError",
|
|
35
|
+
"AsyncCheckAnySite",
|
|
36
|
+
"AuthenticationError",
|
|
37
|
+
"BadRequestError",
|
|
38
|
+
"CheckAnySite",
|
|
39
|
+
"ConflictError",
|
|
40
|
+
"InternalServerError",
|
|
41
|
+
"NotFoundError",
|
|
42
|
+
"PermissionDeniedError",
|
|
43
|
+
"RateLimitError",
|
|
44
|
+
"ScanTimeoutError",
|
|
45
|
+
"UnprocessableEntityError",
|
|
46
|
+
"__version__",
|
|
47
|
+
"models",
|
|
48
|
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Sending a request to the API: authentication, idempotency keys, retries and typed answers.
|
|
2
|
+
|
|
3
|
+
What: `SyncAPIClient` and `AsyncAPIClient`, the transport the clients in _client.py build on.
|
|
4
|
+
Each call is one `httpx.Request` (with one Idempotency-Key for a POST), sent until it succeeds,
|
|
5
|
+
fails in a way that won't pass (_retries.py), or runs out of retries; a success is parsed into
|
|
6
|
+
its model with the answer's headers as `.meta`, an error answer into the class for its status.
|
|
7
|
+
Why one request object for every try: a retried POST must carry the same Idempotency-Key, so the
|
|
8
|
+
API replays the first answer instead of doing (and charging for) the work twice.
|
|
9
|
+
Not here: the endpoints (resources/) or the retry rules (_retries.py).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import contextlib
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from collections.abc import Awaitable, Mapping
|
|
20
|
+
from typing import Callable, Optional, TypeVar, Union
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from ._constants import API_KEY_ENV, BASE_URL_ENV, DEFAULT_BASE_URL, USER_AGENT
|
|
25
|
+
from ._retries import is_retryable_status, retry_delay_seconds
|
|
26
|
+
from .errors import (
|
|
27
|
+
APIConnectionError,
|
|
28
|
+
APIError,
|
|
29
|
+
APIStatusError,
|
|
30
|
+
APITimeoutError,
|
|
31
|
+
error_from_response,
|
|
32
|
+
)
|
|
33
|
+
from .models import ResponseMeta, ResponseModel
|
|
34
|
+
|
|
35
|
+
ResponseT = TypeVar("ResponseT", bound=ResponseModel)
|
|
36
|
+
|
|
37
|
+
Timeout = Union[float, httpx.Timeout]
|
|
38
|
+
"""Seconds for each of connecting, sending and reading, or an `httpx.Timeout` for each apart."""
|
|
39
|
+
|
|
40
|
+
RequestFailure = Union[APIStatusError, APIConnectionError]
|
|
41
|
+
"""How one try can fail: an error answer, or no answer."""
|
|
42
|
+
|
|
43
|
+
QueryParams = Mapping[str, Union[str, int]]
|
|
44
|
+
JsonBody = Mapping[str, object]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _BaseClient:
|
|
48
|
+
"""What the sync and async clients share: their settings, how a request is built and read."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
api_key: Optional[str],
|
|
54
|
+
base_url: Optional[str],
|
|
55
|
+
timeout: Timeout,
|
|
56
|
+
max_retries: int,
|
|
57
|
+
) -> None:
|
|
58
|
+
key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
|
|
59
|
+
if not key:
|
|
60
|
+
raise APIError(
|
|
61
|
+
f"No API key: pass api_key=... or set {API_KEY_ENV}. "
|
|
62
|
+
"Get one free at https://checkanysite.com/account/keys."
|
|
63
|
+
)
|
|
64
|
+
if max_retries < 0:
|
|
65
|
+
raise ValueError("max_retries must be 0 or more.")
|
|
66
|
+
self.api_key = key
|
|
67
|
+
self.base_url = (base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL).rstrip("/")
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
self.max_retries = max_retries
|
|
70
|
+
|
|
71
|
+
def _build_request(
|
|
72
|
+
self,
|
|
73
|
+
http: Union[httpx.Client, httpx.AsyncClient],
|
|
74
|
+
method: str,
|
|
75
|
+
path: str,
|
|
76
|
+
params: Optional[QueryParams],
|
|
77
|
+
body: Optional[JsonBody],
|
|
78
|
+
idempotency_key: Optional[str],
|
|
79
|
+
) -> httpx.Request:
|
|
80
|
+
headers = {
|
|
81
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
82
|
+
"Accept": "application/json",
|
|
83
|
+
"User-Agent": USER_AGENT,
|
|
84
|
+
}
|
|
85
|
+
if method == "POST":
|
|
86
|
+
# One key per call, reused by its retries, so a retried POST is never done twice.
|
|
87
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
88
|
+
return http.build_request(
|
|
89
|
+
method,
|
|
90
|
+
f"{self.base_url}{path}",
|
|
91
|
+
params=dict(params) if params else None,
|
|
92
|
+
json=dict(body) if body is not None else None,
|
|
93
|
+
headers=headers,
|
|
94
|
+
timeout=self.timeout,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _retry_delay(self, retries_done: int, error: RequestFailure) -> Optional[float]:
|
|
98
|
+
"""The wait before trying again after this error, or None to raise it now."""
|
|
99
|
+
if retries_done >= self.max_retries:
|
|
100
|
+
return None
|
|
101
|
+
if isinstance(error, APIStatusError):
|
|
102
|
+
if not is_retryable_status(error.status_code, error.code):
|
|
103
|
+
return None
|
|
104
|
+
return retry_delay_seconds(retries_done, error.retry_after_seconds)
|
|
105
|
+
# No answer at all (a connection error or a timeout): always worth another try.
|
|
106
|
+
return retry_delay_seconds(retries_done, None)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _OwnedClient(httpx.Client):
|
|
110
|
+
"""The `httpx.Client` a client makes for itself, closed when it is garbage collected.
|
|
111
|
+
|
|
112
|
+
Why: a client used without `with` or `close()`, as a script's one client often is, would keep
|
|
113
|
+
its pooled connections open until the interpreter exits, and Python warns about each one
|
|
114
|
+
(ResourceWarning: unclosed socket).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __del__(self) -> None:
|
|
118
|
+
if self.is_closed:
|
|
119
|
+
return
|
|
120
|
+
# Nothing can be reported from a finalizer, so a failure to close is let pass.
|
|
121
|
+
with contextlib.suppress(Exception):
|
|
122
|
+
self.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _OwnedAsyncClient(httpx.AsyncClient):
|
|
126
|
+
"""The `httpx.AsyncClient` an async client makes for itself, closed when it is collected
|
|
127
|
+
while its event loop still runs (`async with` closes it for sure)."""
|
|
128
|
+
|
|
129
|
+
def __del__(self) -> None:
|
|
130
|
+
if self.is_closed:
|
|
131
|
+
return
|
|
132
|
+
# Without a running loop (RuntimeError) there is nothing to close it on.
|
|
133
|
+
with contextlib.suppress(Exception):
|
|
134
|
+
asyncio.get_running_loop().create_task(self.aclose())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _connection_error(exception: httpx.RequestError) -> APIConnectionError:
|
|
138
|
+
error = (
|
|
139
|
+
APITimeoutError()
|
|
140
|
+
if isinstance(exception, httpx.TimeoutException)
|
|
141
|
+
else APIConnectionError(f"Could not reach the CheckAnySite API: {exception}")
|
|
142
|
+
)
|
|
143
|
+
error.__cause__ = exception
|
|
144
|
+
return error
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _parse(response: httpx.Response, cast_to: type[ResponseT]) -> ResponseT:
|
|
148
|
+
"""The successful answer as its model, carrying the answer's headers as `.meta`."""
|
|
149
|
+
try:
|
|
150
|
+
data = response.json()
|
|
151
|
+
except ValueError as exception:
|
|
152
|
+
raise APIError(
|
|
153
|
+
f"The API answered {response.status_code} without a JSON body.",
|
|
154
|
+
status_code=response.status_code,
|
|
155
|
+
request_id=response.headers.get("x-request-id"),
|
|
156
|
+
) from exception
|
|
157
|
+
model = cast_to.model_validate(data)
|
|
158
|
+
model._meta = ResponseMeta.from_headers(response.status_code, response.headers)
|
|
159
|
+
return model
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class SyncAPIClient(_BaseClient):
|
|
163
|
+
"""Sends requests with `httpx.Client`, waiting between retries with `time.sleep`."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
api_key: Optional[str],
|
|
169
|
+
base_url: Optional[str],
|
|
170
|
+
timeout: Timeout,
|
|
171
|
+
max_retries: int,
|
|
172
|
+
http_client: Optional[httpx.Client],
|
|
173
|
+
) -> None:
|
|
174
|
+
super().__init__(
|
|
175
|
+
api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries
|
|
176
|
+
)
|
|
177
|
+
self._owns_http_client = http_client is None
|
|
178
|
+
self._http = http_client if http_client is not None else _OwnedClient()
|
|
179
|
+
# The clock and the wait, as attributes so tests can stand in for them.
|
|
180
|
+
self._sleep: Callable[[float], None] = time.sleep
|
|
181
|
+
self._clock: Callable[[], float] = time.monotonic
|
|
182
|
+
|
|
183
|
+
def _request(
|
|
184
|
+
self,
|
|
185
|
+
method: str,
|
|
186
|
+
path: str,
|
|
187
|
+
*,
|
|
188
|
+
cast_to: type[ResponseT],
|
|
189
|
+
params: Optional[QueryParams] = None,
|
|
190
|
+
body: Optional[JsonBody] = None,
|
|
191
|
+
idempotency_key: Optional[str] = None,
|
|
192
|
+
) -> ResponseT:
|
|
193
|
+
request = self._build_request(self._http, method, path, params, body, idempotency_key)
|
|
194
|
+
retries_done = 0
|
|
195
|
+
while True:
|
|
196
|
+
error: RequestFailure
|
|
197
|
+
try:
|
|
198
|
+
response = self._http.send(request)
|
|
199
|
+
except httpx.RequestError as exception:
|
|
200
|
+
error = _connection_error(exception)
|
|
201
|
+
else:
|
|
202
|
+
if response.is_success:
|
|
203
|
+
return _parse(response, cast_to)
|
|
204
|
+
error = error_from_response(response)
|
|
205
|
+
delay = self._retry_delay(retries_done, error)
|
|
206
|
+
if delay is None:
|
|
207
|
+
raise error
|
|
208
|
+
self._sleep(delay)
|
|
209
|
+
retries_done += 1
|
|
210
|
+
|
|
211
|
+
def close(self) -> None:
|
|
212
|
+
"""Close the connections, unless the `http_client` was passed in (its owner closes it)."""
|
|
213
|
+
if self._owns_http_client:
|
|
214
|
+
self._http.close()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class AsyncAPIClient(_BaseClient):
|
|
218
|
+
"""Sends requests with `httpx.AsyncClient`, waiting between retries with `asyncio.sleep`."""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
api_key: Optional[str],
|
|
224
|
+
base_url: Optional[str],
|
|
225
|
+
timeout: Timeout,
|
|
226
|
+
max_retries: int,
|
|
227
|
+
http_client: Optional[httpx.AsyncClient],
|
|
228
|
+
) -> None:
|
|
229
|
+
super().__init__(
|
|
230
|
+
api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries
|
|
231
|
+
)
|
|
232
|
+
self._owns_http_client = http_client is None
|
|
233
|
+
self._http = http_client if http_client is not None else _OwnedAsyncClient()
|
|
234
|
+
self._sleep: Callable[[float], Awaitable[None]] = asyncio.sleep
|
|
235
|
+
self._clock: Callable[[], float] = time.monotonic
|
|
236
|
+
|
|
237
|
+
async def _request(
|
|
238
|
+
self,
|
|
239
|
+
method: str,
|
|
240
|
+
path: str,
|
|
241
|
+
*,
|
|
242
|
+
cast_to: type[ResponseT],
|
|
243
|
+
params: Optional[QueryParams] = None,
|
|
244
|
+
body: Optional[JsonBody] = None,
|
|
245
|
+
idempotency_key: Optional[str] = None,
|
|
246
|
+
) -> ResponseT:
|
|
247
|
+
request = self._build_request(self._http, method, path, params, body, idempotency_key)
|
|
248
|
+
retries_done = 0
|
|
249
|
+
while True:
|
|
250
|
+
error: RequestFailure
|
|
251
|
+
try:
|
|
252
|
+
response = await self._http.send(request)
|
|
253
|
+
except httpx.RequestError as exception:
|
|
254
|
+
error = _connection_error(exception)
|
|
255
|
+
else:
|
|
256
|
+
if response.is_success:
|
|
257
|
+
return _parse(response, cast_to)
|
|
258
|
+
error = error_from_response(response)
|
|
259
|
+
delay = self._retry_delay(retries_done, error)
|
|
260
|
+
if delay is None:
|
|
261
|
+
raise error
|
|
262
|
+
await self._sleep(delay)
|
|
263
|
+
retries_done += 1
|
|
264
|
+
|
|
265
|
+
async def close(self) -> None:
|
|
266
|
+
"""Close the connections, unless the `http_client` was passed in (its owner closes it)."""
|
|
267
|
+
if self._owns_http_client:
|
|
268
|
+
await self._http.aclose()
|
checkanysite/_client.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The two clients: `CheckAnySite` (sync) and `AsyncCheckAnySite` (async), one surface for both.
|
|
2
|
+
|
|
3
|
+
What: settings (key, base URL, timeout, retries) and the resources `sites`, `scans` and `usage`.
|
|
4
|
+
Not here: sending and retrying requests (_base_client.py) or the endpoints (resources/).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._base_client import AsyncAPIClient, SyncAPIClient, Timeout
|
|
15
|
+
from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS
|
|
16
|
+
from .resources import (
|
|
17
|
+
AsyncScansResource,
|
|
18
|
+
AsyncSitesResource,
|
|
19
|
+
AsyncUsageResource,
|
|
20
|
+
ScansResource,
|
|
21
|
+
SitesResource,
|
|
22
|
+
UsageResource,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CheckAnySite(SyncAPIClient):
|
|
27
|
+
"""The CheckAnySite API client.
|
|
28
|
+
|
|
29
|
+
Example:
|
|
30
|
+
>>> from checkanysite import CheckAnySite
|
|
31
|
+
>>> client = CheckAnySite() # reads CHECKANYSITE_API_KEY
|
|
32
|
+
>>> site = client.sites.retrieve("stripe.com", checks=["ai", "tech"])
|
|
33
|
+
>>> site.meta.credits_remaining
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
api_key: your key (`cas_live_…`); read from CHECKANYSITE_API_KEY when left out.
|
|
37
|
+
base_url: the API's address; CHECKANYSITE_BASE_URL, else https://api.checkanysite.com/v1.
|
|
38
|
+
timeout: seconds each request may take to connect, send and read (30 by default), or an
|
|
39
|
+
`httpx.Timeout`.
|
|
40
|
+
max_retries: retries after the first try for failures that may pass (2 by default; 0
|
|
41
|
+
turns retrying off).
|
|
42
|
+
http_client: an `httpx.Client` of your own (proxies, transports); you close it yourself.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
APIError: no API key was passed or found in the environment.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
sites: SitesResource
|
|
49
|
+
"""The Lookup API: stored results, at once, never a scan."""
|
|
50
|
+
scans: ScansResource
|
|
51
|
+
"""The Scan API: live scans, answered asynchronously."""
|
|
52
|
+
usage: UsageResource
|
|
53
|
+
"""This month's credits."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
api_key: Optional[str] = None,
|
|
59
|
+
base_url: Optional[str] = None,
|
|
60
|
+
timeout: Timeout = DEFAULT_TIMEOUT_SECONDS,
|
|
61
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
62
|
+
http_client: Optional[httpx.Client] = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(
|
|
65
|
+
api_key=api_key,
|
|
66
|
+
base_url=base_url,
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
max_retries=max_retries,
|
|
69
|
+
http_client=http_client,
|
|
70
|
+
)
|
|
71
|
+
self.sites = SitesResource(self)
|
|
72
|
+
self.scans = ScansResource(self)
|
|
73
|
+
self.usage = UsageResource(self)
|
|
74
|
+
|
|
75
|
+
def __enter__(self) -> CheckAnySite:
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def __exit__(
|
|
79
|
+
self,
|
|
80
|
+
exc_type: Optional[type[BaseException]],
|
|
81
|
+
exc: Optional[BaseException],
|
|
82
|
+
traceback: Optional[TracebackType],
|
|
83
|
+
) -> None:
|
|
84
|
+
self.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AsyncCheckAnySite(AsyncAPIClient):
|
|
88
|
+
"""The CheckAnySite API client for asyncio, with the same methods as `CheckAnySite`.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
>>> async with AsyncCheckAnySite() as client:
|
|
92
|
+
... site = await client.sites.retrieve("stripe.com", checks=["ai"])
|
|
93
|
+
|
|
94
|
+
Args: as for `CheckAnySite`, with an `httpx.AsyncClient` as `http_client`.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
sites: AsyncSitesResource
|
|
98
|
+
scans: AsyncScansResource
|
|
99
|
+
usage: AsyncUsageResource
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
api_key: Optional[str] = None,
|
|
105
|
+
base_url: Optional[str] = None,
|
|
106
|
+
timeout: Timeout = DEFAULT_TIMEOUT_SECONDS,
|
|
107
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
108
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
super().__init__(
|
|
111
|
+
api_key=api_key,
|
|
112
|
+
base_url=base_url,
|
|
113
|
+
timeout=timeout,
|
|
114
|
+
max_retries=max_retries,
|
|
115
|
+
http_client=http_client,
|
|
116
|
+
)
|
|
117
|
+
self.sites = AsyncSitesResource(self)
|
|
118
|
+
self.scans = AsyncScansResource(self)
|
|
119
|
+
self.usage = AsyncUsageResource(self)
|
|
120
|
+
|
|
121
|
+
async def __aenter__(self) -> AsyncCheckAnySite:
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
async def __aexit__(
|
|
125
|
+
self,
|
|
126
|
+
exc_type: Optional[type[BaseException]],
|
|
127
|
+
exc: Optional[BaseException],
|
|
128
|
+
traceback: Optional[TracebackType],
|
|
129
|
+
) -> None:
|
|
130
|
+
await self.close()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The library's defaults in one place: where the API is, how long to wait, how often to retry.
|
|
2
|
+
|
|
3
|
+
Each can be changed per client (`CheckAnySite(timeout=..., max_retries=...)`); the base URL and
|
|
4
|
+
the key can also come from the environment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._version import __version__
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.checkanysite.com/v1"
|
|
10
|
+
"""The API v1 (https://checkanysite.com/api/v1 answers the same)."""
|
|
11
|
+
|
|
12
|
+
API_KEY_ENV = "CHECKANYSITE_API_KEY"
|
|
13
|
+
"""The environment variable read when no `api_key` is passed."""
|
|
14
|
+
|
|
15
|
+
BASE_URL_ENV = "CHECKANYSITE_BASE_URL"
|
|
16
|
+
"""The environment variable read when no `base_url` is passed."""
|
|
17
|
+
|
|
18
|
+
USER_AGENT = f"checkanysite-python/{__version__}"
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
21
|
+
"""How long one request may wait for each of connecting, sending and reading."""
|
|
22
|
+
|
|
23
|
+
DEFAULT_MAX_RETRIES = 2
|
|
24
|
+
"""Retries after the first try, for failures that may pass (see _retries.py)."""
|
|
25
|
+
|
|
26
|
+
INITIAL_RETRY_DELAY_SECONDS = 0.5
|
|
27
|
+
"""The first retry's wait without a Retry-After header; it doubles with each retry."""
|
|
28
|
+
|
|
29
|
+
MAX_RETRY_DELAY_SECONDS = 8.0
|
|
30
|
+
"""The longest wait between retries without a Retry-After header."""
|
|
31
|
+
|
|
32
|
+
RETRY_JITTER_FRACTION = 0.25
|
|
33
|
+
"""Up to this share of a backoff wait is taken off at random, so clients don't retry in step."""
|
|
34
|
+
|
|
35
|
+
MAX_RETRY_AFTER_SECONDS = 60.0
|
|
36
|
+
"""The longest Retry-After the library waits out by itself; a longer one is raised to the caller.
|
|
37
|
+
|
|
38
|
+
The API's rate limit window is 60 seconds; a longer wait (no capacity for new scans until later)
|
|
39
|
+
is the caller's to decide.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
DEFAULT_WAIT_TIMEOUT_SECONDS = 120.0
|
|
43
|
+
"""How long `scans.create_and_wait` waits for a scan to be complete before raising."""
|
|
44
|
+
|
|
45
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 3.0
|
|
46
|
+
"""The wait between polls of a scan when the API sends no Retry-After (it sends 3 seconds)."""
|
|
47
|
+
|
|
48
|
+
MIN_POLL_INTERVAL_SECONDS = 1.0
|
|
49
|
+
"""Never poll a scan more than once a second, whatever Retry-After says (a polite client)."""
|
checkanysite/_retries.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""When a failed request is tried again, and after how long (ADR 0102).
|
|
2
|
+
|
|
3
|
+
What: two pure functions the sync and async clients share. A request is retried when the
|
|
4
|
+
failure may pass by itself:
|
|
5
|
+
- no answer (a connection error or a timeout);
|
|
6
|
+
- 409 `idempotency_key_in_use`: the first request with this key is still running;
|
|
7
|
+
- 429 only for `rate_limited`, never `quota_exceeded` or `key_limit_reached` (credits spent) or
|
|
8
|
+
`scan_limit_reached` (the day's new scans started);
|
|
9
|
+
- 500, 502, 503 and 504.
|
|
10
|
+
Retrying a POST is safe because it carries the same Idempotency-Key on every try.
|
|
11
|
+
Not here: the loop itself, which sleeps in the sync or async way (_base_client.py).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import random
|
|
17
|
+
from typing import Callable, Optional
|
|
18
|
+
|
|
19
|
+
from ._constants import (
|
|
20
|
+
INITIAL_RETRY_DELAY_SECONDS,
|
|
21
|
+
MAX_RETRY_AFTER_SECONDS,
|
|
22
|
+
MAX_RETRY_DELAY_SECONDS,
|
|
23
|
+
RETRY_JITTER_FRACTION,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
RETRYABLE_STATUSES = frozenset({500, 502, 503, 504})
|
|
27
|
+
"""Statuses retried whatever their code."""
|
|
28
|
+
|
|
29
|
+
RETRYABLE_CODES = {409: frozenset({"idempotency_key_in_use"}), 429: frozenset({"rate_limited"})}
|
|
30
|
+
"""Statuses retried only for these codes."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_retryable_status(status_code: int, code: Optional[str]) -> bool:
|
|
34
|
+
"""True when an error answer with this status and code may succeed if asked again."""
|
|
35
|
+
if status_code in RETRYABLE_STATUSES:
|
|
36
|
+
return True
|
|
37
|
+
return code is not None and code in RETRYABLE_CODES.get(status_code, frozenset())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def retry_delay_seconds(
|
|
41
|
+
retries_done: int,
|
|
42
|
+
retry_after_seconds: Optional[float],
|
|
43
|
+
random_fraction: Callable[[], float] = random.random,
|
|
44
|
+
) -> Optional[float]:
|
|
45
|
+
"""How long to wait before the next try, or None when the wait is the caller's to decide.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
retries_done: how many retries came before this one (0 for the first).
|
|
49
|
+
retry_after_seconds: the answer's Retry-After, if it sent one.
|
|
50
|
+
random_fraction: a source of numbers in [0, 1), for the jitter (passed in by tests).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The Retry-After when the API sent one it is reasonable to wait out (up to 60 seconds);
|
|
54
|
+
else an exponential backoff (0.5, 1, 2 … up to 8 seconds) with up to a quarter taken off
|
|
55
|
+
at random. None when the Retry-After is longer than the library waits by itself.
|
|
56
|
+
"""
|
|
57
|
+
if retry_after_seconds is not None:
|
|
58
|
+
return retry_after_seconds if retry_after_seconds <= MAX_RETRY_AFTER_SECONDS else None
|
|
59
|
+
backoff: float = min(INITIAL_RETRY_DELAY_SECONDS * 2.0**retries_done, MAX_RETRY_DELAY_SECONDS)
|
|
60
|
+
return backoff * (1 - RETRY_JITTER_FRACTION * random_fraction())
|
checkanysite/_version.py
ADDED