internetdata 2.7.0__tar.gz → 2.7.2__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 (95) hide show
  1. {internetdata-2.7.0 → internetdata-2.7.2}/PKG-INFO +1 -1
  2. {internetdata-2.7.0 → internetdata-2.7.2}/spec/openapi.yaml +5 -2
  3. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/__init__.py +1 -1
  4. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_core.py +78 -73
  5. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +12 -4
  6. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/aio.py +4 -5
  7. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/client.py +4 -5
  8. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/errors.py +58 -14
  9. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/models.py +35 -18
  10. {internetdata-2.7.0 → internetdata-2.7.2}/tests/helpers.py +5 -0
  11. {internetdata-2.7.0 → internetdata-2.7.2}/tests/test_client.py +218 -2
  12. {internetdata-2.7.0 → internetdata-2.7.2}/tests/test_conformance.py +24 -1
  13. {internetdata-2.7.0 → internetdata-2.7.2}/.gitignore +0 -0
  14. {internetdata-2.7.0 → internetdata-2.7.2}/LICENSE +0 -0
  15. {internetdata-2.7.0 → internetdata-2.7.2}/README.md +0 -0
  16. {internetdata-2.7.0 → internetdata-2.7.2}/pyproject.toml +0 -0
  17. {internetdata-2.7.0 → internetdata-2.7.2}/scripts/download-spec.sh +0 -0
  18. {internetdata-2.7.0 → internetdata-2.7.2}/scripts/generate.sh +0 -0
  19. {internetdata-2.7.0 → internetdata-2.7.2}/scripts/publish.sh +0 -0
  20. {internetdata-2.7.0 → internetdata-2.7.2}/scripts/v2_subset.py +0 -0
  21. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/__init__.py +0 -0
  22. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/__init__.py +0 -0
  23. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/account/__init__.py +0 -0
  24. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/account/account_identity.py +0 -0
  25. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
  26. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
  27. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
  28. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
  29. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
  30. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
  31. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
  32. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
  33. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
  34. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
  35. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
  36. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  37. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  38. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  39. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  40. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  41. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  42. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/organization/__init__.py +0 -0
  43. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/organization/account_org.py +0 -0
  44. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
  45. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/client.py +0 -0
  46. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/errors.py +0 -0
  47. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/__init__.py +0 -0
  48. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
  49. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
  50. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_org.py +0 -0
  51. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_org_ref.py +0 -0
  52. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
  53. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_rc.py +0 -0
  54. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
  55. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/account_user.py +0 -0
  56. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/apikey_detail.py +0 -0
  57. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/apikey_list.py +0 -0
  58. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database.py +0 -0
  59. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  60. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_format.py +0 -0
  61. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  62. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  63. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  64. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata.py +0 -0
  65. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  66. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  67. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  68. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
  69. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  70. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  71. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/database_version.py +0 -0
  72. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/db_checksums.py +0 -0
  73. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/device_authorization.py +0 -0
  74. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
  75. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/download.py +0 -0
  76. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/download_outcome.py +0 -0
  77. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/error.py +0 -0
  78. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/identity.py +0 -0
  79. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  80. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  81. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
  82. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
  83. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_error.py +0 -0
  84. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
  85. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
  86. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/revoke_request.py +0 -0
  87. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/standing.py +0 -0
  88. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/token_request.py +0 -0
  89. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/models/token_response.py +0 -0
  90. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/_generated/types.py +0 -0
  91. {internetdata-2.7.0 → internetdata-2.7.2}/src/internetdata/py.typed +0 -0
  92. {internetdata-2.7.0 → internetdata-2.7.2}/testdata/testdata.json +0 -0
  93. {internetdata-2.7.0 → internetdata-2.7.2}/tests/conftest.py +0 -0
  94. {internetdata-2.7.0 → internetdata-2.7.2}/tests/test_download.py +0 -0
  95. {internetdata-2.7.0 → internetdata-2.7.2}/tests/test_oauth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.7.0
3
+ Version: 2.7.2
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
@@ -12,7 +12,7 @@ info:
12
12
  email: support@internetdata.io
13
13
  url: https://internetdata.io
14
14
  termsOfService: https://internetdata.io/terms
15
- version: 2026.10.08
15
+ version: 2026.10.09
16
16
  servers:
17
17
  - url: https://internetdata.io
18
18
  tags:
@@ -501,10 +501,13 @@ paths:
501
501
  summary: Rotate
502
502
  description: |
503
503
  Replaces the secret behind a key, keeping its id, name and settings.
504
- The previous secret stops working immediately.
504
+ The previous secret stops working immediately. The new secret comes
505
+ back in the answer, so this needs `apikeys.reveal` as well as
506
+ `apikeys.manage`.
505
507
  security:
506
508
  - BearerAuth:
507
509
  - apikeys.manage
510
+ - apikeys.reveal
508
511
  parameters:
509
512
  - $ref: '#/components/parameters/ApikeyIdParam'
510
513
  responses:
@@ -39,7 +39,7 @@ from .models import (
39
39
  TokenResponse,
40
40
  )
41
41
 
42
- __version__ = "2.7.0"
42
+ __version__ = "2.7.2"
43
43
 
