Skip to content

Reuse HTTPS connections, negotiate HTTP/2, add new response fields - #35

Merged
jonathan-kosgei merged 3 commits into
masterfrom
http-reuse-and-new-fields
Nov 3, 2026
Merged

jonathan-kosgei merged 3 commits into
masterfrom
http-reuse-and-new-fields

Conversation

@jonathan-kosgei

@jonathan-kosgei jonathan-kosgei commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Every IPData instance now owns one persistent HTTPS connection pool (an httpx.Client; 20 connections, 60 s keep-alive) and multiplexes its requests over HTTP/2 when api.ipdata.co negotiates it, falling back to HTTP/1.1 keep-alive otherwise, so the library no longer pays a TCP + TLS handshake per call. requests is replaced by httpx[http2]>=0.28,<1. When no transport is injected, the client lets httpx build its default transport, so the pool limits, http2, the proxy environment variables and SSL_CERT_FILE all apply; the httpcore-level retries knob is not used, and connection failures (httpx.ConnectError, ConnectTimeout) are TransportErrors handled by the library's own retry loop with the documented schedule, which avoids two retry layers compounding into 8 x 8 connection attempts for an unreachable host. Neither TCP keepalive nor HTTP/2 PING frames are configured; the idle policy is the 60 s pool expiry. The new offline tests live in src/ipdata/test_client.py and src/ipdata/test_response_fields.py; src/ipdata/test_ipdata.py keeps the four live tests marked api unchanged.

Commits:

  1. 7c24765 Reuse HTTPS connections and negotiate HTTP/2 per IPData instance: one httpx.Client per instance, http2=True and transport=None constructor keywords, working retries, close() / context manager, bulk() timeout, ipdata-python/<version> User-Agent plus Accept: application/json, httpx.get(..., follow_redirects=True) in geofeeds, dependency and version (4.1.0) changes, CI matrix with a pull_request trigger, offline tests, README section "Connection reuse and HTTP/2".
  2. 0932516 Add is_satellite, is_anycast and blocklist date fields: both names join IPData.valid_fields, fixtures built from the API's sample responses, README fields documentation.
  3. b968cc2 Run CI on ubuntu-24.04 so Python 3.9 and the pinned 3.10.4 resolve: follow-up after the first PR run failed for 3.9 at actions/setup-python. ubuntu-latest now resolves to the Ubuntu 26.04 image, and the actions/python-versions manifest has no Python 3.9 build for it (3.9.20 to 3.9.25 exist for 22.04 and 24.04 only) and no build of the 3.10.4 the publish job pins (20.04, 22.04 and 24.04 only), so both jobs are pinned to runs-on: ubuntu-24.04. The runner image is the only change to the publish job; its Python pin, steps and tag gate are untouched.

CI: besides the new matrix job, the existing build-n-publish job gained if: github.event_name == 'push' so that pull requests do not run the live tests or the build; it still runs the full suite with the secret on pushes to master and tags, and publishing stays gated on startsWith(github.ref, 'refs/tags').

