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.
Files changed (55) hide show
  1. {internetdata-2.0.0 → internetdata-2.1.0}/PKG-INFO +9 -1
  2. {internetdata-2.0.0 → internetdata-2.1.0}/README.md +8 -0
  3. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/__init__.py +1 -1
  4. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_core.py +131 -5
  5. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/aio.py +19 -18
  6. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/client.py +21 -18
  7. {internetdata-2.0.0 → internetdata-2.1.0}/tests/helpers.py +138 -1
  8. {internetdata-2.0.0 → internetdata-2.1.0}/tests/test_client.py +114 -1
  9. {internetdata-2.0.0 → internetdata-2.1.0}/tests/test_download.py +82 -6
  10. {internetdata-2.0.0 → internetdata-2.1.0}/.gitignore +0 -0
  11. {internetdata-2.0.0 → internetdata-2.1.0}/LICENSE +0 -0
  12. {internetdata-2.0.0 → internetdata-2.1.0}/pyproject.toml +0 -0
  13. {internetdata-2.0.0 → internetdata-2.1.0}/scripts/download-spec.sh +0 -0
  14. {internetdata-2.0.0 → internetdata-2.1.0}/scripts/generate.sh +0 -0
  15. {internetdata-2.0.0 → internetdata-2.1.0}/scripts/publish.sh +0 -0
  16. {internetdata-2.0.0 → internetdata-2.1.0}/scripts/v2_subset.py +0 -0
  17. {internetdata-2.0.0 → internetdata-2.1.0}/spec/openapi.yaml +0 -0
  18. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/__init__.py +0 -0
  19. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/__init__.py +0 -0
  20. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  21. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  22. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  23. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  24. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  25. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  26. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/client.py +0 -0
  27. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/errors.py +0 -0
  28. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/__init__.py +0 -0
  29. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database.py +0 -0
  30. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  31. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_format.py +0 -0
  32. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  33. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  34. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  35. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata.py +0 -0
  36. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  37. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  38. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  39. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  40. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  41. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/database_version.py +0 -0
  42. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/db_checksums.py +0 -0
  43. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/download.py +0 -0
  44. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/download_outcome.py +0 -0
  45. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/error.py +0 -0
  46. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  47. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  48. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/models/standing.py +0 -0
  49. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/_generated/types.py +0 -0
  50. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/errors.py +0 -0
  51. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/models.py +0 -0
  52. {internetdata-2.0.0 → internetdata-2.1.0}/src/internetdata/py.typed +0 -0
  53. {internetdata-2.0.0 → internetdata-2.1.0}/testdata/testdata.json +0 -0
  54. {internetdata-2.0.0 → internetdata-2.1.0}/tests/conftest.py +0 -0
  55. {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.0.0
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
@@ -26,7 +26,7 @@ from .models import (
26
26
  Standing,
27
27
  )
28
28
 
29
- __version__ = "2.0.0"
29
+ __version__ = "2.1.0"
30
30
 
31
31
  __all__ = [
32
32
  "DEFAULT_BASE_URL",
@@ -1,13 +1,17 @@
1
- """Plumbing the sync and the async client both need: transport wiring, response
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 = 10.0
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 lambda does nothing but call the generated function, which is what keeps this
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 send_async(lambda: list_databases.asyncio_detailed(client=self._client))
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 send_async(
141
- lambda: database_metadata_v2.asyncio_detailed(client=self._client, id=database_id)
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 send_async(
152
- lambda: database_checksum_v2.asyncio_detailed(
153
- client=self._client,
154
- id=database_id,
155
- format_=DatabaseFormat(format),
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 send_async(
167
- lambda: list_downloads.asyncio_detailed(client=self._client, limit=limit)
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 send_async(
185
- lambda: download_database_v2.asyncio_detailed(
186
- client=self._client,
187
- id=database_id,
188
- format_=DatabaseFormat(format),
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 = send(lambda: list_databases.sync_detailed(client=self._client))
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 = send(
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 = send(
165
- lambda: database_checksum_v2.sync_detailed(
166
- client=self._client,
167
- id=database_id,
168
- format_=DatabaseFormat(format),
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 = send(lambda: list_downloads.sync_detailed(client=self._client, limit=limit))
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 = send(
200
- lambda: download_database_v2.sync_detailed(
201
- client=self._client,
202
- id=database_id,
203
- format_=DatabaseFormat(format),
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 API, API_KEY, DOWNLOAD_PATH, ClientAdapter, ClientFactory
20
-
21
- from internetdata import InternetDataError
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(api_key=API_KEY, base_url=API, transport=served.transport)
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