internetdata 2.6.0__tar.gz → 2.6.1__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.6.0 → internetdata-2.6.1}/PKG-INFO +1 -1
  2. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/__init__.py +1 -1
  3. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_core.py +28 -7
  4. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/aio.py +8 -2
  5. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/client.py +8 -2
  6. {internetdata-2.6.0 → internetdata-2.6.1}/testdata/testdata.json +66 -0
  7. {internetdata-2.6.0 → internetdata-2.6.1}/tests/test_client.py +33 -3
  8. {internetdata-2.6.0 → internetdata-2.6.1}/tests/test_download.py +26 -0
  9. {internetdata-2.6.0 → internetdata-2.6.1}/tests/test_oauth.py +86 -0
  10. {internetdata-2.6.0 → internetdata-2.6.1}/.gitignore +0 -0
  11. {internetdata-2.6.0 → internetdata-2.6.1}/LICENSE +0 -0
  12. {internetdata-2.6.0 → internetdata-2.6.1}/README.md +0 -0
  13. {internetdata-2.6.0 → internetdata-2.6.1}/pyproject.toml +0 -0
  14. {internetdata-2.6.0 → internetdata-2.6.1}/scripts/download-spec.sh +0 -0
  15. {internetdata-2.6.0 → internetdata-2.6.1}/scripts/generate.sh +0 -0
  16. {internetdata-2.6.0 → internetdata-2.6.1}/scripts/publish.sh +0 -0
  17. {internetdata-2.6.0 → internetdata-2.6.1}/scripts/v2_subset.py +0 -0
  18. {internetdata-2.6.0 → internetdata-2.6.1}/spec/openapi.yaml +0 -0
  19. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/__init__.py +0 -0
  20. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/__init__.py +0 -0
  21. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/account/__init__.py +0 -0
  22. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/account/account_identity.py +0 -0
  23. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/__init__.py +0 -0
  24. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_create_apikey.py +0 -0
  25. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_list_apikeys.py +0 -0
  26. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_reveal_apikey.py +0 -0
  27. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_revoke_apikey.py +0 -0
  28. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/api_keys/account_rotate_apikey.py +0 -0
  29. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/__init__.py +0 -0
  30. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_authorize.py +0 -0
  31. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_device_authorization.py +0 -0
  32. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_metadata.py +0 -0
  33. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_revoke.py +0 -0
  34. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/authorization/oauth_token.py +0 -0
  35. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/__init__.py +0 -0
  36. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/database_checksum_v2.py +0 -0
  37. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/database_metadata_v2.py +0 -0
  38. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/download_database_v2.py +0 -0
  39. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/list_databases.py +0 -0
  40. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/database_v_2/list_downloads.py +0 -0
  41. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/__init__.py +0 -0
  42. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/account_org.py +0 -0
  43. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/api/organization/account_org_members.py +0 -0
  44. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/client.py +0 -0
  45. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/errors.py +0 -0
  46. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/__init__.py +0 -0
  47. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_create_apikey_request.py +0 -0
  48. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_created_apikey.py +0 -0
  49. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org.py +0 -0
  50. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org_ref.py +0 -0
  51. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_org_wrap.py +0 -0
  52. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_rc.py +0 -0
  53. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_revealed_apikey.py +0 -0
  54. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/account_user.py +0 -0
  55. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/apikey_detail.py +0 -0
  56. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/apikey_list.py +0 -0
  57. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database.py +0 -0
  58. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_checksum_v2_response_200.py +0 -0
  59. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_format.py +0 -0
  60. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_1.py +0 -0
  61. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_2_type_1.py +0 -0
  62. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_license_type_type_3_type_1.py +0 -0
  63. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata.py +0 -0
  64. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_column.py +0 -0
  65. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample.py +0 -0
  66. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample_additional_property_item.py +0 -0
  67. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_sample_size.py +0 -0
  68. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_schema.py +0 -0
  69. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_metadata_size.py +0 -0
  70. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/database_version.py +0 -0
  71. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/db_checksums.py +0 -0
  72. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/device_authorization.py +0 -0
  73. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/device_authorization_request.py +0 -0
  74. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/download.py +0 -0
  75. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/download_outcome.py +0 -0
  76. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/error.py +0 -0
  77. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/identity.py +0 -0
  78. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/list_databases_response_200.py +0 -0
  79. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/list_downloads_response_200.py +0 -0
  80. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_authorize_code_challenge_method.py +0 -0
  81. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_authorize_response_type.py +0 -0
  82. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_error.py +0 -0
  83. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_metadata.py +0 -0
  84. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/oauth_revoke_response_200.py +0 -0
  85. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/revoke_request.py +0 -0
  86. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/standing.py +0 -0
  87. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/token_request.py +0 -0
  88. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/models/token_response.py +0 -0
  89. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/_generated/types.py +0 -0
  90. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/errors.py +0 -0
  91. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/models.py +0 -0
  92. {internetdata-2.6.0 → internetdata-2.6.1}/src/internetdata/py.typed +0 -0
  93. {internetdata-2.6.0 → internetdata-2.6.1}/tests/conftest.py +0 -0
  94. {internetdata-2.6.0 → internetdata-2.6.1}/tests/helpers.py +0 -0
  95. {internetdata-2.6.0 → internetdata-2.6.1}/tests/test_conformance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: internetdata