What changes for users

  • One IPData instance keeps its connections alive and reuses them. Create one client per process (or per API key or endpoint), reuse it for every lookup, and call close() or use with IPData(...) as client: at shutdown. Instances are thread-safe (the CLI batch command already shares one across its thread pool and now multiplexes over one HTTP/2 connection). Create clients after forking; never share one across os.fork() / multiprocessing boundaries. The module-level ipdata.lookup() / ipdata.bulk() helpers keep their process-wide ipdata.default_client, created on first use and living until the process exits.
  • New optional constructor keywords: http2=True (set False for HTTP/1.1 keep-alive only) and transport=None (inject any httpx.BaseTransport, for example httpx.MockTransport in tests). All existing positional and keyword arguments keep working. When a transport is injected, httpx 0.28 applies neither the pool limits nor the HTTPS_PROXY / NO_PROXY environment variables to it, so configure those on the transport itself.
  • retry_limit and retry_backoff_factor now do what the docstring promised. They were dead: the retrying adapter was mounted on the prefix "http", which requests never selects (it matches on "http://" / "https://"). GET and POST requests (including POST /bulk) now retry on HTTP 500, 502, 503, 504 and on transport errors (reset, EOF or timeout on a stale pooled connection, including a transport error while the body of a non-streaming response is being read) with sleeps of retry_backoff_factor * 2 ** (attempt - 2) seconds: 0.5, 1, 2, 4, 8, 16, 32 s for the defaults. httpx.UnsupportedProtocol is not retried, since a misconfigured endpoint scheme can never succeed. A 429 is retried only when the response carries Retry-After (sleeping min(Retry-After, 60 s)); otherwise it is returned immediately with status == 429, exactly as the library has always returned non-200 responses, so an exhausted quota fails fast and no new exception type is introduced (ipdata init keeps printing response.message on failure). After the retries are exhausted the last response is returned with its status, or the transport error is raised as IPDataException as before.
  • bulk() now uses the client timeout (default 60 s); it previously sent its POST with no timeout at all.
  • The User-Agent is ipdata-python/<installed version> (ipdata-python/unknown from an uninstalled source checkout) and requests send Accept: application/json.
  • Geofeed downloads follow redirects, as they did with requests.
  • Dependency: requests is gone, httpx[http2]>=0.28,<1 (with h2) is required. Behavioural differences inherited from httpx: a custom CA bundle must be supplied through SSL_CERT_FILE / SSL_CERT_DIR (httpx does not read REQUESTS_CA_BUNDLE); HTTPS_PROXY / NO_PROXY keep working; timeout must be a number, None, or an httpx.Timeout (a (connect, read) tuple, which requests accepted, now raises at construction).
  • New response fields are passed through unchanged: is_satellite and is_anycast (top-level booleans, present only when true, otherwise absent and therefore None through dot notation) and the optional first_seen, last_seen (YYYY-MM-DD strings, UTC) and days_seen (integer) attributes of threat.blocklists entries. Both flags are accepted by fields= / select_field= and by the CLI -f / -e options.
  • Nothing else changes: DotDict, IPDataException, the validation helpers, the CLI commands and the module-level API are untouched. Internally, the private requests-based attribute is replaced by the private _client (an httpx.Client).

Release notes

Version 4.1.0 (setup.cfg). Tag to push after merge: 4.1.0 (the tag push triggers the existing PyPI publish job).

ipdata 4.1.0

Connection reuse and HTTP/2
* Each IPData instance owns one persistent HTTPS connection pool (httpx; 20 connections,
  60 s keep-alive) and multiplexes requests over HTTP/2 when api.ipdata.co negotiates it,
  falling back to HTTP/1.1 keep-alive. Create one client per process (or per API key or
  endpoint) and reuse it for every lookup: each new client pays a fresh TLS handshake.
* New optional constructor arguments http2=True and transport=None (inject any
  httpx.BaseTransport, e.g. httpx.MockTransport in tests). New close() method; IPData is a
  context manager. Instances are thread-safe; create them after forking.
* retry_limit and retry_backoff_factor now work as documented (they were silently inactive):
  GET and POST requests retry on HTTP 500/502/503/504 and on transport errors, sleeping
  0.5, 1, 2, 4, 8, 16 and 32 s with the defaults. HTTP 429 is retried only when the
  response carries a Retry-After header (waiting at most 60 s) and is otherwise returned
  immediately, so an exhausted quota fails fast.
* bulk() now honours the client timeout (it previously sent its request without one).
* User-Agent is ipdata-python/<version>; requests send Accept: application/json.

New response fields (passed through unchanged)
* is_satellite and is_anycast: top-level booleans, present only when true. Both are accepted
  by fields= / select_field= and by the CLI -f / -e options.
* threat.blocklists[].first_seen, last_seen (YYYY-MM-DD strings, UTC) and days_seen
  (integer): optional, some feeds omit them.

