Skip to content

Commit 3887d93

Browse files
Merge pull request #35 from ipdata/http-reuse-and-new-fields
Reuse HTTPS connections, negotiate HTTP/2, add new response fields
2 parents 4d57900 + b968cc2 commit 3887d93

9 files changed

Lines changed: 1001 additions & 44 deletions

File tree

‎.github/workflows/python-publish.yml‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,36 @@ on:
66
- master
77
tags:
88
- '*'
9+
pull_request:
910

1011
jobs:
12+
test:
13+
name: Test (Python ${{ matrix.python-version }})
14+
# ubuntu-latest now resolves to Ubuntu 26.04, for which actions/setup-python has no Python 3.9 builds
15+
runs-on: ubuntu-24.04
16+
strategy:
17+
fail-fast: false
18+
matrix:
19+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
20+
steps:
21+
- uses: actions/checkout@v4
22+
- name: Set up Python ${{ matrix.python-version }}
23+
uses: actions/setup-python@v5
24+
with:
25+
python-version: ${{ matrix.python-version }}
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
python -m pip install -r requirements.txt
30+
- name: Test with pytest (offline tests only, no API key needed)
31+
run: |
32+
python -m pytest -m "not api"
33+
1134
build-n-publish:
1235
name: Build and publish to PyPI
13-
runs-on: ubuntu-latest
36+
if: github.event_name == 'push'
37+
# the pinned Python 3.10.4 below has no build for Ubuntu 26.04 either
38+
runs-on: ubuntu-24.04
1439
steps:
1540
- uses: actions/checkout@v4
1641
- name: Set up Python 3.10.4

