ipwhois-python 1.0.2__tar.gz → 1.2.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.
@@ -5,6 +5,37 @@ All notable changes to `ipwhois-python` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2026-05-10
9
+
10
+ ### Added
11
+
12
+ - Every error response now carries an `error_type` field, including errors
13
+ returned by the API. The new value `'api'` joins the existing `'network'`
14
+ and `'invalid_argument'` codes, so callers can branch on the category of
15
+ any failure with a single `info["error_type"]` check — no need to combine
16
+ `success` with `http_status` to distinguish API vs. non-API errors.
17
+ Applies to HTTP 4xx / 5xx responses, malformed JSON bodies, and HTTP 2xx
18
+ responses where the API itself sets `success: false` (e.g. "Invalid IP
19
+ address", "Reserved range").
20
+
21
+ ### Changed
22
+
23
+ - `retry_after` is now only attached to HTTP 429 responses on the **free
24
+ plan** (`ipwho.is`). The paid endpoint (`ipwhois.pro`) does not send a
25
+ `Retry-After` header, so reading it on paid plans is now skipped and the
26
+ field will not appear there. Behaviour on the free plan is unchanged.
27
+ - README "Setting defaults once" section now shows the Free and Paid plans
28
+ as two separate code blocks, matching the layout used in "Quick start"
29
+ and "HTTPS encryption". The setters work identically on both plans, so
30
+ the lookup-override snippet is shared underneath.
31
+ - README "Error response fields" table now lists `message` explicitly (it
32
+ has always been present on every error response) and the `error_type`
33
+ row covers the new `'api'` value as well.
34
+ - The `_request()` HTTP-error code path was lightly refactored so the
35
+ `Retry-After` header is parsed in one place instead of two (one for the
36
+ dict branch and one for the list branch). No behaviour change beyond the
37
+ free-plan gating noted above.
38
+
8
39
  ## [1.0.2] - 2026-05-10
9
40
 
10
41
  ### Removed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipwhois-python
3
- Version: 1.0.2
3
+ Version: 1.2.0
4
4
  Summary: Official Python client for the ipwhois.io IP Geolocation API. Simple, dependency-free, supports single and bulk IP lookups.
5
5
  Project-URL: Homepage, https://ipwhois.io
6
6
  Project-URL: Documentation, https://ipwhois.io/documentation
@@ -154,14 +154,33 @@ Defaults are set with fluent setters — `set_language()`, `set_fields()`,
154
154
  `set_user_agent()` — and can be chained:
155
155
 
156
156
  ```python
157
- # Pass "YOUR_API_KEY" to the constructor for the paid plan; otherwise omit it.
157
+ from ipwhois import IPWhois
158
+
159
+ # Free plan
158
160
  ipwhois = (
159
161
  IPWhois()
160
162
  .set_language("en")
161
163
  .set_fields(["success", "country", "city", "flag.emoji"])
162
164
  .set_timeout(8)
163
165
  )
166
+ ```
164
167
 
168
+ ```python
169
+ from ipwhois import IPWhois
170
+
171
+ # Paid plan
172
+ ipwhois = (
173
+ IPWhois("YOUR_API_KEY")
174
+ .set_language("en")
175
+ .set_fields(["success", "country", "city", "flag.emoji"])
176
+ .set_timeout(8)
177
+ )
178
+ ```
179
+
180
+ Either client behaves the same way at call time — per-call options always
181
+ win over the defaults:
182
+
183
+ ```python
165
184
  ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
166
185
  ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
167
186
  ```
@@ -250,14 +269,16 @@ application — you decide how to react.
250
269
 
251
270
  ### Error response fields
252
271
 
253
- Every error response contains `success: False` and a `message`. Some errors
254
- include extra fields you can branch on:
272
+ Every error response contains `success: False`, a human-readable `message`,
273
+ and an `error_type` so you can branch on the category of the failure. Some
274
+ errors include extra fields you can branch on:
255
275
 
256
- | Field | When it's present |
257
- | -------------- | ---------------------------------------------------------------------------- |
258
- | `error_type` | `'network'` or `'invalid_argument'` for non-API errors |
259
- | `http_status` | On HTTP 4xx / 5xx responses |
260
- | `retry_after` | On HTTP 429 if the API sent a `Retry-After` header |
276
+ | Field | When it's present |
277
+ | -------------- | -------------------------------------------------------------------------------------------- |
278
+ | `message` | Always human-readable description of what went wrong |
279
+ | `error_type` | Always one of `'api'`, `'network'`, or `'invalid_argument'` |
280
+ | `http_status` | On HTTP 4xx / 5xx responses |
281
+ | `retry_after` | On HTTP 429 — **free plan only** (the paid endpoint does not send a `Retry-After` header) |
261
282
 
262
283
  ```python
263
284
  import time
@@ -347,9 +368,9 @@ An **error** response looks like:
347
368
  {
348
369
  "success": false,
349
370
  "message": "Invalid IP address",
371
+ "error_type": "api", // 'api' / 'network' / 'invalid_argument'
350
372
  "http_status": 400 // present for HTTP 4xx / 5xx
351
- // "retry_after": 60 // additionally present on HTTP 429 if the API sent a Retry-After header
352
- // "error_type": "network" // present for non-API errors: 'network', 'invalid_argument'
373
+ // "retry_after": 60 // additionally present on HTTP 429 free plan only
353
374
  }
354
375
  ```
355
376
 
@@ -101,14 +101,33 @@ Defaults are set with fluent setters — `set_language()`, `set_fields()`,
101
101
  `set_user_agent()` — and can be chained:
102
102
 
103
103
  ```python
104
- # Pass "YOUR_API_KEY" to the constructor for the paid plan; otherwise omit it.
104
+ from ipwhois import IPWhois
105
+
106
+ # Free plan
105
107
  ipwhois = (
106
108
  IPWhois()
107
109
  .set_language("en")
108
110
  .set_fields(["success", "country", "city", "flag.emoji"])
109
111
  .set_timeout(8)
110
112
  )
113
+ ```
111
114
 
115
+ ```python
116
+ from ipwhois import IPWhois
117
+
118
+ # Paid plan
119
+ ipwhois = (
120
+ IPWhois("YOUR_API_KEY")
121
+ .set_language("en")
122
+ .set_fields(["success", "country", "city", "flag.emoji"])
123
+ .set_timeout(8)
124
+ )
125
+ ```
126
+
127
+ Either client behaves the same way at call time — per-call options always
128
+ win over the defaults:
129
+
130
+ ```python
112
131
  ipwhois.lookup("8.8.8.8") # uses lang=en, the field whitelist, and timeout=8
113
132
  ipwhois.lookup("1.1.1.1", lang="de") # overrides lang for this single call only
114
133
  ```
@@ -197,14 +216,16 @@ application — you decide how to react.
197
216
 
198
217
  ### Error response fields
199
218
 
200
- Every error response contains `success: False` and a `message`. Some errors
201
- include extra fields you can branch on:
219
+ Every error response contains `success: False`, a human-readable `message`,
220
+ and an `error_type` so you can branch on the category of the failure. Some
221
+ errors include extra fields you can branch on:
202
222
 
203
- | Field | When it's present |
204
- | -------------- | ---------------------------------------------------------------------------- |
205
- | `error_type` | `'network'` or `'invalid_argument'` for non-API errors |
206
- | `http_status` | On HTTP 4xx / 5xx responses |
207
- | `retry_after` | On HTTP 429 if the API sent a `Retry-After` header |
223
+ | Field | When it's present |
224
+ | -------------- | -------------------------------------------------------------------------------------------- |
225
+ | `message` | Always human-readable description of what went wrong |
226
+ | `error_type` | Always one of `'api'`, `'network'`, or `'invalid_argument'` |
227
+ | `http_status` | On HTTP 4xx / 5xx responses |
228
+ | `retry_after` | On HTTP 429 — **free plan only** (the paid endpoint does not send a `Retry-After` header) |
208
229
 
209
230
  ```python
210
231
  import time
@@ -294,9 +315,9 @@ An **error** response looks like:
294
315
  {
295
316
  "success": false,
296
317
  "message": "Invalid IP address",
318
+ "error_type": "api", // 'api' / 'network' / 'invalid_argument'
297
319
  "http_status": 400 // present for HTTP 4xx / 5xx
298
- // "retry_after": 60 // additionally present on HTTP 429 if the API sent a Retry-After header
299
- // "error_type": "network" // present for non-API errors: 'network', 'invalid_argument'
320
+ // "retry_after": 60 // additionally present on HTTP 429 free plan only
300
321
  }
301
322
  ```
302
323
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ipwhois-python"
7
- version = "1.0.2"
7
+ version = "1.2.0"
8
8
  description = "Official Python client for the ipwhois.io IP Geolocation API. Simple, dependency-free, supports single and bulk IP lookups."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -55,7 +55,7 @@ class IPWhois:
55
55
  """
56
56
 
57
57
  #: Library version, used in the default User-Agent header.
58
- VERSION: str = "1.0.2"
58
+ VERSION: str = "1.2.0"
59
59
 
60
60
  #: Free-plan endpoint host (used when no API key is provided).
61
61
  HOST_FREE: str = "ipwho.is"
@@ -434,6 +434,7 @@ class IPWhois:
434
434
  f"(HTTP {status}): {snippet}"
435
435
  ),
436
436
  "http_status": status,
437
+ "error_type": "api",
437
438
  }
438
439
 
439
440
  if not isinstance(decoded, (dict, list)):
@@ -457,12 +458,6 @@ class IPWhois:
457
458
  "message": message,
458
459
  "http_status": status,
459
460
  }
460
-
461
- if status == 429 and "retry-after" in headers:
462
- try:
463
- decoded["retry_after"] = int(headers["retry-after"])
464
- except (TypeError, ValueError):
465
- pass
466
461
  else:
467
462
  # List response with error status -- wrap as an error dict.
468
463
  decoded = {
@@ -470,11 +465,32 @@ class IPWhois:
470
465
  "message": f"HTTP {status} returned by ipwhois API",
471
466
  "http_status": status,
472
467
  }
473
- if status == 429 and "retry-after" in headers:
474
- try:
475
- decoded["retry_after"] = int(headers["retry-after"])
476
- except (TypeError, ValueError):
477
- pass
468
+
469
+ # `Retry-After` is only emitted by the free-plan endpoint
470
+ # (ipwho.is); the paid endpoint (ipwhois.pro) does not send the
471
+ # header, so don't try to read it there.
472
+ if (
473
+ status == 429
474
+ and self._api_key is None
475
+ and "retry-after" in headers
476
+ ):
477
+ try:
478
+ decoded["retry_after"] = int(headers["retry-after"])
479
+ except (TypeError, ValueError):
480
+ pass
481
+
482
+ # Tag every API-shaped error (`success: False` returned by the API,
483
+ # on any HTTP status) with `error_type: 'api'` so callers can branch
484
+ # on the category alongside the non-API codes ('network',
485
+ # 'environment', 'invalid_argument'). HTTP 2xx + success=false bodies
486
+ # (e.g. "Invalid IP address", "Reserved range") are otherwise passed
487
+ # through untouched.
488
+ if (
489
+ isinstance(decoded, dict)
490
+ and decoded.get("success") is False
491
+ and "error_type" not in decoded
492
+ ):
493
+ decoded["error_type"] = "api"
478
494
 
479
495
  # For HTTP 2xx with `success: false` (e.g. "Invalid IP address",
480
496
  # "Reserved range") we just pass the body through -- it is already
@@ -328,3 +328,120 @@ def test_non_json_response_is_treated_as_error() -> None:
328
328
  assert result["success"] is False
329
329
  assert "Invalid JSON" in result.get("message", "")
330
330
  assert result.get("http_status") == 200
331
+ assert result.get("error_type") == "api"
332
+
333
+
334
+ # --------------------------------------------------------------------- #
335
+ # Response shaping -- the urllib layer is stubbed via mock.patch so #
336
+ # the suite can exercise error tagging without making real HTTP calls. #
337
+ # --------------------------------------------------------------------- #
338
+
339
+
340
+ class _FakeOkResp:
341
+ """Minimal stand-in for a urllib response context manager (HTTP 2xx)."""
342
+
343
+ def __init__(self, status: int, body: bytes, headers: dict) -> None:
344
+ self.status = status
345
+ self._body = body
346
+ self.headers = headers
347
+
348
+ def __enter__(self) -> "_FakeOkResp":
349
+ return self
350
+
351
+ def __exit__(self, *_: object) -> None:
352
+ return None
353
+
354
+ def read(self) -> bytes:
355
+ return self._body
356
+
357
+ def getcode(self) -> int:
358
+ return self.status
359
+
360
+
361
+ def _fake_http_error(status: int, body: bytes, headers: dict):
362
+ """Build a urllib.error.HTTPError for status >= 400.
363
+
364
+ The 4xx / 5xx code path inside ``_request`` is reached via this
365
+ exception, not via the success branch, so tests that target it have
366
+ to make ``urlopen`` raise.
367
+ """
368
+ import io
369
+ import urllib.error
370
+
371
+ return urllib.error.HTTPError(
372
+ url="https://example.test",
373
+ code=status,
374
+ msg="error",
375
+ hdrs=headers, # type: ignore[arg-type]
376
+ fp=io.BytesIO(body),
377
+ )
378
+
379
+
380
+ def test_2xx_with_success_false_is_tagged_as_api_error() -> None:
381
+ # The API returns 200 with `success: False` for things like
382
+ # "Reserved range" or "Invalid IP address" -- these should be passed
383
+ # through without `http_status` (which is reserved for 4xx/5xx),
384
+ # but tagged with `error_type: 'api'` so callers can branch on the
385
+ # category the same way they branch on 'network' / 'environment' /
386
+ # 'invalid_argument'.
387
+ from unittest.mock import patch
388
+
389
+ body = (
390
+ b'{"success": false, "message": "Reserved range", "ip": "127.0.0.1"}'
391
+ )
392
+ fake = _FakeOkResp(200, body, headers={})
393
+ with patch("urllib.request.urlopen", return_value=fake):
394
+ result = IPWhois().lookup("127.0.0.1")
395
+
396
+ assert result["success"] is False
397
+ assert result.get("message") == "Reserved range"
398
+ assert result.get("ip") == "127.0.0.1"
399
+ assert "http_status" not in result
400
+ assert result.get("error_type") == "api"
401
+
402
+
403
+ def test_4xx_response_is_normalised_with_error_type_api() -> None:
404
+ from unittest.mock import patch
405
+
406
+ body = b'{"success": false, "message": "Invalid API key"}'
407
+ err = _fake_http_error(401, body, headers={})
408
+ with patch("urllib.request.urlopen", side_effect=err):
409
+ result = IPWhois("BAD").lookup("8.8.8.8")
410
+
411
+ assert result["success"] is False
412
+ assert result.get("http_status") == 401
413
+ assert result.get("message") == "Invalid API key"
414
+ assert result.get("error_type") == "api"
415
+
416
+
417
+ def test_429_on_free_plan_attaches_retry_after() -> None:
418
+ # The free-plan endpoint (ipwho.is) sends `Retry-After` on rate-limit
419
+ # responses; the client surfaces it as `retry_after`.
420
+ from unittest.mock import patch
421
+
422
+ body = b'{"success": false, "message": "Rate limited"}'
423
+ err = _fake_http_error(429, body, headers={"retry-after": "42"})
424
+ with patch("urllib.request.urlopen", side_effect=err):
425
+ result = IPWhois().lookup("8.8.8.8") # free plan -- no API key
426
+
427
+ assert result["success"] is False
428
+ assert result.get("http_status") == 429
429
+ assert result.get("retry_after") == 42
430
+ assert result.get("error_type") == "api"
431
+
432
+
433
+ def test_429_on_paid_plan_does_not_attach_retry_after() -> None:
434
+ # The paid endpoint (ipwhois.pro) does not send `Retry-After`. Even
435
+ # if a header is present (proxies, test stubs, ...), the client
436
+ # ignores it on paid plans so `retry_after` will not appear.
437
+ from unittest.mock import patch
438
+
439
+ body = b'{"success": false, "message": "Rate limited"}'
440
+ err = _fake_http_error(429, body, headers={"retry-after": "42"})
441
+ with patch("urllib.request.urlopen", side_effect=err):
442
+ result = IPWhois("KEY").lookup("8.8.8.8") # paid plan
443
+
444
+ assert result["success"] is False
445
+ assert result.get("http_status") == 429
446
+ assert "retry_after" not in result
447
+ assert result.get("error_type") == "api"
File without changes