Skip to content

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

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

jonathan-kosgei merged 2 commits into
mainfrom
http-reuse-and-new-fields

Conversation

@jonathan-kosgei

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

Copy link
Copy Markdown
Contributor

Summary

Two logical commits:

  1. 018b4df Reuse HTTPS connections and retry lost-connection bulk lookups.
  2. eba3de3 Add is_satellite, is_anycast and blocklist date fields.

IPDataClient already sent every request through a single URLSession (URLSession.shared by default), so connection pooling and HTTP/2 were structurally in place on Apple platforms and on Linux (swift-corelibs-foundation on libcurl with CURLPIPE_MULTIPLEX). This PR makes that behaviour explicit and complete:

  • New public convenience init(apiKey:baseURL:configuration: URLSessionConfiguration) creates an owned URLSession once and calls finishTasksAndInvalidate() on it in deinit. The existing init(apiKey:baseURL:session:) keeps URLSession.shared as its default (a dedicated URLSession per client would regress callers that instantiate a client per call) and never invalidates an injected URLSession. Both public initialisers now delegate to one private designated initialiser; IPDataClient is final, so turning the public designated initialiser into a convenience one is source compatible.
  • Accept: application/json is set next to the existing api-key header on every request (GET lookups, raw single-field lookups, bulk POST).
  • POST /bulk is retried exactly once when the transport fails with URLError.networkConnectionLost. Nothing else is retried, HTTP statuses are never retried, and the retry goes through the same URLSession (so a fresh pooled connection).
  • The Linux URLSession.data(for:) shim is unchanged.
  • Models: IPResponse.isSatellite and IPResponse.isAnycast (Bool, decoded with decodeIfPresent(Bool.self, ...) ?? false in the existing custom init(from:)), Blocklist.firstSeen / Blocklist.lastSeen (String?, kept as the YYYY-MM-DD UTC strings) and Blocklist.daysSeen (Int?). JSONDecoder ignores unknown keys by default and the client configures nothing else; a test pins that.
  • Tests (all through the existing MockURLProtocol seam; none calls the live API): six lookups of every kind on one client all reach the mock protocol registered only on the injected URLSession; the configuration: initialiser routes requests through its configuration, honours baseURL, and the client deallocates (running deinit, which invalidates the owned URLSession); Accept: application/json is asserted on GET, raw-field and bulk requests; the bulk retry happens once and only once, not for other network errors, not on an HTTP status, and lookup is not retried by the client; the three API sample responses decode (satellite, anycast with dated blocklists, blocklist entry without dates), the new flags read false unless sent as true, bulk responses carry the new fields, unknown keys are ignored, and fields= passes the new names through unchanged (the client validates no field names).
  • README: "Connection reuse and HTTP/2" section after Quick Start; the five new names documented (IPResponse table, new Blocklist table, usage examples); the install snippet points at 1.0.2, the release that ships these changes.
  • CI: no change needed, the CI workflow already runs on pull_request to main.

Overlap with open PR #3. On main the macOS CI job fails because testBulkLookupSendsPostWithIPsInBody force-unwraps request.httpBody, which Apple platforms hand to a URLProtocol as httpBodyStream (every CI run on this repository so far is red for that reason on macOS). PR #3 fixes the same test; its CI run has macOS green but the Ubuntu 5.10 job failing (5.9 cancelled by fail-fast): its fallback branch constructs HTTPURLResponse(), and HTTPURLResponse has no accessible parameterless initialiser in swift-corelibs-foundation. This PR has to touch that test anyway (to assert the Accept header), so it reads the body through a small file-private helper (httpBody, else httpBodyStream) that compiles on both platforms and throws instead of fabricating a placeholder response. If PR #3 merges first, the conflict is confined to those lines and either version can be kept.

