Skip to content

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

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

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

Conversation

@jonathan-kosgei

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

Copy link
Copy Markdown
Collaborator

Summary

Every IPDataClient created without an HttpClient used to build its own HttpClient, so each client paid a fresh TCP + TLS handshake, and the netstandard2.0-only assembly never negotiated HTTP/2. This PR makes the library amortise the cost of HTTPS and adds the response fields the API now returns. Two commits:

1. Reuse HTTPS connections and negotiate HTTP/2

  • IPData.csproj targets netstandard2.0;net8.0; System.Text.Json 4.7.2 is referenced only for netstandard2.0 (in-box on net8.0). VersionPrefix 3.0.1 → 3.1.0.
  • HttpClientAdapter: default adapters share one process-wide HttpClient created lazily by CreateDefaultClient() (Lazy<HttpClient>, never disposed). On .NET 5+ it wraps a SocketsHttpHandler with PooledConnectionLifetime 5 min, PooledConnectionIdleTimeout 60 s, KeepAlivePingDelay 30 s, KeepAlivePingTimeout 15 s, KeepAlivePingPolicy.Always, UseCookies = false; on .NET Standard 2.0 an HttpClientHandler { UseCookies = false }. User-supplied HttpClient / IHttpClient instances stay user-owned. internal HttpClient Client is exposed for tests.
  • IPDataClient: all eleven requests go through a private CreateRequest(HttpMethod, Uri); on .NET 5+ it sets Version = HttpVersion.Version20 and VersionPolicy = RequestVersionOrLower, so HTTP/2 is negotiated through ALPN with automatic HTTP/1.1 fallback. The netstandard2.0 asset never sets Version 2.0 (.NET Framework throws). SendRequestAsync disposes the request and the response. New additive constructor IPDataClient(string apiKey, HttpClient httpClient, Uri baseUrl).
  • JsonSerializer: IgnoreNullValues is obsolete on net8.0 and is replaced there by DefaultIgnoreCondition = WhenWritingNull.
  • Tests (net8.0): request version and policy asserted for GET and bulk POST through the existing Mock<IHttpClient>; a success-path test proves disposal does not break deserialisation; HttpClientAdapterTests cover the shared default client, custom-client pass-through and the handler configuration (the default client's handler is reached through reflection on HttpMessageInvoker._handler, as there is no public accessor; the helper fails with an explicit message if that field is ever renamed); the real-network test is deleted; a RecordingHttpMessageHandler drives a real HttpClient offline.
  • README: new "Connection reuse and HTTP/2" section. CI: dotnet pack … --no-build smoke step so multi-TFM packaging failures surface on PRs.

2. Add is_satellite, is_anycast and dated blocklist entries

  • IPLookupResult.IsSatellite / IsAnycast (bool, [JsonPropertyName("is_satellite")] / ("is_anycast")), false when the key is absent; usable as field selectors.
  • BlocklistInfo.FirstSeen / LastSeen (string, YYYY-MM-DD UTC) and DaysSeen (int?), all optional.
  • Threat.Blocklists setter made public. System.Text.Json never populates collection properties without a public setter, so since the Newtonsoft.Json → System.Text.Json migration the list has always deserialised empty (on master, the API's complete sample response gives Threat.Blocklists.Count == 0), and the new first_seen / last_seen / days_seen fields would have been unreachable. IPLookupResult.Languages has the identical defect but is outside the lines this change touches (see "Noticed, not changed").
  • Fixtures from the three API sample responses; tests for typed values, false-when-absent, optional dates, bulk per-item shape, field-name selection and unknown-key tolerance. README "Response fields" section.

What changes for users

  • Faster repeated lookups, zero configuration. new IPDataClient("API_KEY") now shares one process-wide pooled HttpClient with every other default-constructed client; consecutive lookups reuse the TCP/TLS connection instead of handshaking each time. Keep one client (or one per API key / endpoint) and reuse it for every call.
  • HTTP/2 on .NET 8+. Applications on net8.0 or later get the net8.0 asset: every request asks for HTTP/2 and falls back to HTTP/1.1 automatically. .NET Framework and other .NET Standard 2.0 consumers get the netstandard2.0 asset with HTTP/1.1 keep-alive, now over a shared pool.
  • Idle policy. Default pool on .NET 8+: idle connections closed after 60 s, every connection recycled after 5 min, HTTP/2 keep-alive pings every 30 s. The netstandard2.0 asset keeps the platform defaults.
  • New constructor IPDataClient(string apiKey, HttpClient httpClient, Uri baseUrl) to combine your own HttpClient (for example from IHttpClientFactory) with the EU endpoint.
  • Lifecycle and thread safety. Nothing to dispose: the shared default client lives for the process; an HttpClient / IHttpClient you pass in remains yours to dispose. IPDataClient holds no per-request state and is safe to use concurrently.
  • No library-level retry for POST /bulk. SocketsHttpHandler on .NET 8 discards pooled HTTP/1.1 connections the server has already closed before reusing them, retries HTTP/2 streams the server rejected via GOAWAY or that failed before being sent, and retries an HTTP/1.1 request whose reused connection returned EOF before any response byte only when no request body was sent (request.Content is null, or Expect: 100-continue was still pending). A POST /bulk whose body was already written to a connection that then dies is therefore not retried by the stack and surfaces as an exception; the library adds no retry of its own, because from outside HttpClient it cannot tell "no response byte arrived" apart from "the body read failed".
  • New response fields. IPLookupResult.IsSatellite, IPLookupResult.IsAnycast (false when the API omits the key) and BlocklistInfo.FirstSeen, LastSeen, DaysSeen (null when a feed omits them). Threat.Blocklists is now actually populated from the response.
  • Compatibility. Additive only: no public symbol renamed or removed, every existing constructor and call signature keeps working, exception types unchanged. Threat.Blocklists gains a public setter. The library now disposes each HttpRequestMessage and HttpResponseMessage after reading the body, so a custom IHttpClient must not hold on to the request it receives.

Release notes

v3.1.0 — tag to push after merge: v3.1.0 (the "Publish to NuGet" workflow derives the package version from the tag).

Connection reuse and HTTP/2

  • The package now ships netstandard2.0 and net8.0 assets.
  • IPDataClient instances created without an HttpClient share one process-wide, pooled HttpClient: consecutive lookups reuse the HTTPS connection instead of paying a TCP + TLS handshake per call. Create one client per process (or per API key / endpoint) and reuse it; there is nothing to dispose.
  • On .NET 8+ requests negotiate HTTP/2 (ALPN, automatic HTTP/1.1 fallback); idle connections close after 60 s, connections are recycled after 5 minutes and HTTP/2 keep-alive pings are sent every 30 s. .NET Framework and .NET Standard 2.0 consumers use HTTP/1.1 keep-alive.
  • New constructor IPDataClient(string apiKey, HttpClient httpClient, Uri baseUrl).
  • Requests and responses are disposed once the body has been read.

New response fields

  • IPLookupResult.IsSatellite (is_satellite) and IPLookupResult.IsAnycast (is_anycast): bool, false when the API omits the key; both usable as field selectors (x => x.IsSatellite).
  • BlocklistInfo.FirstSeen (first_seen) and BlocklistInfo.LastSeen (last_seen): YYYY-MM-DD UTC strings; BlocklistInfo.DaysSeen (days_seen): int?. All null when the feed does not report them.

Fixes

  • Threat.Blocklists is populated again; it deserialised as an empty list since the migration to System.Text.Json.

Verification

All commands were run from the repository root with .NET SDK 8.0.133 before each push:

$ dotnet build IPData.sln -c Release
Build succeeded.  0 Error(s), 2 Warning(s)
  NU5048 (PackageIconUrl deprecated) – pre-existing
  SYSLIB0051 (ApiException's SerializationInfo constructor) – pre-existing code, now also compiled for net8.0
  Successfully created package 'src/IPData/bin/Release/IPData.3.1.0.nupkg'   (GeneratePackageOnBuild)
$ dotnet test IPData.sln -c Release --no-build
Passed! - Failed: 0, Passed: 121, Skipped: 0, Total: 121   (master: 100)
$ dotnet pack src/IPData/IPData.csproj -c Release --no-build
exit 0 – an incremental no-op straight after the build (the package is already up to date);
         with the .nupkg deleted first it prints "Successfully created package '…/IPData.3.1.0.nupkg'"
$ unzip -l src/IPData/bin/Release/IPData.3.1.0.nupkg
lib/net8.0/IPData.dll  lib/net8.0/IPData.pdb  lib/netstandard2.0/IPData.dll  lib/netstandard2.0/IPData.pdb
  nuspec dependency groups: net8.0 – none; .NETStandard2.0 – System.Text.Json 4.7.2

Test counts: 100 on master → 112 after commit 1 (+5 adapter tests, +7 client tests, the real-network test deleted) → 121 after commit 2 (+6 serializer tests, +3 field-selection tests).

Two requests from an IPDataClient to api.ipdata.co were served over HTTP/2 (response.Version=2.0) and the second reused the connection.

CI: the CI workflow (restore, build Release, test, pack) already has a pull_request trigger for master. Run #16 on 9d45708 (pull_request, job build-and-test): success — Restore, Build, Test (121 passed) and the new Pack step all passed. Because GeneratePackageOnBuild is on, the Build step already packs and the Pack step is an incremental no-op on this run; it still fails the job if packaging ever breaks or that property is removed.

Noticed, not changed

  • IPLookupResult.Languages has the same private-setter defect that Threat.Blocklists had: System.Text.Json never populates it, so it is always empty. One-line fix ({ get; set; }), left out because it is outside this change's scope.
  • Threat has no properties for is_vpn and scores (vpn_score, proxy_score, threat_score, trust_score), which the API now returns (both appear in its sample responses); they are silently ignored.
  • The library sends no User-Agent header and no Accept: application/json header; the request path sets no headers at all.
  • The test project targets net8.0 only, so the netstandard2.0 branches (HttpClientHandler, IgnoreNullValues) are build-verified but have no runnable tests.
  • src/IPData/IPData.nuspec is stale and unused by dotnet pack (SDK-style project): it lists only a .NETStandard2.0 dependency group and different authors.
  • PackageIconUrl is deprecated (NU5048); PackageIcon with an embedded icon would silence it. ApplicationIcon / Win32Resource are set on a class library.
  • ApiException and its subclasses keep [Serializable] and SerializationInfo constructors, which produce SYSLIB0051 obsolete warnings on net8.0.
  • IPData.sln lists .github/CODE_OF_CONDUCT.md and .github/PULL_REQUEST_TEMPLATE.md as solution items, but neither file exists.
  • Test dependencies are old (Microsoft.NET.Test.Sdk 16.4.0, xunit 2.4.1, FluentAssertions 5.10.0, coverlet.msbuild 2.8.0, Moq 4.7.0 via AutoFixture.AutoMoq 4.11.0); they work on net8.0 and were left alone.
  • .editorconfig asks for CRLF in *.cs, but about half of the files are LF; this PR keeps each file's existing line endings.

🤖 Generated with Claude Code

Every IPDataClient created without an HttpClient used to build its own
HttpClient, so each client paid a fresh TCP and TLS handshake, and the
netstandard2.0-only assembly never negotiated HTTP/2.

* Target netstandard2.0 and net8.0. System.Text.Json is referenced only
  for netstandard2.0; it ships in the shared framework on net8.0.
* HttpClientAdapter: default adapters share one process-wide HttpClient
  built lazily by CreateDefaultClient(). On .NET 5+ it wraps a
  SocketsHttpHandler with a 5 minute connection lifetime, a 60 s idle
  timeout, HTTP/2 keep-alive pings every 30 s (15 s timeout, policy
  Always) and cookies disabled; on .NET Standard 2.0 an HttpClientHandler
  with cookies disabled. The shared client is never disposed (process
  lifetime). User-supplied HttpClient/IHttpClient instances stay
  user-owned. The wrapped client is exposed internally for tests.
* IPDataClient: all eleven requests are built by CreateRequest(), which
  on .NET 5+ asks for HTTP/2 with HttpVersionPolicy.RequestVersionOrLower
  so the handler falls back to HTTP/1.1 when h2 is not negotiated. The
  netstandard2.0 asset never sets Version 2.0 (.NET Framework throws).
  SendRequestAsync disposes the request and the response. New additive
  constructor IPDataClient(string, HttpClient, Uri) combines a
  caller-owned HttpClient with a custom base URL.
* JsonSerializer: IgnoreNullValues is obsolete on net8.0, so that build
  uses DefaultIgnoreCondition = WhenWritingNull instead.
* Tests: request version and policy are asserted for GET and bulk POST
  through the existing Mock<IHttpClient>; a success-path test proves
  disposal does not break deserialisation; HttpClientAdapterTests cover
  the shared default client, pass-through of a custom client and the
  default handler configuration; the real-network test is removed. A
  RecordingHttpMessageHandler drives a real HttpClient offline.
* README: new "Connection reuse and HTTP/2" section. CI: pack step so
  multi-TFM packaging failures surface on pull requests.
* VersionPrefix 3.0.1 -> 3.1.0 (publish derives the version from the tag).

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 optional
fields on every threat.blocklists entry.

* IPLookupResult gains bool IsSatellite ("is_satellite") and bool
  IsAnycast ("is_anycast") next to IsEu. The API includes each key only
  when it is true, so both read false when the key is absent. Because
  they carry [JsonPropertyName], x => x.IsSatellite / x => x.IsAnycast
  work as field selectors for single-field and fields= lookups.
* BlocklistInfo gains string FirstSeen ("first_seen"), string LastSeen
  ("last_seen", both YYYY-MM-DD UTC, kept as strings) and int? DaysSeen
  ("days_seen"). Some feeds omit all three, so they are nullable.
* Threat.Blocklists now has a public setter: System.Text.Json does not
  populate collection properties with a non-public setter, so since the
  move from Newtonsoft.Json the list always deserialised empty and the
  new fields would have been unreachable.
* System.Text.Json already ignores unknown members (is_vpn, scores and
  future keys); a test pins that.
* Tests: fixtures built from the three spec samples (satellite ISP,
  anycast address with dated blocklist entries, VPN exit with an undated
  entry) assert the typed values, the false-when-absent behaviour, the
  per-item shape of bulk responses and that the two new names are
  accepted as selectable fields.
* README: "Response fields" section documenting the five names.

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