3
- Version: 2.6.0
3
+ Version: 2.6.1
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
@@ -39,7 +39,7 @@ from .models import (
39
39
  TokenResponse,
40
40
  )
41
41
 
42
- __version__ = "2.6.0"
42
+ __version__ = "2.6.1"
43
43
 
44
44
  __all__ = [
45
45
  "DATABASE_FORMATS",
@@ -45,6 +45,20 @@ TRANSFER_CHUNK_BYTES = 1 << 20
45
45
 
46
46
  _BACKOFF_BASE = 1.0
47
47
 
48
+ # The longest wait the sync client's clock can count, about 292 years: past it a lock's
49
+ # wait, `socket.settimeout` and `time.sleep` all raise OverflowError instead.
50
+ WAIT_CEILING = threading.TIMEOUT_MAX
51
+
52
+ # The longest `Retry-After` waited as given, 2^31 - 1 ms, the bound every SDK takes. One
53
+ # past it is still a throttle, waited out on the backoff: as given, 2147484 held a call
54
+ # for 24.8 days, and 9223372036854775807, 1e400 or a year-9999 date raised a raw
55
+ # OverflowError from the sync client's sleep (measured on 2.6.0, 2026-09-30).
56
+ RETRY_AFTER_CEILING = 2147483.647
57
+
58
+ # An `expires_in` past this, about 146 billion years, is a deadline nobody reaches. The
59
+ # cap keeps the poll's deadline a float: past 1.8e308 adding it to the clock raises.
60
+ POLL_DEADLINE_CAP = 2**62
61
+
48
62
  T = TypeVar("T")
49
63
 
50
64
 
@@ -124,8 +138,9 @@ def check_timeout(timeout: float | None) -> float | None:
124
138
  Refused where it is SET, because nothing downstream refuses it: zero, a negative
125
139
  number, NaN or a string reached the first call and failed it, and every call after,
126
140
  as a retried `network` error after three seconds of backoff, or for a string as a
127
- `server_error` blaming the API. Infinity is refused too, since the sync client's wait
128
- cannot hold it (`OverflowError`); None is the spelling for no bound.
141
+ `server_error` blaming the API. So is anything past `WAIT_CEILING`, infinity
142
+ included, since the sync client's wait cannot hold it: 1e300 failed every call with
143
+ a raw `OverflowError` (2.6.0, measured 2026-09-30). None is the spelling for no bound.
129
144
  """
130
145
  if timeout is None:
131
146
  return None
@@ -134,10 +149,11 @@ def check_timeout(timeout: float | None) -> float | None:
134
149
  or not isinstance(timeout, int | float)
135
150
  or not math.isfinite(timeout)
136
151
  or timeout <= 0
152
+ or timeout > WAIT_CEILING
137
153
  ):
138
154
  raise ValueError(
139
- f"timeout must be a number of seconds greater than 0, or None for no bound, "
140
- f"not {timeout!r}"
155
+ f"timeout must be a number of seconds greater than 0 and at most "
156
+ f"{WAIT_CEILING:.0f}, or None for no bound, not {timeout!r}"
141
157
  )
142
158
  return timeout
143
159
 
@@ -425,7 +441,11 @@ class Clock:
425
441
  return time.monotonic()
426
442
 
427
443
  def sleep(self, seconds: float) -> None:
428
- time.sleep(seconds)
444
+ # In parts, since `time.sleep` raises past WAIT_CEILING rather than waiting.
445
+ while seconds > 0:
446
+ part = min(seconds, WAIT_CEILING)
447
+ time.sleep(part)
448
+ seconds -= part
429
449
 
430
450
 
431
451
  class AsyncClock:
@@ -513,11 +533,12 @@ def retry_delay(err: InternetDataError, attempt: int, retries: int) -> float | N
513
533
 
514
534
  A server-supplied `Retry-After` wins over the backoff schedule outright: it is the
515
535
  only thing that makes a 429 worth retrying at all, so second-guessing it with a
516
- shorter wait would just spend the next attempt on the same rejection.
536
+ shorter wait would just spend the next attempt on the same rejection. One past
537
+ `RETRY_AFTER_CEILING` is waited out on the backoff instead.
517
538
  """
518
539
  if attempt >= retries or not err.retryable:
519
540
  return None
520
- if err.retry_after_seconds is not None:
541
+ if err.retry_after_seconds is not None and err.retry_after_seconds <= RETRY_AFTER_CEILING:
521
542
  return err.retry_after_seconds
522
543
  return _BACKOFF_BASE * (2.0**attempt)
523
544
 
@@ -26,6 +26,7 @@ from ._core import (
26
26
  OAUTH_METADATA_PATH,
27
27
  OAUTH_REVOKE_PATH,
28
28
  OAUTH_TOKEN_PATH,
29
+ POLL_DEADLINE_CAP,
29
30
  TRANSFER_CHUNK_BYTES,
30
31
  AsyncClock,
31
32
  as_error,
@@ -451,9 +452,14 @@ class AsyncOauthApi:
451
452
  # Refused before the first wait, not after it.
452
453
  check_timeout(timeout)
453
454
  interval = device.interval if device.interval >= 1 else 5
454
- deadline = self._clock.now() + device.expires_in
455
+ # Capped where a deadline stops meaning anything, since the clock is a float and
456
+ # `expires_in` is the server's integer, of any size.
457
+ deadline = self._clock.now() + min(device.expires_in, POLL_DEADLINE_CAP)
455
458
  while True:
456
- await self._clock.sleep(interval)
459
+ # No wait runs past the deadline: an interval ending after it waits only the
460
+ # time left, and the expiry follows with nothing sent. In full, an interval of
461
+ # 2147483647 held a poll with 2 s left for 68 years (2.6.0).
462
+ await self._clock.sleep(min(interval, max(deadline - self._clock.now(), 0)))
457
463
  if self._clock.now() >= deadline:
458
464
  raise OauthExpiredTokenError()
459
465
  try:
@@ -21,6 +21,7 @@ from ._core import (
21
21
  OAUTH_METADATA_PATH,
22
22
  OAUTH_REVOKE_PATH,
23
23
  OAUTH_TOKEN_PATH,
24
+ POLL_DEADLINE_CAP,
24
25
  TRANSFER_CHUNK_BYTES,
25
26
  Clock,
26
27
  as_error,
@@ -497,9 +498,14 @@ class OauthApi:
497
498
  # Refused before the first wait, not after it.
498
499
  check_timeout(timeout)
499
500
  interval = device.interval if device.interval >= 1 else 5
500
- deadline = self._clock.now() + device.expires_in
501
+ # Capped where a deadline stops meaning anything, since the clock is a float and
502
+ # `expires_in` is the server's integer, of any size.
503
+ deadline = self._clock.now() + min(device.expires_in, POLL_DEADLINE_CAP)
501
504
  while True:
502
- self._clock.sleep(interval)
505
+ # No wait runs past the deadline: an interval ending after it waits only the
506
+ # time left, and the expiry follows with nothing sent. In full, an interval of
507
+ # 2147483647 held a poll with 2 s left for 68 years (2.6.0).
508
+ self._clock.sleep(min(interval, max(deadline - self._clock.now(), 0)))
503
509
  if self._clock.now() >= deadline:
504
510
  raise OauthExpiredTokenError()
505
511
  try:
@@ -1215,6 +1215,72 @@
1215
1215
  5
1216
1216
  ]
1217
1217
  }
1218
+ },
1219
+ {
1220
+ "name": "interval-past-the-deadline-sleeps-only-to-it",
1221
+ "clientId": "internetdata-cli",
1222
+ "device": {
1223
+ "device_code": "mo_dc_poll",
1224
+ "user_code": "BCDF-GHJK",
1225
+ "verification_uri": "https://app.internetdata.io/device",
1226
+ "verification_uri_complete": "https://app.internetdata.io/device?user_code=BCDF-GHJK",
1227
+ "expires_in": 2,
1228
+ "interval": 2147483647
1229
+ },
1230
+ "why": "A sleep that would end past the local deadline ends at it, and the local expiry follows with no request sent: the outcome the full sleep reaches anyway, without asking the runtime for a sleep it may not hold (.NET's Task.Delay throws past ~49.7 days). 2147483647 is the largest interval every SDK's type holds.",
1231
+ "responses": [
1232
+ {
1233
+ "status": 400,
1234
+ "body": {
1235
+ "error": "authorization_pending"
1236
+ }
1237
+ }
1238
+ ],
1239
+ "expect": {
1240
+ "outcome": "expiredToken",
1241
+ "requests": 0,
1242
+ "waits": [
1243
+ 2
1244
+ ],
1245
+ "status": null
1246
+ }
1247
+ },
1248
+ {
1249
+ "name": "slow-down-past-the-deadline-sleeps-only-to-it",
1250
+ "clientId": "internetdata-cli",
1251
+ "device": {
1252
+ "device_code": "mo_dc_poll",
1253
+ "user_code": "BCDF-GHJK",
1254
+ "verification_uri": "https://app.internetdata.io/device",
1255
+ "verification_uri_complete": "https://app.internetdata.io/device?user_code=BCDF-GHJK",
1256
+ "expires_in": 8,
1257
+ "interval": 1
1258
+ },
1259
+ "why": "The cut applies to every sleep, a widened one included: 1, then 6 after the first slow_down, then only the 1 s left of the 8 rather than 11.",
1260
+ "responses": [
1261
+ {
1262
+ "status": 400,
1263
+ "body": {
1264
+ "error": "slow_down"
1265
+ }
1266
+ },
1267
+ {
1268
+ "status": 400,
1269
+ "body": {
1270
+ "error": "slow_down"
1271
+ }
1272
+ }
1273
+ ],
1274
+ "expect": {
1275
+ "outcome": "expiredToken",
1276
+ "requests": 2,
1277
+ "waits": [
1278
+ 1,
1279
+ 6,
1280
+ 1
1281
+ ],
1282
+ "status": null
1283
+ }
1218
1284
  }