‎README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Visit our [Documentation](https://docs.ipdata.co/) for more examples and tutoria
2121
- [Getting only one field](#getting-only-one-field)
2222
- [Getting a number of specific fields](#getting-a-number-of-specific-fields)
2323
- [Bulk Lookups](#bulk-lookups)
24+
- [Connection reuse and HTTP/2](#connection-reuse-and-http2)
2425
- [Using the ipdata CLI](#using-the-ipdata-cli)
2526
- [Windows Installation Notes](#windows-installation-notes)
2627
- [Available commands](#available-commands)
@@ -367,6 +368,24 @@ NOTE: Alternatively it is much simpler to process bulk lookups using the `ipdata
367368
```
368369
</details>
369370

371+
### Connection reuse and HTTP/2
372+
373+
An `IPData` client keeps a persistent HTTPS connection pool for as long as it lives. Create one client per process (or one per API key or endpoint), keep it, and reuse it for every lookup: each new client pays a fresh TLS handshake. Requests to api.ipdata.co are multiplexed over HTTP/2 when the platform negotiates it, and fall back to HTTP/1.1 keep-alive otherwise.
374+
375+
The client is built on [httpx](https://www.python-httpx.org/) and is thread-safe: one instance can serve every thread of a process at once (the CLI's `batch` command shares a single client across its thread pool). Idle connections are kept open for 60 seconds and up to 20 connections are pooled. Call `close()` at shutdown, or use the client as a context manager, to release the pool. Pass `http2=False` to stay on HTTP/1.1 keep-alive, or `transport=` to inject your own `httpx.BaseTransport` (for example an `httpx.MockTransport` in tests).
376+
377+
```python
378+
from ipdata import IPData
379+
380+
with IPData("<YOUR API KEY>") as client: # one client, many lookups
381+
for ip in ["8.8.8.8", "1.1.1.1"]:
382+
print(client.lookup(ip).country_name)
383+
```
384+
385+
The module-level helpers `ipdata.lookup()` and `ipdata.bulk()` share one process-wide client, `ipdata.default_client`, which is created on first use and lives until the process exits. Connections must not be shared across `os.fork()` or `multiprocessing` boundaries: create your `IPData` instance (or make the first module-level call) in the child process, after forking.
386+
387+
Failed requests are retried up to `retry_limit` times (default 7). GET and POST requests that fail with a transport error (connection reset, timeout) or with HTTP 500, 502, 503 or 504 are retried with exponential backoff: 0.5, 1, 2, 4, 8, 16 and 32 seconds for the default `retry_backoff_factor=1`. A 429 response is retried only when it carries a `Retry-After` header (waiting at most 60 seconds); without one it is returned immediately, so an exhausted quota fails fast. The `HTTPS_PROXY` and `NO_PROXY` environment variables are honoured; a custom CA bundle is read from `SSL_CERT_FILE` (httpx does not read `REQUESTS_CA_BUNDLE`).
388+
370389
## Using the ipdata CLI
371390

372391

@@ -546,6 +565,18 @@ The `--fields` option is required to use CSV output.
546565
547566
A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields)
548567
568+
The following fields were added to the API in November 2026. The library passes them through unchanged, and both top-level flags are accepted by the `fields` and `select_field` parameters of `lookup()` and `bulk()` and by the CLI's `-f` and `-e` options.
569+
570+
| Field | Description |
571+
|-------|-------------|
572+
| `is_satellite` | `true` when the address belongs to a satellite internet provider (for example Starlink). Present only when true, omitted otherwise. |
573+
| `is_anycast` | `true` when the address is an anycast address announced from several locations at once (for example `8.8.8.8`). Present only when true, omitted otherwise. |
574+
| `threat.blocklists[].first_seen` | The UTC calendar date (a `YYYY-MM-DD` string) on which that blocklist first listed the address. Optional: some feeds, for example ipdata's own vpn feed, do not provide it. |
575+
| `threat.blocklists[].last_seen` | The UTC calendar date (a `YYYY-MM-DD` string) on which that blocklist last listed the address. Optional, as above. |
576+
| `threat.blocklists[].days_seen` | The number of days the address has been listed by that blocklist (an integer). Optional, as above. |
577+
578+
`is_satellite` and `is_anycast` are included in a response only when they are `true`. When absent they are missing from the dictionary and read as `None` through dot notation (`response.is_satellite`), so treat anything falsy as `false`. The dates are kept as strings and are not parsed. Fields the library does not know about yet are passed through as well.
579+
549580
## Geofeed tools
550581
551582
Geofeed publishers can use the `ipdata validate` command to validate their geofeeds before submission to ipdata. This will catch most but not all issues that might cause processing your geofeed to fail.
@@ -754,3 +785,9 @@ To run all tests
754785
```shell
755786
pytest
756787
```
788+
789+
The tests marked `api` call the live API and need an `IPDATA_API_KEY` environment variable (or a `src/ipdata/.env` file). To run only the offline tests, as CI does for pull requests:
790+
791+
```shell
792+
pytest -m "not api"
793+
```

‎requirements.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
requests
1+
httpx[http2]>=0.28,<1
22
rich
33
click
44
click_default_group

‎setup.cfg‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = ipdata
3-
version = 4.0.9
3+
version = 4.1.0
44
author = Jonathan Kosgei
55
author_email = jonathan@ipdata.co
66
description = This is the official IPData client library for Python
@@ -24,7 +24,7 @@ package_dir =
2424
packages = find:
2525
python_requires = >=3.9
2626
install_requires =
27-
requests
27+
httpx[http2]>=0.28,<1
2828
rich
2929
click
3030
click_default_group

‎src/ipdata/geofeeds.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import random
99
from pathlib import Path
1010

11-
import requests
11+
import httpx
1212

1313
from .codes import COUNTRIES, REGION_CODES
1414

@@ -49,7 +49,7 @@ def _download(self):
4949
cache_path = f"{self.dir}/{random_name}.csv"
5050

5151
try:
52-
response = requests.get(self.source, timeout=60)
52+
response = httpx.get(self.source, timeout=60, follow_redirects=True)
5353
text = response.text
5454
with open(cache_path, "w") as f:
5555
f.write(

0 commit comments

Comments
 (0)