From 374b86a1515b786461a5f23cbec2245459526f9c Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 18:15:16 +0100 Subject: [PATCH 1/2] Add plan 14 for HttpClient connection lifetime (fixes #8 planning) Document the DNS-staleness issue affecting the self-owned HttpClient in WLedClient(string) and a SocketsHttpHandler PooledConnectionLifetime fix guarded for the netstandard2.0 target. --- plans/14-httpclient-connection-lifetime.md | 111 +++++++++++++++++++++ plans/README.md | 1 + 2 files changed, 112 insertions(+) create mode 100644 plans/14-httpclient-connection-lifetime.md diff --git a/plans/14-httpclient-connection-lifetime.md b/plans/14-httpclient-connection-lifetime.md new file mode 100644 index 0000000..95a6636 --- /dev/null +++ b/plans/14-httpclient-connection-lifetime.md @@ -0,0 +1,111 @@ +# Plan 14 — HttpClient connection lifetime & DNS staleness + +**Theme:** Correctness · Reliability · Cross-cutting + +Tracks [issue #8](https://github.com/kevbite/WLED.NET/issues/8). + +## Why + +A long-lived `HttpClient` over a default `HttpClientHandler` keeps its TCP connections +open indefinitely and therefore **never honours DNS changes** — a documented .NET pitfall +(see [HttpClient guidelines: DNS behavior](https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines#dns-behavior)). + +WLED devices are a realistic trigger for this: + +- they are frequently addressed by mDNS/hostname or a DHCP-assigned IP that can change + (device reboot, lease renewal, network change), and +- a typical consumer creates **one** `WLedClient("http://wled.local/")` for the lifetime of + the app and reuses it. + +The DI path (`services.AddWledClient(...)`) is already safe: it goes through +`IHttpClientFactory`, which rotates handlers on a default 2-minute lifetime. The problem is +isolated to the convenience constructors that own a self-created `HttpClient`: + +- `WLedClient(string baseUri)` → `new HttpClientHandler()` (see `WLedClient.cs`). +- `WLedClient(HttpMessageHandler, string)` → caller-supplied handler (out of scope; the + caller owns its lifetime). + +## Goal + +When the library creates the handler itself, give pooled connections a finite lifetime so +DNS is periodically refreshed, **without** regressing throughput or the netstandard2.0 +target. + +## Approach + +In `WLedClient`, replace the default `new HttpClientHandler()` used by +`WLedClient(string baseUri)` with a `SocketsHttpHandler` configured with a bounded +`PooledConnectionLifetime`, on runtimes where `SocketsHttpHandler` exists. + +`SocketsHttpHandler` is available on .NET Core 2.1+/.NET 5+ but **not** in the +netstandard2.0 reference assemblies, so this must be guarded by a target-framework +conditional rather than a runtime check: + +```csharp +private static HttpMessageHandler CreateDefaultHandler() +{ +#if NETSTANDARD2_0 + // .NET Framework / legacy: SocketsHttpHandler is unavailable. Fall back to the + // platform handler; consumers on these runtimes should prefer IHttpClientFactory. + return new HttpClientHandler(); +#else + return new SocketsHttpHandler + { + // Refresh pooled connections (and therefore DNS) periodically. + PooledConnectionLifetime = TimeSpan.FromMinutes(2) + }; +#endif +} +``` + +Wire it into the existing constructor: + +```csharp +public WLedClient(string baseUri) : this(CreateDefaultHandler(), baseUri) +{ +} +``` + +Notes / decisions: + +- **Two minutes** mirrors `IHttpClientFactory`'s default handler lifetime, keeping the two + paths consistent. It is a reasonable default for LAN devices and can be revisited if a + configurable overload is later requested. +- Keep the existing `Connection: keep-alive` default request header; `PooledConnectionLifetime` + bounds connection reuse without disabling keep-alive. +- **Do not** change the `WLedClient(HttpMessageHandler, string)` or `WLedClient(HttpClient)` + constructors — those hand ownership of the handler/client to the caller (including DI), + and overriding their lifetime would be surprising. +- netstandard2.0 constraint: `SocketsHttpHandler` is not referenced under that TFM, so the + `#if` keeps the netstandard2.0 build green (it currently builds clean and must stay so). + +## Tests + +- A test asserting `new WLedClient("http://wled.local/")` constructs successfully and can + issue a request (the existing `MockHttpMessageHandler` path already covers request + behaviour; this guards the default-handler wiring on net8/9/10). +- Because handler internals aren't observable through `HttpClient`, prefer a small, + internal, testable seam if exact `PooledConnectionLifetime` verification is wanted: + e.g. an `internal static HttpMessageHandler CreateDefaultHandler()` exposed to the test + project via `InternalsVisibleTo`, asserting it returns a `SocketsHttpHandler` with the + expected lifetime on modern TFMs. Keep this minimal and conditional-compiled. +- Confirm the full multi-target build (incl. netstandard2.0) and the existing 205 tests + remain green. + +## Definition of Done (per `plans/README.md`) + +1. **README.md** — add a short note under connecting/DI guidance explaining that the + convenience constructor now refreshes DNS via a bounded connection lifetime, and that + `IHttpClientFactory`/DI remains the recommended approach for apps. +2. **samples/BasicConsole** — no behavioural change required; the existing + `new WLedClient(...)` usage now benefits automatically. Update a comment if helpful. +3. **CHANGELOG.md** — record the fix under the unreleased section + (e.g. *"The `WLedClient(string)` constructor now uses a `SocketsHttpHandler` with a + bounded `PooledConnectionLifetime` so DNS changes are picked up (fixes #8)."*). + +## Out of scope + +- Making the connection lifetime configurable via a new constructor/options overload — can + be a fast follow if requested. +- Any change to the DI package, which already delegates lifetime management to + `IHttpClientFactory`. diff --git a/plans/README.md b/plans/README.md index a847a8d..d9617a9 100644 --- a/plans/README.md +++ b/plans/README.md @@ -66,6 +66,7 @@ These plans cross-reference two mature community libraries (linked by the WLED d | 11 | [Client ergonomics & cross-cutting concerns](11-client-ergonomics-and-cross-cutting.md) | Quality | | 12 | [Usability & correctness improvements (post-review)](12-usability-and-correctness-improvements.md) | Correctness | | 13 | [Device ergonomics, catalogs & agent guidance](13-device-ergonomics-and-agent-guidance.md) | Usability | +| 14 | [HttpClient connection lifetime & DNS staleness](14-httpclient-connection-lifetime.md) | Correctness | Plan 0 modernises the toolchain (multi-targeting `netstandard2.0;net8.0;net9.0;net10.0`) and should land first. Plans 1–2 are the ergonomic foundation and unblock everything else. Plans 3–10 add From 0527b8db7f2f0152ebbbe3a76fd9705289bbb1bb Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 18:17:20 +0100 Subject: [PATCH 2/2] Refresh DNS in WLedClient(string) via SocketsHttpHandler Use a SocketsHttpHandler with a 2-minute PooledConnectionLifetime for the self-owned HttpClient on modern runtimes so a long-lived client picks up DNS/IP changes, falling back to HttpClientHandler on netstandard2.0. Expose CreateDefaultHandler internally for testing and document the behaviour (fixes #8). --- CHANGELOG.md | 5 ++++ README.md | 5 ++++ src/Kevsoft.WLED/Kevsoft.WLED.csproj | 4 +++ src/Kevsoft.WLED/WLedClient.cs | 21 +++++++++++++++- .../WLedClientConstructionTests.cs | 25 +++++++++++++++++++ 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 test/Kevsoft.WLED.Tests/WLedClientConstructionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b004ba9..448bdc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,11 @@ rather than deprecated, so consuming code must be updated. - **`ColorTemperature.Kelvin` range widened to `1000–20000 K`** to match the docs' forward- compatible guidance, with a new `ColorTemperature.KelvinUnchecked(int)` escape hatch for values outside that range. +- **`WLedClient(string)` now refreshes DNS.** The convenience constructor's self-owned + `HttpClient` uses a `SocketsHttpHandler` with a bounded `PooledConnectionLifetime` (2 minutes) + on modern runtimes, so a long-lived client picks up DNS/IP changes instead of pinning a stale + connection (fixes [#8](https://github.com/kevbite/WLED.NET/issues/8)). The `netstandard2.0` + build falls back to `HttpClientHandler`; use `IHttpClientFactory`/DI there. ### Added diff --git a/README.md b/README.md index eec2ee2..344a33b 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,11 @@ services.AddWledClient("http://office-computer-wled/"); services.AddWledClient(client => client.BaseAddress = new Uri("http://office-computer-wled/")); ``` +The `WLedClient(string)` constructor owns its `HttpClient` and uses a `SocketsHttpHandler` +with a bounded `PooledConnectionLifetime`, so a long-lived client still picks up DNS changes +(e.g. a WLED device that gets a new IP). For applications, registering via +`IHttpClientFactory`/DI as above remains the recommended approach. + ### Quick commands Common operations have first-class "intent" methods: diff --git a/src/Kevsoft.WLED/Kevsoft.WLED.csproj b/src/Kevsoft.WLED/Kevsoft.WLED.csproj index d653112..8d7edcf 100644 --- a/src/Kevsoft.WLED/Kevsoft.WLED.csproj +++ b/src/Kevsoft.WLED/Kevsoft.WLED.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 9dc51f7..eb406ee 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -9,7 +9,7 @@ public WLedClient(HttpMessageHandler httpMessageHandler, string baseUri) { } - public WLedClient(string baseUri) : this(new HttpClientHandler(), baseUri) + public WLedClient(string baseUri) : this(CreateDefaultHandler(), baseUri) { } @@ -38,6 +38,25 @@ private static HttpClient CreateClient(HttpMessageHandler httpMessageHandler, st return client; } + /// + /// Creates the default used when the client owns its own + /// . On modern runtimes this is a SocketsHttpHandler with a bounded + /// PooledConnectionLifetime so pooled connections — and therefore DNS — are refreshed + /// periodically. See + /// https://learn.microsoft.com/dotnet/fundamentals/networking/http/httpclient-guidelines#dns-behavior. + /// + internal static HttpMessageHandler CreateDefaultHandler() + { +#if NETSTANDARD2_0 + return new HttpClientHandler(); +#else + return new SocketsHttpHandler + { + PooledConnectionLifetime = TimeSpan.FromMinutes(2) + }; +#endif + } + public Task Get(CancellationToken cancellationToken = default) => GetJson("json", cancellationToken); diff --git a/test/Kevsoft.WLED.Tests/WLedClientConstructionTests.cs b/test/Kevsoft.WLED.Tests/WLedClientConstructionTests.cs new file mode 100644 index 0000000..7654e59 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/WLedClientConstructionTests.cs @@ -0,0 +1,25 @@ +using System.Net.Http; + +namespace Kevsoft.WLED.Tests; + +public class WLedClientConstructionTests +{ + [Fact] + public void StringConstructorSucceeds() + { + var act = () => new WLedClient("http://wled.local/"); + + act.Should().NotThrow(); + } + +#if !NETSTANDARD2_0 + [Fact] + public void DefaultHandlerRefreshesPooledConnectionsToAvoidStaleDns() + { + var handler = WLedClient.CreateDefaultHandler(); + + handler.Should().BeOfType() + .Which.PooledConnectionLifetime.Should().Be(TimeSpan.FromMinutes(2)); + } +#endif +}