1219
1285
  ]
1220
1286
  },
@@ -357,6 +357,34 @@ def test_a_rate_limit_is_retried_and_honors_retry_after(make_client: ClientFacto
357
357
  assert len(stub.requests) == 3
358
358
 
359
359
 
360
+ @pytest.mark.parametrize(
361
+ "retry_after", ["2147484", "9223372036854775807", "1e400", "Fri, 31 Dec 9999 23:59:59 GMT"]
362
+ )
363
+ def test_a_retry_after_past_the_ceiling_waits_the_backoff_and_stays_a_throttle(
364
+ make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, retry_after: str
365
+ ) -> None:
366
+ """As given, 2147484 held the call for 24.8 days, and the rest raised a raw
367
+ OverflowError from the sync client's sleep."""
368
+ monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
369
+ headers = {"Retry-After": retry_after}
370
+ stub = Stub({LIST_PATH: {"status": 429, "body": {"rc": "RATE_LIMITED"}, "headers": headers}})
371
+ client = make_client(transport=stub.transport, retries=1)
372
+
373
+ outcome = settle(client.database.list, within=5.0)
374
+
375
+ assert isinstance(outcome, InternetDataError), f"settled with {outcome!r}"
376
+ assert outcome.kind == "rate_limited"
377
+ assert len(stub.requests) == 2
378
+
379
+
380
+ def test_a_retry_after_is_waited_as_given_up_to_the_ceiling() -> None:
381
+ def throttled(seconds: float) -> InternetDataError:
382
+ return InternetDataError("rate_limited", "RATE_LIMITED", 429, seconds)
383
+
384
+ assert _core.retry_delay(throttled(2147483.647), 0, 1) == 2147483.647
385
+ assert _core.retry_delay(throttled(2147483.648), 0, 1) == _core._BACKOFF_BASE
386
+
387
+
360
388
  def test_a_transport_failure_surfaces_as_a_network_error() -> None:
361
389
  def refuse(request: httpx.Request) -> httpx.Response:
362
390
  raise httpx.ConnectError("connection refused", request=request)
@@ -441,7 +469,7 @@ def test_a_per_call_timeout_bounds_a_trickling_body_and_leaves_the_clients_own_a
441
469
  _assert_bounded_by(default, CLIENT_TIMEOUT)
442
470
 
443
471
 
444
- @pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), "30", True])
472
+ @pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), 1e300, "30", True])
445
473
  @pytest.mark.parametrize("call", PER_CALL)
446
474
  def test_a_per_call_timeout_no_attempt_can_meet_is_refused_before_any_request(
447
475
  make_client: ClientFactory, call: str, timeout: Any
@@ -494,7 +522,9 @@ def test_the_default_timeout_is_thirty_seconds() -> None:
494
522
 
495
523
 
496
524
  @pytest.mark.parametrize("client", [InternetData, AsyncInternetData])
497
- @pytest.mark.parametrize("timeout", [0, -1, 0.0, float("nan"), float("inf"), "30", True])
525
+ @pytest.mark.parametrize(
526
+ "timeout", [0, -1, 0.0, float("nan"), float("inf"), 9223372037, 1e300, "30", True]
527
+ )
498
528
  def test_a_timeout_no_attempt_can_meet_is_refused_when_the_client_is_built(
499
529
  client: type[InternetData | AsyncInternetData], timeout: Any
500
530
  ) -> None:
@@ -503,7 +533,7 @@ def test_a_timeout_no_attempt_can_meet_is_refused_when_the_client_is_built(
503
533
  client(API_KEY, timeout=timeout)
504
534
 
505
535
 
506
- @pytest.mark.parametrize("timeout", [None, 0.25, 1, 30])
536
+ @pytest.mark.parametrize("timeout", [None, 0.25, 1, 30, 9223372036])
507
537
  def test_a_usable_timeout_builds_a_client(timeout: float | None) -> None:
508
538
  InternetData(API_KEY, timeout=timeout).close()
509
539
  asyncio.run(AsyncInternetData(API_KEY, timeout=timeout).aclose())
@@ -326,6 +326,32 @@ def test_a_storage_5xx_before_the_body_is_retried(
326
326
  assert outcome == SMALL
327
327
 
328
328
 
329
+ @pytest.mark.parametrize("retry_after", ["2147484", "9223372036854775807"])
330
+ def test_a_storage_retry_after_past_the_ceiling_waits_the_backoff(
331
+ make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, retry_after: str
332
+ ) -> None:
333
+ """Object storage's 429 is bounded like the API's: as given, one held the transfer for
334
+ 24.8 days, and the other raised a raw OverflowError from the sync client's sleep."""
335
+ monkeypatch.setattr(_core, "_BACKOFF_BASE", 0.0)
336
+ requests: list[httpx.Request] = []
337
+
338
+ def handle(request: httpx.Request) -> httpx.Response:
339
+ requests.append(request)
340
+ if request.url.path == DOWNLOAD_PATH:
341
+ return httpx.Response(302, headers={"Location": BLOB})
342
+ return httpx.Response(429, headers={"Retry-After": retry_after})
343
+
344
+ client = make_client(
345
+ api_key=API_KEY, base_url=API, transport=httpx.MockTransport(handle), retries=1
346
+ )
347
+
348
+ outcome = settle(lambda: client.database.download_bytes("bogon_ip_v1", "csvgz"), within=5.0)
349
+
350
+ assert [r.url.path for r in requests].count("/blob") == 2, "storage's 429 and its retry"
351
+ assert isinstance(outcome, InternetDataError), f"settled with {outcome!r}"
352
+ assert outcome.kind == "rate_limited"
353
+
354
+
329
355
  @pytest.mark.parametrize("method", ["download", "download_bytes"])
330
356
  def test_a_transfer_that_dies_part_way_is_not_fetched_again(
331
357
  make_client: ClientFactory, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, method: str
@@ -305,6 +305,92 @@ def test_poll_device_token_waits_widens_and_ends_as_the_corpus_says(
305
305
  _assert_outcome(outcome, {**expect, "type": expect["outcome"]}, case["name"])
306
306
 
307
307
 
308
+ def test_the_poll_sleeps_the_fraction_left_before_its_deadline(make_client: ClientFactory) -> None:
309
+ """On the real clock, which the corpus's seam replaces. The second wait is the ~0.99 s
310
+ left, so the deadline passes with one request sent; a sleep that dropped the fraction
311
+ would poll again at once, and this stub approves that second poll."""
312
+ bound = LoopBound()
313
+ replies = [
314
+ {"status": 400, "body": {"error": "authorization_pending"}},
315
+ {"status": 200, "body": EVERY_REQUIRED_MEMBER},
316
+ ]
317
+ stub = OauthStub(replies, bound, limit=2)
318
+ client = make_client(base_url=BASE_URL, transport=stub.transport)
319
+ device = DeviceAuthorization(
320
+ device_code="mo_dc_x",
321
+ user_code="BCDF-GHJK",
322
+ verification_uri="https://app.example.test/device",
323
+ expires_in=2,
324
+ interval=1,
325
+ )
326
+
327
+ outcome = settle(lambda: client.oauth.poll_device_token("internetdata-cli", device), bound)
328
+
329
+ assert isinstance(outcome, OauthExpiredTokenError), f"settled with {outcome!r}"
330
+ assert outcome.status is None, "the local deadline, not the server's"
331
+ assert len(stub.requests) == 1
332
+
333
+
334
+ def test_the_real_clock_sleeps_past_what_time_sleep_can_count_in_parts(
335
+ monkeypatch: pytest.MonkeyPatch,
336
+ ) -> None:
337
+ """`time.sleep` raises OverflowError past WAIT_CEILING, and a poll's wait is the server's
338
+ `interval` or `expires_in`, of any size."""
339
+ waits: list[float] = []
340
+ monkeypatch.setattr(time, "sleep", waits.append)
341
+
342
+ _core.Clock().sleep(_core.WAIT_CEILING * 2.5)
343
+
344
+ assert waits == [_core.WAIT_CEILING, _core.WAIT_CEILING, _core.WAIT_CEILING / 2]
345
+
346
+
347
+ def test_a_poll_whose_request_outlives_the_deadline_waits_nothing_more(
348
+ make_client: ClientFactory,
349
+ ) -> None:
350
+ """The request itself takes the clock 10 s past the deadline, so the time left is
351
+ negative, and `time.sleep` raises ValueError for a negative wait."""
352
+ bound = LoopBound()
353
+ clock = FakeClock(bound)
354
+ stub = OauthStub([{"status": 400, "body": {"error": "authorization_pending"}}], bound, limit=1)
355
+
356
+ def slow(request: httpx.Request) -> httpx.Response:
357
+ clock._elapsed += 10
358
+ return stub._handle(request)
359
+
360
+ client = make_client(base_url=BASE_URL, transport=httpx.MockTransport(slow))
361
+ client.oauth.use_clock(clock)
362
+ device = DeviceAuthorization(**{**_device_fields(), "expires_in": 5, "interval": 1})
363
+
364
+ outcome = settle(lambda: client.oauth.poll_device_token("internetdata-cli", device), bound)
365
+
366
+ assert isinstance(outcome, OauthExpiredTokenError), f"settled with {outcome!r}"
367
+ assert clock.waits == [1, 0]
368
+
369
+
370
+ def test_an_expires_in_past_any_float_still_polls(make_client: ClientFactory) -> None:
371
+ """The server's integer, of any size: added to the clock whole, 10**400 raised a raw
372
+ OverflowError before the first wait."""
373
+ bound = LoopBound()
374
+ stub = OauthStub([{"status": 200, "body": EVERY_REQUIRED_MEMBER}], bound, limit=1)
375
+ client = make_client(base_url=BASE_URL, transport=stub.transport)
376
+ clock = FakeClock(bound)
377
+ client.oauth.use_clock(clock)
378
+ device = DeviceAuthorization(**{**_device_fields(), "expires_in": 10**400, "interval": 1})
379
+
380
+ outcome = settle(lambda: client.oauth.poll_device_token("internetdata-cli", device), bound)
381
+
382
+ assert isinstance(outcome, TokenResponse), f"settled with {outcome!r}"
383
+ assert clock.waits == [1]
384
+
385
+
386
+ def _device_fields() -> dict[str, Any]:
387
+ return {
388
+ "device_code": "mo_dc_x",
389
+ "user_code": "BCDF-GHJK",
390
+ "verification_uri": "https://app.example.test/device",
391
+ }
392
+
393
+
308
394
  def test_cancelling_a_poll_during_its_first_wait_ends_it_at_once() -> None:
309
395
  bound = LoopBound()
310
396
  stub = OauthStub([{"status": 400, "body": {"error": "authorization_pending"}}], bound, limit=1)
File without changes
File without changes
File without changes