internetdata 2.0.0__tar.gz → 2.1.0__tar.gz
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.
- {internetdata-2.0.0 → internetdata-2.1.0}/PKG-INFO +9 -1
- {internetdata-2.0.0 → internetdata-2.1.0}/README.md +8 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/__init__.py +1 -1
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_core.py +131 -5
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/aio.py +19 -18
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/client.py +21 -18
- {internetdata-2.0.0 → internetdata-2.1.0}/tests/helpers.py +138 -1
- {internetdata-2.0.0 → internetdata-2.1.0}/tests/test_client.py +114 -1
- {internetdata-2.0.0 → internetdata-2.1.0}/tests/test_download.py +82 -6
- {internetdata-2.0.0 → internetdata-2.1.0}/.gitignore +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/LICENSE +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/pyproject.toml +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/scripts/download-spec.sh +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/scripts/generate.sh +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/scripts/publish.sh +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/scripts/v2_subset.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/spec/openapi.yaml +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/__init__.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/__init__.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/client.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/errors.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/__init__.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_format.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_version.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/db_checksums.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/download.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/download_outcome.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/error.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/standing.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/types.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/errors.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/models.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/py.typed +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/testdata/testdata.json +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/tests/conftest.py +0 -0
- {internetdata-2.0.0 → internetdata-2.1.0}/tests/test_conformance.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: internetdata
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Official Python client library for the InternetData API. Download and verify licensed IP datasets.
|
|
5
5
|
Project-URL: Homepage, https://internetdata.io
|
|
6
6
|
Project-URL: Documentation, https://docs.internetdata.io
|
|
@@ -165,6 +165,14 @@ except InternetDataError as err:
|
|
|
165
165
|
|
|
166
166
|
`kind` is one of `bad_request`, `unauthorized`, `forbidden`, `rate_limited`, `quota_exceeded`, `server_error` or `network`. `message` is the API's own result code, passed through as it was sent, so you can switch on `NOT_LICENSED` against `LICENSE_EXPIRED` without reading the status.
|
|
167
167
|
|
|
168
|
+
A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
client = InternetData(api_key, timeout=10)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Changed in 2.1.0:** the default was 10 seconds, and it bounded each read of a response rather than the whole attempt, so a response trickling in slowly could run past it for as long as the server kept sending.
|
|
175
|
+
|
|
168
176
|
Note that `rate_limited` and `quota_exceeded` both arrive as HTTP 429 and are not the same thing. A rate limit is the API facing a traffic burst, and retrying later works; a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, and server and network failures, but never a spent quota or anything else you sent.
|
|
169
177
|
|
|
170
178
|
## Other Libraries
|
|
@@ -131,6 +131,14 @@ except InternetDataError as err:
|
|
|
131
131
|
|
|
132
132
|
`kind` is one of `bad_request`, `unauthorized`, `forbidden`, `rate_limited`, `quota_exceeded`, `server_error` or `network`. `message` is the API's own result code, passed through as it was sent, so you can switch on `NOT_LICENSED` against `LICENSE_EXPIRED` without reading the status.
|
|
133
133
|
|
|
134
|
+
A request that runs past its `timeout` fails with `network`, and is retried like any other network failure. The default is 30 seconds per attempt, body included, so a retried call can take longer in total. A database transfer is exempt, so `download` and `download_bytes` are never cut off part way through a large file. Set it on the client, in seconds, or pass `None` for no bound:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
client = InternetData(api_key, timeout=10)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Changed in 2.1.0:** the default was 10 seconds, and it bounded each read of a response rather than the whole attempt, so a response trickling in slowly could run past it for as long as the server kept sending.
|
|
141
|
+
|
|
134
142
|
Note that `rate_limited` and `quota_exceeded` both arrive as HTTP 429 and are not the same thing. A rate limit is the API facing a traffic burst, and retrying later works; a spent quota needs your allowance raised or the window to roll over. The library retries rate limits for you, and server and network failures, but never a spent quota or anything else you sent.
|
|
135
143
|
|
|
136
144
|
## Other Libraries
|
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
"""Plumbing the sync and the async client both need: transport wiring,
|
|
2
|
-
unwrapping, and the retry policy."""
|
|
1
|
+
"""Plumbing the sync and the async client both need: transport wiring, the deadline on
|
|
2
|
+
each attempt, response unwrapping, and the retry policy."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import asyncio
|
|
6
7
|
import contextlib
|
|
8
|
+
import contextvars
|
|
7
9
|
import json
|
|
8
10
|
import os
|
|
11
|
+
import threading
|
|
9
12
|
from collections.abc import Awaitable, Callable, Iterator
|
|
10
13
|
from pathlib import Path
|
|
14
|
+
from types import ModuleType
|
|
11
15
|
from typing import IO, Any, TypeVar, cast
|
|
12
16
|
|
|
13
17
|
import httpx
|
|
@@ -19,7 +23,7 @@ from .models import Database, Download, to_database, to_download
|
|
|
19
23
|
|
|
20
24
|
DEFAULT_BASE_URL = "https://internetdata.io"
|
|
21
25
|
DEFAULT_RETRIES = 2
|
|
22
|
-
DEFAULT_TIMEOUT =
|
|
26
|
+
DEFAULT_TIMEOUT = 30.0
|
|
23
27
|
DEFAULT_DOWNLOADS_LIMIT = 50
|
|
24
28
|
|
|
25
29
|
# One chunk of a transfer, and therefore the ceiling on what a download of any size
|
|
@@ -172,8 +176,10 @@ def send(call: Callable[[], Response[Any]]) -> Response[Any]:
|
|
|
172
176
|
something this client cannot read, which is a failed request rather than a bug in the
|
|
173
177
|
caller's code, so all three become the one error type here.
|
|
174
178
|
|
|
175
|
-
The
|
|
176
|
-
catch from swallowing a fault of our own.
|
|
179
|
+
The call does nothing but build one generated request, send it and hand the answer to
|
|
180
|
+
the generated decoder, which is what keeps this catch from swallowing a fault of our own.
|
|
181
|
+
An argument is converted before it, so a format this client does not publish is the
|
|
182
|
+
caller's `ValueError` rather than the server's failure.
|
|
177
183
|
"""
|
|
178
184
|
try:
|
|
179
185
|
return call()
|
|
@@ -189,6 +195,91 @@ async def send_async(call: Callable[[], Awaitable[Response[Any]]]) -> Response[A
|
|
|
189
195
|
raise malformed(exc) from exc
|
|
190
196
|
|
|
191
197
|
|
|
198
|
+
def request(
|
|
199
|
+
endpoint: ModuleType, client: AuthenticatedClient, bound: float | None, **params: Any
|
|
200
|
+
) -> Response[Any]:
|
|
201
|
+
"""`send` for one generated endpoint, one attempt of it finished within `bound` seconds,
|
|
202
|
+
or unbounded when that is None.
|
|
203
|
+
|
|
204
|
+
Assembled from the endpoint module's `_get_kwargs` and `_build_response` because its
|
|
205
|
+
`sync_detailed` sends through httpx alone, and httpx has no bound on the attempt.
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
def call() -> Response[Any]:
|
|
209
|
+
http = client.get_httpx_client()
|
|
210
|
+
req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
|
|
211
|
+
res = exchange(http, req, bound)
|
|
212
|
+
return cast(Response[Any], endpoint._build_response(client=client, response=res))
|
|
213
|
+
|
|
214
|
+
return send(call)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def request_async(
|
|
218
|
+
endpoint: ModuleType, client: AuthenticatedClient, bound: float | None, **params: Any
|
|
219
|
+
) -> Response[Any]:
|
|
220
|
+
"""`request`, awaited."""
|
|
221
|
+
|
|
222
|
+
async def call() -> Response[Any]:
|
|
223
|
+
http = client.get_async_httpx_client()
|
|
224
|
+
req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
|
|
225
|
+
res = await exchange_async(http, req, bound)
|
|
226
|
+
return cast(Response[Any], endpoint._build_response(client=client, response=res))
|
|
227
|
+
|
|
228
|
+
return await send_async(call)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def exchange(http: httpx.Client, req: httpx.Request, bound: float | None) -> httpx.Response:
|
|
232
|
+
"""One attempt at `req`, its whole body read, finished within `bound` seconds or failed
|
|
233
|
+
as a `network` error.
|
|
234
|
+
|
|
235
|
+
httpx bounds each PHASE of a request (connect, write, every read), not the attempt, so a
|
|
236
|
+
body trickling in a byte at a time outlasts any timeout it is given. The attempt runs on
|
|
237
|
+
a thread of its own and the caller waits at most `bound` for it; one abandoned stops at
|
|
238
|
+
its next chunk, or at the per-phase bound `req` also carries.
|
|
239
|
+
"""
|
|
240
|
+
if bound is None:
|
|
241
|
+
return http.send(req)
|
|
242
|
+
finished = threading.Event()
|
|
243
|
+
abandoned = threading.Event()
|
|
244
|
+
outcome: list[httpx.Response | BaseException] = []
|
|
245
|
+
context = contextvars.copy_context()
|
|
246
|
+
|
|
247
|
+
def attempt() -> None:
|
|
248
|
+
try:
|
|
249
|
+
outcome.append(context.run(_read_whole, http, req, abandoned))
|
|
250
|
+
except BaseException as exc: # noqa: BLE001 - raised again on the caller's thread
|
|
251
|
+
outcome.append(exc)
|
|
252
|
+
finally:
|
|
253
|
+
finished.set()
|
|
254
|
+
|
|
255
|
+
threading.Thread(target=attempt, name="internetdata-attempt", daemon=True).start()
|
|
256
|
+
try:
|
|
257
|
+
if not finished.wait(bound):
|
|
258
|
+
raise _deadline_passed(bound)
|
|
259
|
+
finally:
|
|
260
|
+
abandoned.set()
|
|
261
|
+
if isinstance(outcome[0], BaseException):
|
|
262
|
+
raise outcome[0]
|
|
263
|
+
return outcome[0]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def exchange_async(
|
|
267
|
+
http: httpx.AsyncClient, req: httpx.Request, bound: float | None
|
|
268
|
+
) -> httpx.Response:
|
|
269
|
+
"""`exchange`, awaited. Cancelling the attempt closes its connection, so no thread is
|
|
270
|
+
needed to leave it behind."""
|
|
271
|
+
if bound is None:
|
|
272
|
+
return await http.send(req)
|
|
273
|
+
deadline = asyncio.timeout(bound)
|
|
274
|
+
try:
|
|
275
|
+
async with deadline:
|
|
276
|
+
return await http.send(req)
|
|
277
|
+
except TimeoutError:
|
|
278
|
+
if not deadline.expired():
|
|
279
|
+
raise
|
|
280
|
+
raise _deadline_passed(bound) from None
|
|
281
|
+
|
|
282
|
+
|
|
192
283
|
def malformed(exc: Exception) -> InternetDataError:
|
|
193
284
|
return InternetDataError("server_error", f"malformed response from the API: {exc}")
|
|
194
285
|
|
|
@@ -273,6 +364,41 @@ def retry_delay(err: InternetDataError, attempt: int, retries: int) -> float | N
|
|
|
273
364
|
return _BACKOFF_BASE * (2.0**attempt)
|
|
274
365
|
|
|
275
366
|
|
|
367
|
+
def _read_whole(
|
|
368
|
+
http: httpx.Client, req: httpx.Request, abandoned: threading.Event
|
|
369
|
+
) -> httpx.Response:
|
|
370
|
+
res = http.send(req, stream=True)
|
|
371
|
+
try:
|
|
372
|
+
if isinstance(res.stream, httpx.SyncByteStream):
|
|
373
|
+
res.stream = _Abandonable(res.stream, abandoned, req)
|
|
374
|
+
res.read()
|
|
375
|
+
finally:
|
|
376
|
+
res.close()
|
|
377
|
+
return res
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class _Abandonable(httpx.SyncByteStream):
|
|
381
|
+
def __init__(
|
|
382
|
+
self, inner: httpx.SyncByteStream, abandoned: threading.Event, req: httpx.Request
|
|
383
|
+
) -> None:
|
|
384
|
+
self._inner = inner
|
|
385
|
+
self._abandoned = abandoned
|
|
386
|
+
self._req = req
|
|
387
|
+
|
|
388
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
389
|
+
for chunk in self._inner:
|
|
390
|
+
if self._abandoned.is_set():
|
|
391
|
+
raise httpx.ReadError("abandoned once its deadline passed", request=self._req)
|
|
392
|
+
yield chunk
|
|
393
|
+
|
|
394
|
+
def close(self) -> None:
|
|
395
|
+
self._inner.close()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _deadline_passed(bound: float) -> InternetDataError:
|
|
399
|
+
return InternetDataError("network", f"the request did not complete within {bound:g} seconds")
|
|
400
|
+
|
|
401
|
+
|
|
276
402
|
# The generated Response declares a plain MutableMapping, but always carries httpx's
|
|
277
403
|
# case-insensitive Headers. Rebuilding one keeps a header lookup case-blind whichever it
|
|
278
404
|
# turns out to be, which matters for `Retry-After`.
|
|
@@ -32,8 +32,8 @@ from ._core import (
|
|
|
32
32
|
parse_body,
|
|
33
33
|
part_file,
|
|
34
34
|
redirect_location,
|
|
35
|
+
request_async,
|
|
35
36
|
retry_delay,
|
|
36
|
-
send_async,
|
|
37
37
|
storage_refusal,
|
|
38
38
|
unwrap,
|
|
39
39
|
)
|
|
@@ -76,6 +76,7 @@ class AsyncInternetData:
|
|
|
76
76
|
self._client = build_client(api_key, base_url, timeout, transport)
|
|
77
77
|
self._transfer = build_async_transfer_client(timeout, transport)
|
|
78
78
|
self._retries = retries
|
|
79
|
+
self._timeout = timeout
|
|
79
80
|
self.database = AsyncDatabaseApi(self)
|
|
80
81
|
|
|
81
82
|
async def aclose(self) -> None:
|
|
@@ -128,7 +129,7 @@ class AsyncDatabaseApi:
|
|
|
128
129
|
"""
|
|
129
130
|
|
|
130
131
|
async def call() -> builtins.list[Database]:
|
|
131
|
-
res = await
|
|
132
|
+
res = await request_async(list_databases, self._client, self._owner._timeout)
|
|
132
133
|
return parse_body(unwrap(res), databases_of)
|
|
133
134
|
|
|
134
135
|
return await self._retrying(call)
|
|
@@ -137,8 +138,8 @@ class AsyncDatabaseApi:
|
|
|
137
138
|
"""What is inside one database: freshness, row count, columns, samples and sizes."""
|
|
138
139
|
|
|
139
140
|
async def call() -> DatabaseMetadata:
|
|
140
|
-
res = await
|
|
141
|
-
|
|
141
|
+
res = await request_async(
|
|
142
|
+
database_metadata_v2, self._client, self._owner._timeout, id=database_id
|
|
142
143
|
)
|
|
143
144
|
return parse_body(unwrap(res), to_metadata)
|
|
144
145
|
|
|
@@ -148,12 +149,12 @@ class AsyncDatabaseApi:
|
|
|
148
149
|
"""Every checksum published for one database file, keyed by algorithm."""
|
|
149
150
|
|
|
150
151
|
async def call() -> dict[str, str]:
|
|
151
|
-
res = await
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
152
|
+
res = await request_async(
|
|
153
|
+
database_checksum_v2,
|
|
154
|
+
self._client,
|
|
155
|
+
self._owner._timeout,
|
|
156
|
+
id=database_id,
|
|
157
|
+
format_=DatabaseFormat(format),
|
|
157
158
|
)
|
|
158
159
|
return parse_body(unwrap(res), checksums_of)
|
|
159
160
|
|
|
@@ -163,8 +164,8 @@ class AsyncDatabaseApi:
|
|
|
163
164
|
"""Your organization's recent download attempts, newest first."""
|
|
164
165
|
|
|
165
166
|
async def call() -> builtins.list[Download]:
|
|
166
|
-
res = await
|
|
167
|
-
|
|
167
|
+
res = await request_async(
|
|
168
|
+
list_downloads, self._client, self._owner._timeout, limit=limit
|
|
168
169
|
)
|
|
169
170
|
return parse_body(unwrap(res), downloads_of)
|
|
170
171
|
|
|
@@ -181,12 +182,12 @@ class AsyncDatabaseApi:
|
|
|
181
182
|
"""
|
|
182
183
|
|
|
183
184
|
async def call() -> str:
|
|
184
|
-
res = await
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
185
|
+
res = await request_async(
|
|
186
|
+
download_database_v2,
|
|
187
|
+
self._client,
|
|
188
|
+
self._owner._timeout,
|
|
189
|
+
id=database_id,
|
|
190
|
+
format_=DatabaseFormat(format),
|
|
190
191
|
)
|
|
191
192
|
return redirect_location(res)
|
|
192
193
|
|
|
@@ -27,8 +27,8 @@ from ._core import (
|
|
|
27
27
|
parse_body,
|
|
28
28
|
part_file,
|
|
29
29
|
redirect_location,
|
|
30
|
+
request,
|
|
30
31
|
retry_delay,
|
|
31
|
-
send,
|
|
32
32
|
storage_refusal,
|
|
33
33
|
unwrap,
|
|
34
34
|
)
|
|
@@ -58,6 +58,10 @@ class InternetData:
|
|
|
58
58
|
rather than an empty one: what this API serves without a licence is a product
|
|
59
59
|
decision, not the client's to refuse.
|
|
60
60
|
|
|
61
|
+
`timeout` is how long one attempt at a request may take, in seconds, body included, so a
|
|
62
|
+
call that is retried can take longer in total; None means no bound, and a database
|
|
63
|
+
transfer is exempt.
|
|
64
|
+
|
|
61
65
|
Holds an HTTP connection pool, so use it as a context manager or call `close()` when
|
|
62
66
|
you are done with it.
|
|
63
67
|
"""
|
|
@@ -77,6 +81,7 @@ class InternetData:
|
|
|
77
81
|
self._client = build_client(api_key, base_url, timeout, transport)
|
|
78
82
|
self._transfer = build_transfer_client(timeout, transport)
|
|
79
83
|
self._retries = retries
|
|
84
|
+
self._timeout = timeout
|
|
80
85
|
self.database = DatabaseApi(self)
|
|
81
86
|
|
|
82
87
|
def close(self) -> None:
|
|
@@ -133,7 +138,7 @@ class DatabaseApi:
|
|
|
133
138
|
"""
|
|
134
139
|
|
|
135
140
|
def call() -> builtins.list[Database]:
|
|
136
|
-
res =
|
|
141
|
+
res = request(list_databases, self._client, self._owner._timeout)
|
|
137
142
|
return parse_body(unwrap(res), databases_of)
|
|
138
143
|
|
|
139
144
|
return self._retrying(call)
|
|
@@ -146,9 +151,7 @@ class DatabaseApi:
|
|
|
146
151
|
"""
|
|
147
152
|
|
|
148
153
|
def call() -> DatabaseMetadata:
|
|
149
|
-
res =
|
|
150
|
-
lambda: database_metadata_v2.sync_detailed(client=self._client, id=database_id)
|
|
151
|
-
)
|
|
154
|
+
res = request(database_metadata_v2, self._client, self._owner._timeout, id=database_id)
|
|
152
155
|
return parse_body(unwrap(res), to_metadata)
|
|
153
156
|
|
|
154
157
|
return self._retrying(call)
|
|
@@ -161,12 +164,12 @@ class DatabaseApi:
|
|
|
161
164
|
"""
|
|
162
165
|
|
|
163
166
|
def call() -> dict[str, str]:
|
|
164
|
-
res =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)
|
|
167
|
+
res = request(
|
|
168
|
+
database_checksum_v2,
|
|
169
|
+
self._client,
|
|
170
|
+
self._owner._timeout,
|
|
171
|
+
id=database_id,
|
|
172
|
+
format_=DatabaseFormat(format),
|
|
170
173
|
)
|
|
171
174
|
return parse_body(unwrap(res), checksums_of)
|
|
172
175
|
|
|
@@ -180,7 +183,7 @@ class DatabaseApi:
|
|
|
180
183
|
"""
|
|
181
184
|
|
|
182
185
|
def call() -> builtins.list[Download]:
|
|
183
|
-
res =
|
|
186
|
+
res = request(list_downloads, self._client, self._owner._timeout, limit=limit)
|
|
184
187
|
return parse_body(unwrap(res), downloads_of)
|
|
185
188
|
|
|
186
189
|
return self._retrying(call)
|
|
@@ -196,12 +199,12 @@ class DatabaseApi:
|
|
|
196
199
|
"""
|
|
197
200
|
|
|
198
201
|
def call() -> str:
|
|
199
|
-
res =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
)
|
|
202
|
+
res = request(
|
|
203
|
+
download_database_v2,
|
|
204
|
+
self._client,
|
|
205
|
+
self._owner._timeout,
|
|
206
|
+
id=database_id,
|
|
207
|
+
format_=DatabaseFormat(format),
|
|
205
208
|
)
|
|
206
209
|
return redirect_location(res)
|
|
207
210
|
|
|
@@ -10,10 +10,15 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
12
|
import json
|
|
13
|
+
import socket
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
13
17
|
from pathlib import Path
|
|
14
|
-
from typing import Any
|
|
18
|
+
from typing import Any, Self
|
|
15
19
|
|
|
16
20
|
import httpx
|
|
21
|
+
import pytest
|
|
17
22
|
|
|
18
23
|
from internetdata import AsyncInternetData, InternetData
|
|
19
24
|
|
|
@@ -152,6 +157,138 @@ class Stub:
|
|
|
152
157
|
return bodies[min(self._served(self.requests[-1].url.path) - 1, len(bodies) - 1)]
|
|
153
158
|
|
|
154
159
|
|
|
160
|
+
class LocalServer:
|
|
161
|
+
"""A real socket on 127.0.0.1, answering each connection's one request from a thread.
|
|
162
|
+
|
|
163
|
+
What a deadline is tested against: `MockTransport` never consults a timeout. Past
|
|
164
|
+
`MAX_REQUESTS` it stops accepting, so a retry loop that never ends waits for good instead
|
|
165
|
+
of spinning, and `settle` fails the test from outside it.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
MAX_REQUESTS = 20
|
|
169
|
+
|
|
170
|
+
def __init__(self) -> None:
|
|
171
|
+
self._listener = socket.create_server(("127.0.0.1", 0))
|
|
172
|
+
self._listener.settimeout(0.05)
|
|
173
|
+
host, port = self._listener.getsockname()[:2]
|
|
174
|
+
self.url = f"http://{host}:{port}"
|
|
175
|
+
self.paths: list[str] = []
|
|
176
|
+
self.closed = threading.Event()
|
|
177
|
+
threading.Thread(target=self._serve, daemon=True).start()
|
|
178
|
+
|
|
179
|
+
def __enter__(self) -> Self:
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
183
|
+
self.closed.set()
|
|
184
|
+
self._listener.close()
|
|
185
|
+
|
|
186
|
+
def answer(self, conn: socket.socket, path: str) -> None:
|
|
187
|
+
raise NotImplementedError
|
|
188
|
+
|
|
189
|
+
def _serve(self) -> None:
|
|
190
|
+
while not self.closed.is_set():
|
|
191
|
+
if len(self.paths) >= self.MAX_REQUESTS:
|
|
192
|
+
self.closed.wait(0.05)
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
conn, _ = self._listener.accept()
|
|
196
|
+
except TimeoutError:
|
|
197
|
+
continue
|
|
198
|
+
except OSError:
|
|
199
|
+
return
|
|
200
|
+
threading.Thread(target=self._handle, args=(conn,), daemon=True).start()
|
|
201
|
+
|
|
202
|
+
def _handle(self, conn: socket.socket) -> None:
|
|
203
|
+
with conn:
|
|
204
|
+
try:
|
|
205
|
+
conn.settimeout(3.0)
|
|
206
|
+
request_line = conn.recv(65536).decode("latin-1").split("\r\n", 1)[0]
|
|
207
|
+
path = request_line.split(" ")[1].split("?", 1)[0] if " " in request_line else ""
|
|
208
|
+
self.paths.append(path)
|
|
209
|
+
self.answer(conn, path)
|
|
210
|
+
except OSError:
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class SlowBody(LocalServer):
|
|
215
|
+
"""Answers every request with a 200's headers and the first byte of its body, then a
|
|
216
|
+
byte every `trickle` seconds, or nothing more when that is None.
|
|
217
|
+
|
|
218
|
+
What httpx's own timeout cannot bound: it limits each read rather than the attempt, so a
|
|
219
|
+
trickle faster than the bound resets it forever. Each response gives up after
|
|
220
|
+
`for_at_most` seconds, so a client that stops honoring its bound fails its test instead
|
|
221
|
+
of hanging the suite.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(self, trickle: float | None, for_at_most: float = 3.0) -> None:
|
|
225
|
+
self._trickle = trickle
|
|
226
|
+
self._for_at_most = for_at_most
|
|
227
|
+
super().__init__()
|
|
228
|
+
|
|
229
|
+
def answer(self, conn: socket.socket, path: str) -> None:
|
|
230
|
+
give_up = time.monotonic() + self._for_at_most
|
|
231
|
+
conn.sendall(
|
|
232
|
+
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
|
|
233
|
+
b"Content-Length: 100000\r\nConnection: close\r\n\r\n{"
|
|
234
|
+
)
|
|
235
|
+
gap = self._for_at_most if self._trickle is None else self._trickle
|
|
236
|
+
while not self.closed.wait(gap) and time.monotonic() < give_up:
|
|
237
|
+
if self._trickle is not None:
|
|
238
|
+
conn.sendall(b" ")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class SlowTransfer(LocalServer):
|
|
242
|
+
"""The API's `302` to a blob on this same server, and that blob in two halves with
|
|
243
|
+
`pause` seconds between them.
|
|
244
|
+
|
|
245
|
+
A pause longer than a client's timeout outlasts it twice over: in total, and as the one
|
|
246
|
+
gap between two reads. So neither a deadline over the transfer nor a bound on each read
|
|
247
|
+
can pass unseen.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
BODY = bytes(range(256)) * 64
|
|
251
|
+
|
|
252
|
+
def __init__(self, pause: float) -> None:
|
|
253
|
+
self._pause = pause
|
|
254
|
+
super().__init__()
|
|
255
|
+
|
|
256
|
+
def answer(self, conn: socket.socket, path: str) -> None:
|
|
257
|
+
if path == DOWNLOAD_PATH:
|
|
258
|
+
conn.sendall(
|
|
259
|
+
f"HTTP/1.1 302 Found\r\nLocation: {self.url}/blob\r\n"
|
|
260
|
+
"Content-Length: 0\r\nConnection: close\r\n\r\n".encode()
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
half = len(self.BODY) // 2
|
|
264
|
+
conn.sendall(
|
|
265
|
+
b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n"
|
|
266
|
+
+ f"Content-Length: {len(self.BODY)}\r\nConnection: close\r\n\r\n".encode()
|
|
267
|
+
+ self.BODY[:half]
|
|
268
|
+
)
|
|
269
|
+
if not self.closed.wait(self._pause):
|
|
270
|
+
conn.sendall(self.BODY[half:])
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def settle(call: Callable[[], Any], within: float = 10.0) -> Any:
|
|
274
|
+
"""What `call` returned, or the exception it raised, run on a thread of its own so a
|
|
275
|
+
call that never ends fails the test instead of hanging the suite."""
|
|
276
|
+
outcome: list[Any] = []
|
|
277
|
+
|
|
278
|
+
def run() -> None:
|
|
279
|
+
try:
|
|
280
|
+
outcome.append(call())
|
|
281
|
+
except BaseException as exc: # noqa: BLE001 - the outcome under test
|
|
282
|
+
outcome.append(exc)
|
|
283
|
+
|
|
284
|
+
worker = threading.Thread(target=run, daemon=True)
|
|
285
|
+
worker.start()
|
|
286
|
+
worker.join(within)
|
|
287
|
+
if worker.is_alive():
|
|
288
|
+
pytest.fail(f"the call under test did not settle within {within}s")
|
|
289
|
+
return outcome[0]
|
|
290
|
+
|
|
291
|
+
|
|
155
292
|
def database(base: str, **overrides: Any) -> dict[str, Any]:
|
|
156
293
|
"""One served catalog entry, in the wire shape, with the boring fields filled in."""
|
|
157
294
|
entry: dict[str, Any] = {
|
|
@@ -4,21 +4,29 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import dataclasses
|
|
6
6
|
import datetime
|
|
7
|
+
import inspect
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
7
11
|
|
|
8
12
|
import httpx
|
|
9
13
|
import pytest
|
|
10
14
|
from helpers import (
|
|
11
15
|
API,
|
|
12
16
|
API_KEY,
|
|
17
|
+
CHECKSUM_PATH,
|
|
13
18
|
DOWNLOADS_PATH,
|
|
14
19
|
LIST_PATH,
|
|
15
20
|
METADATA_PATH,
|
|
21
|
+
ClientAdapter,
|
|
16
22
|
ClientFactory,
|
|
23
|
+
SlowBody,
|
|
17
24
|
Stub,
|
|
18
25
|
database,
|
|
26
|
+
settle,
|
|
19
27
|
)
|
|
20
28
|
|
|
21
|
-
from internetdata import InternetData, InternetDataError
|
|
29
|
+
from internetdata import AsyncInternetData, InternetData, InternetDataError, _core
|
|
22
30
|
|
|
23
31
|
METADATA = {
|
|
24
32
|
"id": "bogon_ip_v1",
|
|
@@ -62,6 +70,22 @@ DOWNLOADS = {
|
|
|
62
70
|
]
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
# Far under SlowBody's three seconds, so a client that ignores its bound fails on how long
|
|
74
|
+
# the call took rather than on the server giving up.
|
|
75
|
+
TIMEOUT = 0.3
|
|
76
|
+
# A byte every 20 ms, so no single read ever waits long enough for httpx's own bound.
|
|
77
|
+
TRICKLE = 0.02
|
|
78
|
+
# What starting a thread or an event loop may add to a deadline.
|
|
79
|
+
SLACK = 0.25
|
|
80
|
+
|
|
81
|
+
JSON_CALLS: dict[str, Callable[[ClientAdapter], Any]] = {
|
|
82
|
+
"list": lambda client: client.database.list(),
|
|
83
|
+
"metadata": lambda client: client.database.metadata("bogon_ip_v1"),
|
|
84
|
+
"checksums": lambda client: client.database.checksums("bogon_ip_v1", "csvgz"),
|
|
85
|
+
"downloads": lambda client: client.database.downloads(),
|
|
86
|
+
"download_url": lambda client: client.database.download_url("bogon_ip_v1", "csvgz"),
|
|
87
|
+
}
|
|
88
|
+
|
|
65
89
|
|
|
66
90
|
def test_metadata_is_typed_rather_than_a_bag_of_strings(make_client: ClientFactory) -> None:
|
|
67
91
|
stub = Stub({METADATA_PATH: {"body": METADATA}})
|
|
@@ -212,6 +236,76 @@ def test_a_transport_failure_surfaces_as_a_network_error() -> None:
|
|
|
212
236
|
assert caught.value.retryable is True
|
|
213
237
|
|
|
214
238
|
|
|
239
|
+
@pytest.mark.parametrize("call", JSON_CALLS)
|
|
240
|
+
def test_the_timeout_bounds_a_trickling_body_on_every_json_call(
|
|
241
|
+
make_client: ClientFactory, call: str
|
|
242
|
+
) -> None:
|
|
243
|
+
with SlowBody(trickle=TRICKLE) as server:
|
|
244
|
+
client = make_client(base_url=server.url, timeout=TIMEOUT, retries=0)
|
|
245
|
+
elapsed, outcome = _timed(lambda: JSON_CALLS[call](client))
|
|
246
|
+
|
|
247
|
+
_assert_timed_out(outcome)
|
|
248
|
+
_assert_one_bound(elapsed)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# httpx's per-read bound catches a full stall on its own, so this passes without the
|
|
252
|
+
# deadline; the trickle above is what proves the deadline exists.
|
|
253
|
+
def test_a_body_that_stalls_after_its_headers_is_bounded(make_client: ClientFactory) -> None:
|
|
254
|
+
with SlowBody(trickle=None) as server:
|
|
255
|
+
client = make_client(base_url=server.url, timeout=TIMEOUT, retries=0)
|
|
256
|
+
elapsed, outcome = _timed(client.database.list)
|
|
257
|
+
|
|
258
|
+
_assert_timed_out(outcome)
|
|
259
|
+
_assert_one_bound(elapsed)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_a_second_call_is_held_to_the_clients_bound_too(make_client: ClientFactory) -> None:
|
|
263
|
+
with SlowBody(trickle=TRICKLE) as server:
|
|
264
|
+
client = make_client(base_url=server.url, timeout=TIMEOUT, retries=0)
|
|
265
|
+
first, first_outcome = _timed(client.database.list)
|
|
266
|
+
second, second_outcome = _timed(client.database.list)
|
|
267
|
+
|
|
268
|
+
_assert_timed_out(first_outcome)
|
|
269
|
+
_assert_one_bound(first)
|
|
270
|
+
_assert_timed_out(second_outcome)
|
|
271
|
+
_assert_one_bound(second)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# The bound is on each ATTEMPT, so a retried call takes one bound per attempt in total.
|
|
275
|
+
def test_a_timed_out_attempt_is_retried_under_a_bound_of_its_own(
|
|
276
|
+
make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch
|
|
277
|
+
) -> None:
|
|
278
|
+
monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
|
|
279
|
+
with SlowBody(trickle=TRICKLE) as server:
|
|
280
|
+
client = make_client(base_url=server.url, timeout=TIMEOUT, retries=1)
|
|
281
|
+
elapsed, outcome = _timed(client.database.list)
|
|
282
|
+
|
|
283
|
+
assert server.paths == [LIST_PATH, LIST_PATH], "a timed-out attempt was not retried"
|
|
284
|
+
_assert_timed_out(outcome)
|
|
285
|
+
assert 2 * TIMEOUT - 0.05 <= elapsed < 2 * TIMEOUT + SLACK, (
|
|
286
|
+
f"two attempts took {elapsed:.2f}s, not one bound each"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_the_default_timeout_is_thirty_seconds() -> None:
|
|
291
|
+
for client in (InternetData, AsyncInternetData):
|
|
292
|
+
assert inspect.signature(client).parameters["timeout"].default == 30
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# Refused before the network, and never retried: a typo is the caller's, not a failure of
|
|
296
|
+
# the server's to wait out.
|
|
297
|
+
def test_an_unpublished_format_is_refused_before_any_request(make_client: ClientFactory) -> None:
|
|
298
|
+
stub = Stub({CHECKSUM_PATH: {"body": {"checksums": {"sha256": "00"}}}})
|
|
299
|
+
client = make_client(transport=stub.transport, retries=2)
|
|
300
|
+
|
|
301
|
+
started = time.monotonic()
|
|
302
|
+
with pytest.raises(ValueError, match="zip"):
|
|
303
|
+
client.database.checksums("bogon_ip_v1", "zip")
|
|
304
|
+
|
|
305
|
+
assert stub.requests == []
|
|
306
|
+
assert time.monotonic() - started < 0.5, "the refusal was retried"
|
|
307
|
+
|
|
308
|
+
|
|
215
309
|
def test_the_base_url_is_overridable(make_client: ClientFactory) -> None:
|
|
216
310
|
stub = Stub({LIST_PATH: {"body": {"databases": []}}})
|
|
217
311
|
client = make_client(transport=stub.transport)
|
|
@@ -236,3 +330,22 @@ def test_a_value_this_client_was_not_generated_for_stays_inside_the_error_type(
|
|
|
236
330
|
|
|
237
331
|
assert caught.value.kind == "server_error"
|
|
238
332
|
assert "trial" in caught.value.message, "the message does not say which value it choked on"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _timed(call: Callable[[], Any]) -> tuple[float, Any]:
|
|
336
|
+
"""How long `call` took to settle, and what it settled with."""
|
|
337
|
+
started = time.monotonic()
|
|
338
|
+
outcome = settle(call)
|
|
339
|
+
return time.monotonic() - started, outcome
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _assert_timed_out(outcome: Any) -> None:
|
|
343
|
+
assert isinstance(outcome, InternetDataError), f"settled with {outcome!r}"
|
|
344
|
+
assert outcome.kind == "network", outcome
|
|
345
|
+
assert outcome.retryable is True
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _assert_one_bound(elapsed: float) -> None:
|
|
349
|
+
assert TIMEOUT - 0.05 <= elapsed < TIMEOUT + SLACK, (
|
|
350
|
+
f"gave up after {elapsed:.2f}s against a {TIMEOUT}s bound"
|
|
351
|
+
)
|
|
@@ -9,6 +9,7 @@ and where it did not.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import gzip
|
|
12
|
+
import time
|
|
12
13
|
import tracemalloc
|
|
13
14
|
from collections.abc import AsyncIterator, Iterator
|
|
14
15
|
from dataclasses import dataclass
|
|
@@ -16,9 +17,17 @@ from pathlib import Path
|
|
|
16
17
|
|
|
17
18
|
import httpx
|
|
18
19
|
import pytest
|
|
19
|
-
from helpers import
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
from helpers import (
|
|
21
|
+
API,
|
|
22
|
+
API_KEY,
|
|
23
|
+
DOWNLOAD_PATH,
|
|
24
|
+
ClientAdapter,
|
|
25
|
+
ClientFactory,
|
|
26
|
+
SlowTransfer,
|
|
27
|
+
settle,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from internetdata import InternetDataError, _core
|
|
22
31
|
|
|
23
32
|
STORAGE = "https://storage.example"
|
|
24
33
|
BLOB = f"{STORAGE}/blob?X-Amz-Signature=deadbeef"
|
|
@@ -35,7 +44,8 @@ class Blob:
|
|
|
35
44
|
A `size` of zero is the small CSV nearly every test wants. Above that the body is
|
|
36
45
|
synthetic, and `delivered` under `size` promises a whole database and cuts it short,
|
|
37
46
|
which is the silent truncation a client has to catch. `dies` makes that shortfall
|
|
38
|
-
arrive as a dropped connection instead of a clean end of body.
|
|
47
|
+
arrive as a dropped connection instead of a clean end of body. `refusals` is how many
|
|
48
|
+
requests are answered 503 before the blob is served.
|
|
39
49
|
"""
|
|
40
50
|
|
|
41
51
|
size: int = 0
|
|
@@ -43,6 +53,7 @@ class Blob:
|
|
|
43
53
|
dies: bool = False
|
|
44
54
|
status: int = 200
|
|
45
55
|
encoding: str | None = None
|
|
56
|
+
refusals: int = 0
|
|
46
57
|
|
|
47
58
|
|
|
48
59
|
class Origin:
|
|
@@ -75,6 +86,8 @@ class Origin:
|
|
|
75
86
|
return httpx.Response(302, headers={"Location": BLOB})
|
|
76
87
|
if str(request.url) != BLOB:
|
|
77
88
|
return httpx.Response(404, json={"rc": "UNKNOWN_DATASET"})
|
|
89
|
+
if self.paths().count("/blob") <= self.blob.refusals:
|
|
90
|
+
return httpx.Response(503)
|
|
78
91
|
if self.blob.status != 200:
|
|
79
92
|
return httpx.Response(self.blob.status, content=b"<Error><Code>AccessDenied</Code>")
|
|
80
93
|
|
|
@@ -118,9 +131,11 @@ class BlobStream(httpx.SyncByteStream, httpx.AsyncByteStream):
|
|
|
118
131
|
raise httpx.ReadError("the connection dropped mid-transfer")
|
|
119
132
|
|
|
120
133
|
|
|
121
|
-
def origin(make_client: ClientFactory, blob: Blob | None = None) -> Origin:
|
|
134
|
+
def origin(make_client: ClientFactory, blob: Blob | None = None, retries: int = 2) -> Origin:
|
|
122
135
|
served = Origin(blob or Blob())
|
|
123
|
-
served.client = make_client(
|
|
136
|
+
served.client = make_client(
|
|
137
|
+
api_key=API_KEY, base_url=API, transport=served.transport, retries=retries
|
|
138
|
+
)
|
|
124
139
|
return served
|
|
125
140
|
|
|
126
141
|
|
|
@@ -289,3 +304,64 @@ def test_a_content_encoded_body_is_not_mistaken_for_a_short_one(
|
|
|
289
304
|
served = origin(make_client, Blob(encoding="gzip"))
|
|
290
305
|
|
|
291
306
|
assert served.client.database.download_bytes("bogon_ip_v1", "csvgz") == SMALL
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# Only the response HEAD of a transfer is retried. A 5xx there has written nothing, so it is
|
|
310
|
+
# as transient as the API's; a body that dies part way is never fetched again, or the second
|
|
311
|
+
# copy would append to the bytes already written. Each half pins the other, so neither passes
|
|
312
|
+
# vacuously, and each counts storage requests before it looks at the outcome.
|
|
313
|
+
def test_a_storage_5xx_before_the_body_is_retried(
|
|
314
|
+
make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch
|
|
315
|
+
) -> None:
|
|
316
|
+
monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
|
|
317
|
+
served = origin(make_client, Blob(refusals=1), retries=2)
|
|
318
|
+
|
|
319
|
+
outcome: bytes | Exception
|
|
320
|
+
try:
|
|
321
|
+
outcome = served.client.database.download_bytes("bogon_ip_v1", "csvgz")
|
|
322
|
+
except (InternetDataError, httpx.HTTPError) as exc:
|
|
323
|
+
outcome = exc
|
|
324
|
+
|
|
325
|
+
assert served.paths().count("/blob") == 2, "object storage should see the 503 and its retry"
|
|
326
|
+
assert outcome == SMALL
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@pytest.mark.parametrize("method", ["download", "download_bytes"])
|
|
330
|
+
def test_a_transfer_that_dies_part_way_is_not_fetched_again(
|
|
331
|
+
make_client: ClientFactory, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, method: str
|
|
332
|
+
) -> None:
|
|
333
|
+
monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
|
|
334
|
+
served = origin(make_client, Blob(size=4 << 20, delivered=1 << 20, dies=True), retries=2)
|
|
335
|
+
rest = (tmp_path / "dies-once.csv.gz",) if method == "download" else ()
|
|
336
|
+
|
|
337
|
+
failure: Exception | None = None
|
|
338
|
+
try:
|
|
339
|
+
getattr(served.client.database, method)("bogon_ip_v1", "csvgz", *rest)
|
|
340
|
+
except (InternetDataError, httpx.HTTPError) as exc:
|
|
341
|
+
failure = exc
|
|
342
|
+
|
|
343
|
+
assert served.paths().count("/blob") == 1, "a body that died part way was fetched again"
|
|
344
|
+
assert isinstance(failure, httpx.ReadError), f"got {failure!r}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# A transfer runs to gigabytes and minutes, so the timeout that bounds every JSON call must
|
|
348
|
+
# cut one off neither in total nor at a gap between two reads. On a real socket, because a
|
|
349
|
+
# mocked transport never consults a timeout.
|
|
350
|
+
@pytest.mark.parametrize("method", ["download", "download_bytes"])
|
|
351
|
+
def test_a_transfer_that_outlasts_the_timeout_still_completes(
|
|
352
|
+
make_client: ClientFactory, tmp_path: Path, method: str
|
|
353
|
+
) -> None:
|
|
354
|
+
timeout = 0.3
|
|
355
|
+
path = tmp_path / "slow.mmdb"
|
|
356
|
+
rest = (path,) if method == "download" else ()
|
|
357
|
+
with SlowTransfer(pause=2 * timeout) as server:
|
|
358
|
+
client = make_client(base_url=server.url, timeout=timeout, retries=0)
|
|
359
|
+
started = time.monotonic()
|
|
360
|
+
outcome = settle(lambda: getattr(client.database, method)("bogon_ip_v1", "mmdb", *rest))
|
|
361
|
+
elapsed = time.monotonic() - started
|
|
362
|
+
|
|
363
|
+
assert server.paths == [DOWNLOAD_PATH, "/blob"]
|
|
364
|
+
assert not isinstance(outcome, BaseException), f"the transfer failed: {outcome!r}"
|
|
365
|
+
assert elapsed > 2 * timeout, f"took {elapsed:.2f}s, inside the timeout, so this proves nothing"
|
|
366
|
+
held = path.read_bytes() if method == "download" else outcome
|
|
367
|
+
assert held == SlowTransfer.BODY
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_format.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_version.py
RENAMED
|
File without changes
|
{internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/db_checksums.py
RENAMED
|
File without changes
|
|
File without changes
|
{internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/download_outcome.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|