Dependencies and compatibility
* requests is replaced by httpx[http2]>=0.28,<1. A custom CA bundle must be given via
  SSL_CERT_FILE (httpx does not read REQUESTS_CA_BUNDLE); HTTPS_PROXY / NO_PROXY keep
  working. timeout takes a number, None or an httpx.Timeout (no (connect, read) tuples).
* Python 3.9 to 3.13. Pull requests now run the offline test suite on all five versions.

Verification

Local, python -m pytest -m "not api" with the package installed editable and requests uninstalled (the 4 deselected tests are the live tests marked api, which need an API key):

  • baseline on master: 50 passed, 4 deselected
  • after 7c24765: 79 passed, 4 deselected
  • after 0932516, and again before the CI follow-up push: 87 passed, 4 deselected on Python 3.13.18
  • the same command on Python 3.9.25, 3.10.20, 3.11.18 and 3.12.3 (package not installed, so the ipdata-python/unknown User-Agent fallback is exercised): 87 passed, 4 deselected on each
  • ast.parse(source, feature_version=(3, 9)) on every changed module: OK
  • The repository configures no linter or formatter, so none was run.

A request to api.ipdata.co from an IPData client negotiated HTTP/2 and further requests on the same client reused the connection.

CI, workflow "Test and Publish ipdata to PyPI" (pull_request event added by this PR):

  • Run #116 on 0932516, https://github.com/ipdata/python/actions/runs/41620641859: failure. Test (Python 3.10), 3.11, 3.12 and 3.13 succeeded; Test (Python 3.9) failed in actions/setup-python before any test ran: "The version '3.9' with architecture 'x64' was not found for Ubuntu 26.04" (ubuntu-latest is now the Ubuntu 26.04 image, and the python-versions manifest has no 3.9 build for it). Build and publish to PyPI skipped, as intended on pull requests.
  • Run #117 on b968cc2, https://github.com/ipdata/python/actions/runs/41621069854: success. Test (Python 3.9), 3.10, 3.11, 3.12 and 3.13 all passed (python -m pytest -m "not api"); Build and publish to PyPI skipped on the pull request as intended.

Noticed, not changed

  • bulk() accepts a set of resources in its validation but json= cannot serialise a set, so a set has always ended in IPDataException("Error when looking up ... Object of type set is not JSON serializable"); same with httpx. list(resources) would fix it.
  • DotDict.__getattr__ wraps every list element in DotDict, so dot access to a list of scalars (data.x when x == [1, 2, 3]) raises TypeError; API lists hold objects so this does not surface today.
  • CLI batch --exclude: fields = set(ipdata.valid_fields)... runs before ipdata = IPData(...) is assigned in the same function, so ipdata batch -e <field> raises UnboundLocalError.
  • CLI batch CSV output: the threat sub-field list omits is_vpn (and now the blocklist dates), so threat_is_vpn is dropped from CSV rows.
  • README: the "Batch lookup with output to CSV file" example uses --output-format CSV; the option is --format.
  • test_bulk_v4 / test_bulk_v6 compare pair[0] with itself, so they never check the returned IPs; all four live tests run 100 hypothesis examples each against the live API on every push to master.
  • The publish job pins Python 3.10.4 (a 2022 patch release) and installs build with --user; it now runs on ubuntu-24.04, where that version still exists, but the pin should eventually move to "3.10" or newer. actions/checkout@v4 and actions/setup-python@v5 are Node 20 actions, which GitHub flags as deprecated on its runners.
  • timeout is still passed through the constructor only; there is no per-call timeout, as before.

🤖 Generated with Claude Code

Replace the per-instance requests.Session with one httpx.Client so that
every IPData instance keeps a persistent connection pool (20 connections,
60 s keep-alive) for its whole life and multiplexes requests over HTTP/2
when api.ipdata.co negotiates it, falling back to HTTP/1.1 keep-alive
otherwise. A first request to the API costs a TCP + TLS 1.3 handshake
(roughly 660 to 980 ms); a request on a reused connection costs about a
third of that, so clients should be created once and reused.