What changes for users

  • Nothing has to change. IPDataClient(apiKey:) still uses URLSession.shared; every existing initialiser, method and error type keeps its signature. Changes are additive only.
  • New: IPDataClient(apiKey:baseURL:configuration:) for a client that owns a dedicated URLSession (custom timeouts, proxies, isolation from the shared pool). It is invalidated automatically when the client is deallocated; there is nothing to close by hand, and URLSession.shared is never invalidated.
  • Idle policy: URLSession exposes no pool idle timeout, HTTP/2 keepalive ping or TCP keepalive setting (timeoutIntervalForRequest is per request), so the platform defaults apply.
  • Every request now carries Accept: application/json.
  • bulkLookup transparently resends its POST once if a pooled connection was closed by the server before any response arrived (URLError.networkConnectionLost). The retry keys on that error alone, because URLSession.data(for:) cannot tell whether response bytes had already arrived. Other errors, and HTTP statuses, surface exactly as before.
  • New model properties: IPResponse.isSatellite, IPResponse.isAnycast (Bool, false when the API omits the key), Blocklist.firstSeen, Blocklist.lastSeen (String?), Blocklist.daysSeen (Int?). Neither type has a public memberwise initialiser (values come from decoding), so adding stored properties breaks no caller. Encoding an IPResponse now always writes is_satellite and is_anycast.
  • The README explains how to reuse one client, that HTTP/2 and keep-alive are automatic, and how to pass a custom URLSession or configuration.

Release notes

Version 1.0.2. After merging, tag the merge commit v1.0.2 and publish a GitHub release with:

## 1.0.2

### Connection reuse and HTTP/2
- New `IPDataClient(apiKey:baseURL:configuration:)` creates a dedicated `URLSession` once, keeps its connections alive for the client's lifetime and invalidates it (`finishTasksAndInvalidate()`) when the client is deallocated. `IPDataClient(apiKey:baseURL:session:)` is unchanged and still defaults to `URLSession.shared`, whose pool is shared by the whole process.
- HTTP/2 and keep-alive are automatic through `URLSession` on Apple platforms and on Linux (libcurl). The README's new "Connection reuse and HTTP/2" section explains how to reuse one client.
- Every request now sends `Accept: application/json`.
- `bulkLookup` retries its `POST /bulk` exactly once when the transport fails with `URLError.networkConnectionLost` (an idle pooled connection closed by the server). No other request is retried; HTTP statuses are never retried.

### New response fields
- `IPResponse.isSatellite` and `IPResponse.isAnycast` (`Bool`). The API sends `is_satellite` / `is_anycast` only when true; both read `false` when the key is absent.
- `Blocklist.firstSeen`, `Blocklist.lastSeen` (`String?`, `YYYY-MM-DD` UTC) and `Blocklist.daysSeen` (`Int?`). Optional, because some feeds (for example ipdata's VPN feed) omit them.

### Fixes
- The bulk lookup test no longer crashes on macOS: the request body is read from `httpBodyStream` when `httpBody` is nil.

Verification

  • CI, workflow CI, run 41621101180 on eba3de3 (event pull_request): Swift 5.9 on Ubuntu success, Swift 5.10 on Ubuntu success, Swift on macOS success. On every job swift test executed 40 tests with 0 failures (25 in IPDataClientTests, 15 in IPDataTests). This is the first fully green run in the repository (all five earlier runs were red on at least one job). No follow-up commit was needed.
  • A curl request to api.ipdata.co negotiated HTTP/2 and a second request in the same invocation reused the connection.

Noticed, not changed

  • No User-Agent header is added: a Swift package carries no version metadata to derive ipdata-swift/<version> from, and a hard-coded value would go stale.
  • bulkLookup contains dead code: if !url.absoluteString.contains("api-key") can never be true because buildURL always adds the api-key query item.
  • README IPResponse table still lists timeZone as TimeZone? although the type was renamed TimeZoneInfo in Rename TimeZone to TimeZoneInfo to fix macOS CI failure #2, and the table has no rows for regionType and emojiUnicode.
  • The CI matrix (Swift 5.9 and 5.10 containers, macos-14) was not modernised, and the swift-tools-version stays 5.9.
  • MockURLProtocol uses mutable statics (requestHandler, and now requestCount), fine in the Swift 5 language mode the package uses; they would need isolation under Swift 6 strict concurrency.
  • The Linux URLSession.data(for:) shim would become ambiguous if a future swift-corelibs-foundation ships the same async method; the 5.9 and 5.10 images used by CI compile it today.
  • Every CI run on this repository before this PR was red (macOS crash described above); the open PR Fix macOS test crash: read httpBodyStream when httpBody is nil #3 addresses the same crash with a change that does not compile on Linux.

🤖 Generated with Claude Code

IPDataClient already sends every request through one URLSession (the
process-wide URLSession.shared by default), so connections are pooled and
HTTP/2 is negotiated natively on Apple platforms and on Linux (libcurl).
This commit makes that behaviour explicit and complete:

- Add `init(apiKey:baseURL:configuration:)`, which creates a dedicated
  URLSession once from the given configuration, owns it for the lifetime of
  the client and calls `finishTasksAndInvalidate()` in `deinit`. The existing
  `init(apiKey:baseURL:session:)` keeps `.shared` as its default and never
  invalidates an injected session; both public initialisers now delegate to
  one private designated initialiser.
- Send `Accept: application/json` next to the existing `api-key` header on
  every request.
- Retry `POST /bulk` exactly once when the transport fails with
  `URLError.networkConnectionLost`, the error a pooled connection produces
  when the server closed it while idle. Nothing else is retried, and HTTP
  statuses are never retried.
- Keep the Linux `URLSession.data(for:)` shim unchanged.

Tests: consecutive lookups of every kind arrive through the single injected
mock session (handler invocations counted); the owned-session initialiser
routes requests through its configuration, honours `baseURL` and lets the
client deallocate; the Accept header is asserted on GET, raw-field and bulk
requests; the bulk retry happens once and only for `networkConnectionLost`,
never for other errors or HTTP statuses, and plain lookups are not retried
by the client. The bulk test now reads the request body from
`httpBodyStream` when `httpBody` is nil, which is how Apple platforms hand
POST bodies to a URLProtocol; this fixes the macOS test crash on main that
PR #3 also addresses, in a form that compiles on Linux too.

README: new "Connection reuse and HTTP/2" section after Quick Start, and the
install snippet points at 1.0.2, the release that ships these changes.

Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The API now returns two more top-level booleans and three more keys per
blocklist entry:

- `IPResponse.isSatellite` and `IPResponse.isAnycast` (`is_satellite`,
  `is_anycast`), siblings of `isEU`. The API includes each key only when it
  is true, so they are plain `Bool`s decoded with
  `decodeIfPresent(Bool.self, ...) ?? false` in the existing custom
  `init(from:)`: absent or null reads false.
- `Blocklist.firstSeen`, `Blocklist.lastSeen` (`first_seen`, `last_seen`,
  `YYYY-MM-DD` UTC strings) and `Blocklist.daysSeen` (`days_seen`, Int).
  Some feeds, for example ipdata's own vpn feed, omit all three, so they are
  optional and decode to nil when absent. The dates stay strings: no date
  parsing, no new dependencies.

`JSONDecoder` ignores unknown keys by default and the client configures
nothing else, so responses with further new keys keep decoding; a test now
pins that. The client does not validate `fields=` names, so the new names
pass through unchanged (covered by a client test).

Tests: fixtures built from the three sample responses (satellite ISP;
anycast address with dated blocklist entries; VPN exit whose third blocklist
entry has no dates), the same shape in a bulk response, false-when-absent on
the existing fixtures, explicit false/true/null values, and unknown keys.

README: the two flags join the IPResponse table, a Blocklist table documents
the entry fields and which feeds omit the dates, and the usage examples show
both.

Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jonathan-kosgei
jonathan-kosgei merged commit 20e199f into main Nov 3, 2026
3 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