44
44
  __all__ = [
45
45
  "DATABASE_FORMATS",
@@ -14,7 +14,7 @@ import os
14
14
  import secrets
15
15
  import threading
16
16
  import time
17
- from collections.abc import Awaitable, Callable, Iterator
17
+ from collections.abc import Callable, Iterator
18
18
  from pathlib import Path
19
19
  from types import ModuleType
20
20
  from typing import IO, Any, TypeVar, cast
@@ -23,7 +23,6 @@ from urllib.parse import quote
23
23
  import httpx
24
24
 
25
25
  from ._generated.client import AuthenticatedClient, Client
26
- from ._generated.types import Response
27
26
  from .errors import InternetDataError, error_from_response, oauth_error_from
28
27
  from .models import Database, Download, Pkce, to_database, to_download
29
28
 
@@ -81,10 +80,11 @@ def build_client(
81
80
  `set_httpx_client()`, which silently bypasses auth: the generated client only adds
82
81
  the `Authorization` header when it CONSTRUCTS the httpx client itself.
83
82
  """
83
+ api_key = check_api_key(api_key)
84
84
  httpx_args: dict[str, Any] = {}
85
85
  if transport is not None:
86
86
  httpx_args["transport"] = transport
87
- if not api_key:
87
+ if api_key is None:
88
88
  return cast(
89
89
  AuthenticatedClient,
90
90
  Client(base_url=base_url, timeout=httpx.Timeout(timeout), httpx_args=httpx_args),
@@ -132,6 +132,25 @@ def build_async_transfer_client(
132
132
  )
133
133
 
134
134
 
135
+ def check_api_key(api_key: str | None) -> str | None:
136
+ """`api_key` without the blanks around it, or None when nothing else is left.
137
+
138
+ Refused where it is SET when a control character remains: httpx refuses a header
139
+ holding a CR, LF or NUL, so every call failed as a retried `network` error with nothing
140
+ sent, while a tab or DEL inside the key went out on the wire. Untrimmed, a key of blanks
141
+ was sent as one rather than as no key, and failed every call the same way (2.7.1,
142
+ measured 2026-10-12).
143
+ """
144
+ if api_key is None:
145
+ return None
146
+ if not isinstance(api_key, str):
147
+ raise TypeError(f"api_key must be a string or None, not {type(api_key).__name__}")
148
+ key = api_key.strip()
149
+ if any(ord(char) < 0x20 or ord(char) == 0x7F for char in key):
150
+ raise ValueError("api_key holds a control character")
151
+ return key or None
152
+
153
+
135
154
  def check_timeout(timeout: float | None) -> float | None:
136
155
  """`timeout`, once it is a bound an attempt can meet.
137
156
 
@@ -218,67 +237,51 @@ def part_file(destination: str | os.PathLike[str]) -> Iterator[IO[bytes]]:
218
237
  raise
219
238
 
220
239
 
221
- def send(call: Callable[[], Response[Any]]) -> Response[Any]:
222
- """One generated endpoint call.
223
-
224
- The generated code eagerly decodes the body of every DOCUMENTED status into its model
225
- before anything here sees the response, and it raises three different ways doing it: a
226
- 503 carrying an intermediary's HTML error page is a `ValueError` out of `json()`, a
227
- 200 missing a required key is a `KeyError`, and a `standing` this pinned spec predates
228
- is a `ValueError` out of an enum constructor. All three are the server sending
229
- something this client cannot read, which is a failed request rather than a bug in the
230
- caller's code, so all three become the one error type here.
231
-
232
- The call does nothing but build one generated request, send it and hand the answer to
233
- the generated decoder, which is what keeps this catch from swallowing a fault of our own.
234
- An argument is converted before it, so a format this client does not publish is the
235
- caller's `ValueError` rather than the server's failure.
236
- """
237
- try:
238
- return call()
239
- except (KeyError, TypeError, ValueError) as exc:
240
- raise malformed(exc) from exc
241
-
242
-
243
- async def send_async(call: Callable[[], Awaitable[Response[Any]]]) -> Response[Any]:
244
- """`send`, awaited."""
245
- try:
246
- return await call()
247
- except (KeyError, TypeError, ValueError) as exc:
248
- raise malformed(exc) from exc
249
-
250
-
251
240
  def request(
252
241
  endpoint: ModuleType, client: AuthenticatedClient, bound: float | None, **params: Any
253
- ) -> Response[Any]:
254
- """`send` for one generated endpoint, one attempt of it finished within `bound` seconds,
255
- or unbounded when that is None.
242
+ ) -> httpx.Response:
243
+ """One attempt at one generated endpoint, finished within `bound` seconds, or unbounded
244
+ when that is None, and its answer checked by `decoded`.
256
245
 
257
246
  Assembled from the endpoint module's `_get_kwargs` and `_build_response` because its
258
247
  `sync_detailed` sends through httpx alone, and httpx has no bound on the attempt.
259
248
  """
260
-
261
- def call() -> Response[Any]:
262
- http = client.get_httpx_client()
263
- req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
264
- res = exchange(http, req, bound)
265
- return cast(Response[Any], endpoint._build_response(client=client, response=res))
266
-
267
- return send(call)
249
+ http = client.get_httpx_client()
250
+ req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
251
+ return decoded(endpoint, client, exchange(http, req, bound))
268
252
 
269
253
 
270
254
  async def request_async(
271
255
  endpoint: ModuleType, client: AuthenticatedClient, bound: float | None, **params: Any
272
- ) -> Response[Any]:
256
+ ) -> httpx.Response:
273
257
  """`request`, awaited."""
258
+ http = client.get_async_httpx_client()
259
+ req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
260
+ return decoded(endpoint, client, await exchange_async(http, req, bound))
274
261
 
275
- async def call() -> Response[Any]:
276
- http = client.get_async_httpx_client()
277
- req = http.build_request(**endpoint._get_kwargs(**params), timeout=httpx.Timeout(bound))
278
- res = await exchange_async(http, req, bound)
279
- return cast(Response[Any], endpoint._build_response(client=client, response=res))
280
262
 
281
- return await send_async(call)
263
+ def decoded(
264
+ endpoint: ModuleType, client: AuthenticatedClient, res: httpx.Response
265
+ ) -> httpx.Response:
266
+ """`res`, once the generated decoder has read it, where it is a 2xx.
267
+
268
+ The generated code eagerly decodes the body of every DOCUMENTED status into its model,
269
+ and it raises three different ways doing it: a body that is not JSON is a `ValueError`
270
+ out of `json()`, an object missing a required key a `KeyError`, and a `standing` this
271
+ pinned spec predates a `ValueError` out of an enum constructor. On a 2xx each is the
272
+ server sending an answer this client cannot read, so each is the one error type here,
273
+ a retried `server_error` carrying the status.
274
+
275
+ Any other status is left to `unwrap`, which classifies it by the status alone, whatever
276
+ its body. Decoded too, a proxy's HTML page on a 401, a 404 or an empty 403 was a
277
+ retried `server_error` with no status, sent three times (2.7.1, measured 2026-10-12).
278
+ """
279
+ if 200 <= res.status_code < 300:
280
+ try:
281
+ endpoint._build_response(client=client, response=res)
282
+ except (KeyError, TypeError, ValueError) as exc:
283
+ raise malformed(exc, res.status_code) from exc
284
+ return res
282
285
 
283
286
 
284
287
  def exchange(http: httpx.Client, req: httpx.Request, bound: float | None) -> httpx.Response:
@@ -458,16 +461,16 @@ class AsyncClock:
458
461
  await asyncio.sleep(seconds)
459
462
 
460
463
 
461
- def malformed(exc: Exception) -> InternetDataError:
462
- return InternetDataError("server_error", f"malformed response from the API: {exc}")
464
+ def malformed(exc: Exception, status: int) -> InternetDataError:
465
+ return InternetDataError("server_error", f"malformed response from the API: {exc}", status)
463
466
 
464
467
 
465
- def unwrap(res: Response[Any]) -> dict[str, Any]:
468
+ def unwrap(res: httpx.Response) -> dict[str, Any]:
466
469
  """The response body as it came off the wire, or the failure it describes."""
467
470
  body = _decode(res.content)
468
- status = int(res.status_code)
471
+ status = res.status_code
469
472
  if not 200 <= status < 300:
470
- raise error_from_response(status, _headers(res), body)
473
+ raise error_from_response(status, res.headers, body)
471
474
  if not isinstance(body, dict):
472
475
  raise InternetDataError("server_error", "the API answered with a non-object body", status)
473
476
  return body
@@ -484,23 +487,27 @@ def as_error(exc: InternetDataError | httpx.HTTPError) -> InternetDataError:
484
487
  return InternetDataError("network", str(exc) or type(exc).__name__)
485
488
 
486
489
 
487
- def parse_body(body: dict[str, Any], parse: Callable[[dict[str, Any]], T]) -> T:
490
+ def parse_body(res: httpx.Response, parse: Callable[[dict[str, Any]], T]) -> T:
488
491
  """A served body through the model layer, with a malformed one reported as the
489
- server's failure rather than as a traceback out of a dataclass constructor."""
492
+ server's failure, carrying its status, rather than as a traceback out of a dataclass
493
+ constructor."""
494
+ body = unwrap(res)
490
495
  try:
491
496
  return parse(body)
492
- except (KeyError, TypeError, ValueError) as exc:
493
- raise malformed(exc) from exc
497
+ # AttributeError too: metadata's `schema` or `sample` as an array instead of an object
498
+ # escaped 2.7.1 raw, out of `.items()` (measured 2026-10-12).
499
+ except (AttributeError, KeyError, TypeError, ValueError) as exc:
500
+ raise malformed(exc, res.status_code) from exc
494
501
 
495
502
 
496
- def redirect_location(res: Response[Any]) -> str:
503
+ def redirect_location(res: httpx.Response) -> str:
497
504
  """Where a `302` points, or whatever the API said instead."""
498
- location: str | None = _headers(res).get("location")
499
- if int(res.status_code) == 302 and location:
505
+ location: str | None = res.headers.get("location")
506
+ if res.status_code == 302 and location:
500
507
  return location
501
508
  unwrap(res)
502
509
  raise InternetDataError(
503
- "server_error", "expected a redirect to object storage", int(res.status_code)
510
+ "server_error", "expected a redirect to object storage", res.status_code
504
511
  )
505
512
 
506
513
 
@@ -519,13 +526,18 @@ def downloads_of(body: dict[str, Any]) -> list[Download]:
519
526
 
520
527
 
521
528
  def checksums_of(body: dict[str, Any]) -> dict[str, str]:
522
- """The digests, unwrapped one level past the envelope.
529
+ """The digests, unwrapped one level past the envelope, the four the spec requires
530
+ among them as strings.
523
531
 
524
532
  The response carries `id` and `format` beside them, so reading a top-level `sha256`
525
533
  finds nothing. That exact mistake shipped in another binding's 1.0.x, which is why
526
534
  the shared corpus pins the depth.
527
535
  """
528
- return dict(body["checksums"])
536
+ digests = dict(body["checksums"])
537
+ for algorithm in ("md5", "sha1", "sha256", "sha512"):
538
+ if not isinstance(digests[algorithm], str):
539
+ raise TypeError(f"the {algorithm} checksum is not a string: {digests[algorithm]!r}")
540
+ return digests
529
541
 
530
542
 
531
543
  def retry_delay(err: InternetDataError, attempt: int, retries: int) -> float | None:
@@ -596,13 +608,6 @@ def _deadline_passed(bound: float) -> InternetDataError:
596
608
  return InternetDataError("network", f"the request did not complete within {bound:g} seconds")
597
609
 
598
610
 
599
- # The generated Response declares a plain MutableMapping, but always carries httpx's
600
- # case-insensitive Headers. Rebuilding one keeps a header lookup case-blind whichever it
601
- # turns out to be, which matters for `Retry-After`.
602
- def _headers(res: Response[Any]) -> httpx.Headers:
603
- return httpx.Headers(res.headers)
604
-
605
-
606
611
  def _decode(content: bytes) -> Any:
607
612
  try:
608
613
  return json.loads(content)
@@ -74,7 +74,9 @@ def sync_detailed(
74
74
  """Rotate
75
75
 
76
76
  Replaces the secret behind a key, keeping its id, name and settings.
77
- The previous secret stops working immediately.
77
+ The previous secret stops working immediately. The new secret comes
78
+ back in the answer, so this needs `apikeys.reveal` as well as
79
+ `apikeys.manage`.
78
80
 
79
81
  Args:
80
82
  id (UUID):
@@ -106,7 +108,9 @@ def sync(
106
108
  """Rotate
107
109
 
108
110
  Replaces the secret behind a key, keeping its id, name and settings.
109
- The previous secret stops working immediately.
111
+ The previous secret stops working immediately. The new secret comes
112
+ back in the answer, so this needs `apikeys.reveal` as well as
113
+ `apikeys.manage`.
110
114
 
111
115
  Args:
112
116
  id (UUID):
@@ -133,7 +137,9 @@ async def asyncio_detailed(
133
137
  """Rotate
134
138
 
135
139
  Replaces the secret behind a key, keeping its id, name and settings.
136
- The previous secret stops working immediately.
140
+ The previous secret stops working immediately. The new secret comes
141
+ back in the answer, so this needs `apikeys.reveal` as well as
142
+ `apikeys.manage`.
137
143
 
138
144
  Args:
139
145
  id (UUID):
@@ -163,7 +169,9 @@ async def asyncio(
163
169
  """Rotate
164
170
 
165
171
  Replaces the secret behind a key, keeping its id, name and settings.
166
- The previous secret stops working immediately.
172
+ The previous secret stops working immediately. The new secret comes
173
+ back in the answer, so this needs `apikeys.reveal` as well as
174
+ `apikeys.manage`.
167
175
 
168
176
  Args:
169
177
  id (UUID):
@@ -48,7 +48,6 @@ from ._core import (
48
48
  request_async,
49
49
  retry_delay,
50
50
  storage_refusal,
51
- unwrap,
52
51
  )
53
52
  from ._generated.api.database_v_2 import (
54
53
  database_checksum_v2,
@@ -172,7 +171,7 @@ class AsyncDatabaseApi:
172
171
 
173
172
  async def call() -> builtins.list[Database]:
174
173
  res = await request_async(list_databases, self._client, self._bound(timeout))
175
- return parse_body(unwrap(res), databases_of)
174
+ return parse_body(res, databases_of)
176
175
 
177
176
  return await self._retrying(call)
178
177
 
@@ -186,7 +185,7 @@ class AsyncDatabaseApi:
186
185
  res = await request_async(
187
186
  database_metadata_v2, self._client, self._bound(timeout), id=database_id
188
187
  )
189
- return parse_body(unwrap(res), to_metadata)
188
+ return parse_body(res, to_metadata)
190
189
 
191
190
  return await self._retrying(call)
192
191
 
@@ -206,7 +205,7 @@ class AsyncDatabaseApi:
206
205
  id=database_id,
207
206
  format_=DatabaseFormat(format),
208
207
  )
209
- return parse_body(unwrap(res), checksums_of)
208
+ return parse_body(res, checksums_of)
210
209
 
211
210
  return await self._retrying(call)
212
211
 
@@ -222,7 +221,7 @@ class AsyncDatabaseApi:
222
221
  res = await request_async(
223
222
  list_downloads, self._client, self._bound(timeout), limit=limit
224
223
  )
225
- return parse_body(unwrap(res), downloads_of)
224
+ return parse_body(res, downloads_of)
226
225
 
227
226
  return await self._retrying(call)
228
227
 
@@ -43,7 +43,6 @@ from ._core import (
43
43
  request,
44
44
  retry_delay,
45
45
  storage_refusal,
46
- unwrap,
47
46
  )
48
47
  from ._generated.api.database_v_2 import (
49
48
  database_checksum_v2,
@@ -184,7 +183,7 @@ class DatabaseApi:
184
183
 
185
184
  def call() -> builtins.list[Database]:
186
185
  res = request(list_databases, self._client, self._bound(timeout))
187
- return parse_body(unwrap(res), databases_of)
186
+ return parse_body(res, databases_of)
188
187
 
189
188
  return self._retrying(call)
190
189
 
@@ -199,7 +198,7 @@ class DatabaseApi:
199
198
 
200
199
  def call() -> DatabaseMetadata:
201
200
  res = request(database_metadata_v2, self._client, self._bound(timeout), id=database_id)
202
- return parse_body(unwrap(res), to_metadata)
201
+ return parse_body(res, to_metadata)
203
202
 
204
203
  return self._retrying(call)
205
204
 
@@ -222,7 +221,7 @@ class DatabaseApi:
222
221
  id=database_id,
223
222
  format_=DatabaseFormat(format),
224
223
  )
225
- return parse_body(unwrap(res), checksums_of)
224
+ return parse_body(res, checksums_of)
226
225
 
227
226
  return self._retrying(call)
228
227
 
@@ -239,7 +238,7 @@ class DatabaseApi:
239
238
 
240
239
  def call() -> builtins.list[Download]:
241
240
  res = request(list_downloads, self._client, self._bound(timeout), limit=limit)
242
- return parse_body(unwrap(res), downloads_of)
241
+ return parse_body(res, downloads_of)
243
242
 
244
243
  return self._retrying(call)
245
244
 
@@ -3,8 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import datetime
6
- import email.utils
7
6
  import math
7
+ import re
8
8
  from typing import Any, Literal
9
9
 
10
10
  import httpx
@@ -170,21 +170,65 @@ def _message_of(body: Any) -> str | None:
170
170
  return None
171
171
 
172
172
 
173
+ # `Retry-After` is a count of seconds or an HTTP date in one of RFC 9110's three forms,
174
+ # always GMT, and nothing else: `float()` also read `+1`, `1_0`, `1e3` and `1.5` as counts,
175
+ # and `email.utils` read a date in any zone, without its day name or with a two-digit year,
176
+ # so `1_0` held a call ten seconds and a `PST` date eight hours (2.7.1, measured 2026-10-12).
177
+ _SECONDS = re.compile(r"[0-9]+")
178
+ _MONTH = r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
179
+ _CLOCK = r"([0-9]{2}):([0-9]{2}):([0-9]{2})"
180
+ _IMF_FIXDATE = re.compile(
181
+ rf"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), ([0-9]{{2}}) {_MONTH} ([0-9]{{4}}) {_CLOCK} GMT"
182
+ )
183
+ _RFC850_DATE = re.compile(
184
+ rf"(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), ([0-9]{{2}})-{_MONTH}-([0-9]{{2}}) {_CLOCK} GMT"
185
+ )
186
+ _ASCTIME_DATE = re.compile(
187
+ rf"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) {_MONTH} ([0-9]{{2}}| [0-9]) {_CLOCK} ([0-9]{{4}})"
188
+ )
189
+ _MONTHS = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
190
+
191
+
173
192
  def _parse_retry_after(value: str | None) -> float | None:
174
- if value is None or value.strip() == "":
193
+ if value is None:
175
194
  return None
176
- try:
177
- seconds = float(value)
178
- except ValueError:
179
- pass
180
- else:
181
- return seconds if seconds >= 0 else None
182
- # The header also permits an HTTP date.
183
- try:
184
- when = email.utils.parsedate_to_datetime(value)
185
- except (TypeError, ValueError):
195
+ value = value.strip(" \t")
196
+ if _SECONDS.fullmatch(value):
197
+ # A count of any length: float() reads one past its range as inf, never raising.
198
+ return float(value)
199
+ when = _http_date(value)
200
+ if when is None:
186
201
  return None
187
- if when.tzinfo is None:
188
- when = when.replace(tzinfo=datetime.UTC)
189
202
  delta = (when - datetime.datetime.now(datetime.UTC)).total_seconds()
190
203
  return max(0.0, math.ceil(delta))
204
+
205
+
206
+ # The moment an HTTP date names, or None for anything else, a day no calendar has included.
207
+ def _http_date(value: str) -> datetime.datetime | None:
208
+ if match := _IMF_FIXDATE.fullmatch(value):
209
+ day, month, year, hour, minute, second = match.groups()
210
+ full_year = int(year)
211
+ elif match := _RFC850_DATE.fullmatch(value):
212
+ day, month, year, hour, minute, second = match.groups()
213
+ # RFC 9110: a two-digit year more than 50 years ahead is the most recent past one.
214
+ this_year = datetime.datetime.now(datetime.UTC).year
215
+ full_year = this_year - this_year % 100 + int(year)
216
+ if full_year > this_year + 50:
217
+ full_year -= 100
218
+ elif match := _ASCTIME_DATE.fullmatch(value):
219
+ month, day, hour, minute, second, year = match.groups()
220
+ full_year = int(year)
221
+ else:
222
+ return None
223
+ try:
224
+ return datetime.datetime(
225
+ full_year,
226
+ _MONTHS.index(month) + 1,
227
+ int(day),
228
+ int(hour),
229
+ int(minute),
230
+ int(second),
231
+ tzinfo=datetime.UTC,
232
+ )
233
+ except ValueError:
234
+ return None
@@ -175,36 +175,36 @@ class Download:
175
175
 
176
176
  def to_database(body: dict[str, Any]) -> Database:
177
177
  return Database(
178
- base=body["base"],
179
- name=body["name"],
180
- summary=body["summary"],
181
- standing=body["standing"],
178
+ base=_present(body, "base"),
179
+ name=_present(body, "name"),
180
+ summary=_present(body, "summary"),
181
+ standing=_present(body, "standing"),
182
182
  license_type=body["license_type"],
183
183
  starts=_datetime(body["starts"]),
184
184
  expires=_datetime(body["expires"]),
185
185
  renews_at=_datetime(body["renews_at"]),
186
186
  notice_due_at=_datetime(body["notice_due_at"]),
187
- versions=tuple(to_version(v) for v in body["versions"]),
187
+ versions=tuple(to_version(v) for v in _present(body, "versions")),
188
188
  raw=body,
189
- open=body["open"],
189
+ open=_present(body, "open"),
190
190
  )
191
191
 
192
192
 
193
193
  def to_version(body: dict[str, Any]) -> DatabaseVersion:
194
194
  return DatabaseVersion(
195
- id=body["id"],
196
- version=body["version"],
197
- summary=body["summary"],
198
- formats=tuple(body["formats"]),
195
+ id=_present(body, "id"),
196
+ version=_present(body, "version"),
197
+ summary=_present(body, "summary"),
198
+ formats=tuple(_present(body, "formats")),
199
199
  sample_formats=_optional_tuple(body.get("sample_formats")),
200
200
  )
201
201
 
202
202
 
203
203
  def to_metadata(body: dict[str, Any]) -> DatabaseMetadata:
204
204
  return DatabaseMetadata(
205
- id=body["id"],
205
+ id=_present(body, "id"),
206
206
  updated=datetime.date.fromisoformat(body["updated"]),
207
- entries=body["entries"],
207
+ entries=_present(body, "entries"),
208
208
  schema={
209
209
  fmt: tuple(to_column(c) for c in columns) for fmt, columns in body["schema"].items()
210
210
  },
@@ -218,25 +218,42 @@ def to_metadata(body: dict[str, Any]) -> DatabaseMetadata:
218
218
 
219
219
 
220
220
  def to_column(body: dict[str, Any]) -> MetadataColumn:
221
- return MetadataColumn(name=body["name"], type=body["type"], description=body.get("description"))
221
+ return MetadataColumn(
222
+ name=_present(body, "name"),
223
+ type=_present(body, "type"),
224
+ description=body.get("description"),
225
+ )
222
226
 
223
227
 
224
228
  def to_download(body: dict[str, Any]) -> Download:
225
229
  return Download(
226
- dataset_id=body["dataset_id"],
227
- format=body["format"],
228
- outcome=body["outcome"],
230
+ dataset_id=_present(body, "dataset_id"),
231
+ format=_present(body, "format"),
232
+ outcome=_present(body, "outcome"),
229
233
  created=datetime.datetime.fromisoformat(body["created"]),
230
234
  bytes=body["bytes"],
231
235
  http_status=body["http_status"],
232
236
  apikey_id=body["apikey_id"],
233
237
  client_ip=body["client_ip"],
234
238
  user_agent=body["user_agent"],
235
- sample=body["sample"],
236
- open=body["open"],
239
+ sample=_present(body, "sample"),
240
+ open=_present(body, "open"),
237
241
  )
238
242
 
239
243
 
244
+ def _present(body: dict[str, Any], name: str) -> Any:
245
+ """A member the spec requires and never sends as null.
246
+
247
+ A null one is the server's failure, raised for the caller to report as one, rather
248
+ than handed back in the answer: 2.7.1 listed a family whose `base` was null as a
249
+ family with no base (measured 2026-10-12).
250
+ """
251
+ value = body[name]
252
+ if value is None:
253
+ raise ValueError(f"{name} is null")
254
+ return value
255
+
256
+
240
257
  def _optional_tuple(value: Any) -> tuple[Any, ...] | None:
241
258
  return tuple(value) if value is not None else None
242
259
 
@@ -301,6 +301,11 @@ class Stub:
301
301
  route = self.routes.get(request.url.path)
302
302
  if route is None:
303
303
  return httpx.Response(404, json={"rc": "UNKNOWN_DATASET"})
304
+ if "raw" in route:
305
+ # Bytes as they are, for a body that is not JSON at all.
306
+ return httpx.Response(
307
+ route.get("status", 200), content=route["raw"], headers=route.get("headers")
308
+ )
304
309
  return httpx.Response(
305
310
  route.get("status", 200), json=self._body(route), headers=route.get("headers")
306
311
  )
@@ -9,7 +9,7 @@ import inspect
9
9
  import time
10
10
  from collections.abc import Callable
11
11
  from pathlib import Path
12
- from typing import Any, get_args
12
+ from typing import Any, cast, get_args
13
13
 
14
14
  import attrs
15
15
  import httpx
@@ -18,6 +18,7 @@ from helpers import (
18
18
  API,
19
19
  API_KEY,
20
20
  CHECKSUM_PATH,
21
+ DOWNLOAD_PATH,
21
22
  DOWNLOADS_PATH,
22
23
  LIST_PATH,
23
24
  METADATA_PATH,
@@ -60,6 +61,7 @@ from internetdata._generated.models.download import Download as WireDownload
60
61
  from internetdata._generated.models.download_outcome import DownloadOutcome as WireDownloadOutcome
61
62
  from internetdata._generated.models.oauth_metadata import OauthMetadata as WireOauthMetadata
62
63
  from internetdata._generated.models.token_response import TokenResponse as WireTokenResponse
64
+ from internetdata.errors import error_from_response
63
65
 
64
66
  METADATA = {
65
67
  "id": "bogon_ip_v1",
@@ -319,6 +321,157 @@ def test_a_malformed_body_is_the_servers_failure_not_a_traceback(
319
321
  assert "malformed response" in caught.value.message
320
322
 
321
323
 
324
+ JSON_CALL_PATHS = {
325
+ "list": LIST_PATH,
326
+ "metadata": METADATA_PATH,
327
+ "checksums": CHECKSUM_PATH,
328
+ "downloads": DOWNLOADS_PATH,
329
+ "download_url": DOWNLOAD_PATH,
330
+ }
331
+
332
+ UNREADABLE_2XX = {
333
+ "html": b"<html><body>Bad gateway</body></html>",
334
+ "cut off": b'{"databases": [',
335
+ "empty": b"",
336
+ "null": b"null",
337
+ "array": b"[]",
338
+ "empty object": b"{}",
339
+ }
340
+
341
+
342
+ @pytest.mark.parametrize("body", UNREADABLE_2XX)
343
+ @pytest.mark.parametrize("call", JSON_CALLS)
344
+ def test_a_2xx_the_call_cannot_read_is_a_retried_server_error_with_its_status(
345
+ make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, call: str, body: str
346
+ ) -> None:
347
+ """Retried, but with no status, through 2.7.1. For `download_url` a 200 stands for the
348
+ 302 it did not send."""
349
+ monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
350
+ stub = Stub({JSON_CALL_PATHS[call]: {"raw": UNREADABLE_2XX[body]}})
351
+ client = make_client(transport=stub.transport, retries=2)
352
+
353
+ with pytest.raises(InternetDataError) as caught:
354
+ JSON_CALLS[call](client)
355
+
356
+ assert caught.value.kind == "server_error"
357
+ assert caught.value.status == 200
358
+ assert len(stub.requests) == 3
359
+
360
+
361
+ def _without(member: str, entry: dict[str, Any]) -> dict[str, Any]:
362
+ return {**entry, member: None}
363
+
364
+
365
+ def _version_without(member: str) -> dict[str, Any]:
366
+ family = database("bogon_ip")
367
+ return {**family, "versions": [_without(member, family["versions"][0])]}
368
+
369
+
370
+ DIGESTS = {"md5": "m", "sha1": "s1", "sha256": "s256", "sha512": "s512"}
371
+
372
+ # Each member the spec requires and never sends as null, sent as null; and metadata's two
373
+ # maps sent as arrays, which escaped 2.7.1 as a raw AttributeError.
374
+ UNREADABLE_MEMBERS: dict[str, tuple[str, dict[str, Any]]] = {
375
+ **{
376
+ f"list {m}": (LIST_PATH, {"databases": [_without(m, database("bogon_ip"))]})
377
+ for m in ("base", "name", "summary", "standing", "open", "versions")
378
+ },
379
+ **{
380
+ f"list version {m}": (LIST_PATH, {"databases": [_version_without(m)]})
381
+ for m in ("id", "version", "summary", "formats")
382
+ },
383
+ **{
384
+ f"metadata {m}": (METADATA_PATH, _without(m, METADATA))
385
+ for m in ("id", "updated", "entries", "schema", "size")
386
+ },
387
+ "metadata column name": (
388
+ METADATA_PATH,
389
+ {**METADATA, "schema": {"csvgz": [{"name": None, "type": "string"}]}},
390
+ ),
391
+ "metadata schema array": (METADATA_PATH, {**METADATA, "schema": []}),
392
+ "metadata sample array": (METADATA_PATH, {**METADATA, "sample": []}),
393
+ **{
394
+ f"downloads {m}": (
395
+ DOWNLOADS_PATH,
396
+ {"downloads": [_without(m, cast(list[dict[str, Any]], DOWNLOADS["downloads"])[0])]},
397
+ )
398
+ for m in ("dataset_id", "format", "outcome", "sample", "open", "created")
399
+ },
400
+ **{
401
+ f"checksums {m}": (
402
+ CHECKSUM_PATH,
403
+ {"id": "bogon_ip_v1", "format": "csvgz", "checksums": _without(m, DIGESTS)},
404
+ )
405
+ for m in ("md5", "sha256")
406
+ },
407
+ "checksums number": (
408
+ CHECKSUM_PATH,
409
+ {"id": "bogon_ip_v1", "format": "csvgz", "checksums": {**DIGESTS, "sha1": 5}},
410
+ ),
411
+ }
412
+
413
+ MEMBER_CALLS = {LIST_PATH: "list", METADATA_PATH: "metadata", DOWNLOADS_PATH: "downloads"}
414
+
415
+
416
+ @pytest.mark.parametrize("case", UNREADABLE_MEMBERS)
417
+ def test_an_answer_missing_what_the_spec_requires_is_a_retried_server_error(
418
+ make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, case: str
419
+ ) -> None:
420
+ """2.7.1 handed a null required member back in the answer, and raised a raw
421
+ AttributeError for metadata's `schema` or `sample` sent as an array."""
422
+ monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
423
+ path, body = UNREADABLE_MEMBERS[case]
424
+ stub = Stub({path: {"body": body}})
425
+ client = make_client(transport=stub.transport, retries=2)
426
+
427
+ with pytest.raises(InternetDataError) as caught:
428
+ JSON_CALLS[MEMBER_CALLS.get(path, "checksums")](client)
429
+
430
+ assert (caught.value.kind, caught.value.status) == ("server_error", 200)
431
+ assert len(stub.requests) == 3
432
+
433
+
434
+ @pytest.mark.parametrize(
435
+ ("status", "body", "kind"),
436
+ [
437
+ (401, b"<html>401</html>", "unauthorized"),
438
+ (401, b"{}", "unauthorized"),
439
+ (403, b"", "forbidden"),
440
+ (404, b"<html>Not Found</html>", "bad_request"),
441
+ (400, b"null", "bad_request"),
442
+ ],
443
+ )
444
+ @pytest.mark.parametrize("call", JSON_CALLS)
445
+ def test_a_4xx_is_classified_by_its_status_whatever_its_body(
446
+ make_client: ClientFactory, call: str, status: int, body: bytes, kind: str
447
+ ) -> None:
448
+ """A proxy's page on a 4xx is still the client's error, sent once. Read through the
449
+ generated decoder, it was a retried server error with no status (2.7.1)."""
450
+ stub = Stub({JSON_CALL_PATHS[call]: {"status": status, "raw": body}})
451
+ client = make_client(transport=stub.transport, retries=2)
452
+
453
+ with pytest.raises(InternetDataError) as caught:
454
+ JSON_CALLS[call](client)
455
+
456
+ assert (caught.value.kind, caught.value.status) == (kind, status)
457
+ assert len(stub.requests) == 1
458
+
459
+
460
+ @pytest.mark.parametrize("call", JSON_CALLS)
461
+ def test_a_5xx_page_is_a_retried_server_error_with_its_status(
462
+ make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, call: str
463
+ ) -> None:
464
+ monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
465
+ stub = Stub({JSON_CALL_PATHS[call]: {"status": 503, "raw": b"<html>503</html>"}})
466
+ client = make_client(transport=stub.transport, retries=2)
467
+
468
+ with pytest.raises(InternetDataError) as caught:
469
+ JSON_CALLS[call](client)
470
+
471
+ assert (caught.value.kind, caught.value.status) == ("server_error", 503)
472
+ assert len(stub.requests) == 3
473
+
474
+
322
475
  def test_a_server_error_is_retried_and_then_reported(make_client: ClientFactory) -> None:
323
476
  stub = Stub({LIST_PATH: {"status": 503, "body": {"rc": "UNAVAILABLE"}}})
324
477
  client = make_client(transport=stub.transport, retries=2)
@@ -374,7 +527,7 @@ def test_a_rate_limit_is_retried_and_honors_retry_after(make_client: ClientFacto
374
527
 
375
528
 
376
529
  @pytest.mark.parametrize(
377
- "retry_after", ["2147484", "9223372036854775807", "1e400", "Fri, 31 Dec 9999 23:59:59 GMT"]
530
+ "retry_after", ["2147484", "9223372036854775807", "9" * 400, "Fri, 31 Dec 9999 23:59:59 GMT"]
378
531
  )
379
532
  def test_a_retry_after_past_the_ceiling_waits_the_backoff_and_stays_a_throttle(
380
533
  make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, retry_after: str
@@ -393,6 +546,69 @@ def test_a_retry_after_past_the_ceiling_waits_the_backoff_and_stays_a_throttle(
393
546
  assert len(stub.requests) == 2
394
547
 
395
548
 
549
+ @pytest.mark.parametrize(
550
+ "retry_after",
551
+ [
552
+ "-1",
553
+ "x",
554
+ "tomorrow",
555
+ "+1",
556
+ "0x10",
557
+ "1_0",
558
+ "1e3",
559
+ "1.5",
560
+ "inf",
561
+ "1e400",
562
+ "2026-10-12T06:00:00Z",
563
+ "Mon, 12 Oct 2026 06:00:00 +0500",
564
+ "Mon, 12 Oct 2026 06:00:00 PST",
565
+ "12 Oct 2026 06:00:00 GMT",
566
+ "Mon, 12 Oct 26 06:00:00 GMT",
567
+ "Tue, 31 Nov 2026 10:00:00 GMT",
568
+ "Mon, 12 Oct 2026 25:61:61 GMT",
569
+ ],
570
+ )
571
+ def test_a_retry_after_that_is_neither_seconds_nor_an_http_date_is_a_spent_quota(
572
+ make_client: ClientFactory, retry_after: str
573
+ ) -> None:
574
+ """`float()` and `email.utils` read `+1` as a second, `1_0` as ten, `1e3` as a thousand
575
+ and a date in any zone (2.7.1). RFC 9110 admits digits, or a date in one of three
576
+ forms, in GMT."""
577
+ headers = {"Retry-After": retry_after}
578
+ stub = Stub({LIST_PATH: {"status": 429, "body": {"rc": "LIMITED"}, "headers": headers}})
579
+ # No retries, so a value misread as a long wait fails here rather than holding the run.
580
+ client = make_client(transport=stub.transport, retries=0)
581
+
582
+ with pytest.raises(InternetDataError) as caught:
583
+ client.database.list()
584
+
585
+ assert caught.value.kind == "quota_exceeded"
586
+ assert caught.value.retry_after_seconds is None
587
+
588
+
589
+ @pytest.mark.parametrize(
590
+ "form",
591
+ [
592
+ lambda when: when.strftime("%a, %d %b %Y %H:%M:%S GMT"),
593
+ lambda when: when.strftime("%A, %d-%b-%y %H:%M:%S GMT"),
594
+ lambda when: when.strftime("%a %b ") + f"{when.day:2d}" + when.strftime(" %H:%M:%S %Y"),
595
+ ],
596
+ ids=["imf-fixdate", "rfc850", "asctime"],
597
+ )
598
+ def test_a_retry_after_date_is_read_in_each_of_rfc_9110s_three_forms(
599
+ form: Callable[[datetime.datetime], str],
600
+ ) -> None:
601
+ # Computed as the case runs: a date made up front can have passed by the time it is read.
602
+ when = datetime.datetime.now(datetime.UTC) + datetime.timedelta(seconds=100)
603
+ headers = httpx.Headers({"Retry-After": form(when)})
604
+
605
+ err = error_from_response(429, headers, None)
606
+
607
+ assert err.kind == "rate_limited"
608
+ assert err.retry_after_seconds is not None
609
+ assert 98 <= err.retry_after_seconds <= 101
610
+
611
+
396
612
  def test_a_retry_after_is_waited_as_given_up_to_the_ceiling() -> None:
397
613
  def throttled(seconds: float) -> InternetDataError:
398
614
  return InternetDataError("rate_limited", "RATE_LIMITED", 429, seconds)
@@ -245,7 +245,7 @@ def test_the_api_key_reaches_the_wire_under_the_bearer_scheme(
245
245
  assert stub.requests[0].headers.get("authorization") == f"Bearer {API_KEY}"
246
246
 
247
247
 
248
- @pytest.mark.parametrize("api_key", [None, ""])
248
+ @pytest.mark.parametrize("api_key", [None, "", " ", "\t", "\r\n"])
249
249
  def test_a_keyless_client_sends_no_authorization_header(
250
250
  make_client: ClientFactory, api_key: str | None
251
251
  ) -> None:
@@ -260,3 +260,26 @@ def test_a_keyless_client_sends_no_authorization_header(
260
260
  client.database.list()
261
261
 
262
262
  assert "authorization" not in stub.requests[0].headers
263
+
264
+
265
+ def test_the_api_key_is_trimmed(make_client: ClientFactory) -> None:
266
+ """A key pasted with a line ending around it went out padded, which httpx refuses, so
267
+ every call failed as a retried network error with nothing sent (2.7.1)."""
268
+ stub = Stub({LIST_PATH: {"body": {"databases": []}}})
269
+ client = make_client(transport=stub.transport, api_key=f" {API_KEY}\r\n")
270
+
271
+ client.database.list()
272
+
273
+ assert stub.requests[0].headers.get("authorization") == f"Bearer {API_KEY}"
274
+
275
+
276
+ @pytest.mark.parametrize(
277
+ "api_key", ["key\r\nX-Injected: 1", "ke\ty", "key\x00", "key\x7f", "k\ney"]
278
+ )
279
+ def test_a_control_character_in_the_api_key_is_refused_where_it_is_set(
280
+ make_client: ClientFactory, api_key: str
281
+ ) -> None:
282
+ """Accepted, a CR, LF or NUL failed every call as a retried network error with nothing
283
+ sent, and a tab or DEL went out on the wire inside the header (2.7.1)."""
284
+ with pytest.raises(ValueError, match="control character"):
285
+ make_client(api_key=api_key)
File without changes
File without changes
File without changes