- IPData.__init__ keeps its signature and gains http2=True and
  transport=None keyword arguments; the latter injects any
  httpx.BaseTransport (httpx.MockTransport in the new offline tests).
  When no transport is injected httpx builds its default one, which
  honours HTTPS_PROXY/NO_PROXY and SSL_CERT_FILE like requests did and
  applies the pool limits; an explicit HTTPTransport would have ignored
  both, so the connection-level `retries` knob is not used and connect
  failures are retried by the retry loop below instead.
- retry_limit and retry_backoff_factor were dead: the retrying adapter
  was mounted on the prefix "http" which requests never selects. They
  now work as the docstring always described: GET and POST retry on
  500, 502, 503 and 504 and on httpx.TransportError (reset, EOF or
  timeout on a stale pooled connection) after sleeping
  retry_backoff_factor * 2 ** (attempt - 2) seconds, i.e. 0.5, 1, 2, 4,
  8, 16 and 32 s by default. 429 is retried only when the response has
  a Retry-After header (sleep min(Retry-After, 60 s)); otherwise it is
  returned at once so an exhausted quota fails fast.
- bulk() now uses the client timeout; it sent its POST without one.
- Add close(), __enter__ and __exit__ to release the pool.
- The User-Agent becomes ipdata-python/<version> using
  importlib.metadata (fallback "unknown") and requests send
  Accept: application/json.
- geofeeds.py downloads with httpx.get(..., follow_redirects=True), as
  requests followed redirects by default.
- Dependency: requests -> httpx[http2]>=0.28,<1 in setup.cfg and
  requirements.txt. Version 4.0.9 -> 4.1.0.
- CI: the workflow gains a pull_request trigger and a Python 3.9-3.13
  matrix running `python -m pytest -m "not api"`; the existing job
  (full suite with the API key secret, build, tag-gated publish) keeps
  running on pushes to master and tags only.
- Tests: offline tests through httpx.MockTransport cover lookup, bulk
  (timeout present), the retry schedule with a patched sleep, 429
  fast-fail and Retry-After, close/context manager, user-agent and
  api-key parameters, transport reuse across calls and the module-level
  default client. The four live tests stay marked `api`.
- README: new "Connection reuse and HTTP/2" section and offline test
  instructions.

Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The API now returns three additions: the top-level booleans
is_satellite and is_anycast (siblings of is_eu, included only when
true and omitted otherwise) and, on each threat.blocklists entry, the
optional first_seen and last_seen UTC calendar dates (YYYY-MM-DD
strings) and the days_seen integer. Some feeds, such as ipdata's own
vpn feed, omit all three.

The library is untyped and passes the JSON through unchanged, so the
code change is to accept the two new names in IPData.valid_fields,
which validates the `fields` and `select_field` parameters and feeds
the CLI's -f / -e options. The dates stay strings and are not parsed.

Tests built from the specification samples cover a satellite response,
an anycast response with dated blocklist entries, a VPN exit whose
third blocklist entry has no dates, the same shapes inside a bulk
response, the None-when-absent behaviour of DotDict for the two flags,
pass-through of unknown keys and field-name validation for both names.
The README documents the five fields and the present-only-when-true
rule.

Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first run of the new pull_request matrix failed for Python 3.9
before any test ran: ubuntu-latest now resolves to the Ubuntu 26.04
image, and the actions/python-versions manifest has no 3.9 build for
it ("The version '3.9' with architecture 'x64' was not found for
Ubuntu 26.04"). 3.9 builds exist for 22.04 and 24.04 only. Python
3.10 to 3.13 passed on 26.04.

Pin the test matrix to ubuntu-24.04, where all five versions have
builds. The build-n-publish job pins Python 3.10.4, which also has no
26.04 build (only 20.04, 22.04 and 24.04), so it would fail at setup
on the next push to master or tag; pin its runner to ubuntu-24.04 too,
leaving its steps and the tag gate untouched.

Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jonathan-kosgei
jonathan-kosgei merged commit 3887d93 into master Nov 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants