From 21d3543a960b5d91516ad9aedd0d8689553d3d19 Mon Sep 17 00:00:00 2001 From: Brent Date: Fri, 10 Jul 2026 18:11:14 -0400 Subject: [PATCH] SMOODEV-2513: add bounded connect timeout to .NET SmooAI.Fetch (parity with #88) Mirror the Rust connect-timeout port. api-prime stalls (SMOODEV-2481) trace to fresh SYNs black-holing to dead pod IPs still in a ClusterIP's iptables; without a connect timeout the client waits the whole-request timeout before a retry can land on a live endpoint. - SmooFetchOptions.ConnectTimeout (TimeSpan?), default null -> unchanged. - SmooFetchBuilder.WithConnectTimeout, mirrors WithTimeout. - Self-managed client uses SocketsHttpHandler; sets ConnectTimeout only when set. - IHttpClientFactory-managed clients: caveat documented (factory owns handler). Co-Authored-By: Claude Opus 4.8 --- .../SmooAI.Fetch.Tests/ConnectTimeoutTests.cs | 34 +++++++++++++++++++ dotnet/SmooAI.Fetch/SmooFetch.cs | 12 ++++++- dotnet/SmooAI.Fetch/SmooFetchBuilder.cs | 12 +++++++ dotnet/SmooAI.Fetch/SmooFetchOptions.cs | 10 ++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 dotnet/SmooAI.Fetch.Tests/ConnectTimeoutTests.cs diff --git a/dotnet/SmooAI.Fetch.Tests/ConnectTimeoutTests.cs b/dotnet/SmooAI.Fetch.Tests/ConnectTimeoutTests.cs new file mode 100644 index 0000000..06ff7de --- /dev/null +++ b/dotnet/SmooAI.Fetch.Tests/ConnectTimeoutTests.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using SmooAI.Fetch; + +namespace SmooAI.Fetch.Tests; + +/// +/// Mirrors the Rust connect_timeout_tests.rs port: a connect to a non-routable +/// black-hole IP with a short ConnectTimeout must fail in ~that window, not stall until +/// the whole-request Timeout. +/// +public class ConnectTimeoutTests +{ + // RFC 5737 / non-routable black hole — SYNs get dropped, so connect hangs until timeout. + private const string BlackHoleUrl = "http://10.255.255.1:80/"; + + private sealed record Reply(bool Ok); + + [Fact] + public async Task ConnectTimeout_fails_fast_before_whole_timeout() + { + var fetch = SmooFetchBuilder.Create() + .WithNoRetry() + .WithConnectTimeout(TimeSpan.FromMilliseconds(500)) + .WithTimeout(TimeSpan.FromSeconds(5)) + .Build(); + + var sw = Stopwatch.StartNew(); + await Assert.ThrowsAnyAsync(() => fetch.GetAsync(BlackHoleUrl)); + sw.Stop(); + + // ~0.5s connect timeout, not the 5s whole-request timeout. Generous ceiling for CI jitter. + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(3), $"expected fast connect-timeout failure, took {sw.Elapsed}"); + } +} diff --git a/dotnet/SmooAI.Fetch/SmooFetch.cs b/dotnet/SmooAI.Fetch/SmooFetch.cs index bda480e..f687cb6 100644 --- a/dotnet/SmooAI.Fetch/SmooFetch.cs +++ b/dotnet/SmooAI.Fetch/SmooFetch.cs @@ -100,7 +100,14 @@ public static SmooFetch Create(Action? configure = null) "SmooFetchOptions.RequireHttpClientFactory is true — register via AddSmooFetch and resolve from DI instead of Create()."); } - var handler = new HttpClientHandler(); + // SocketsHttpHandler so we can honor SmooFetchOptions.ConnectTimeout when set. + // Left unset (null) → handler default (Infinite), i.e. behavior-identical to before. + var handler = new SocketsHttpHandler(); + if (options.ConnectTimeout is { } connectTimeout) + { + handler.ConnectTimeout = connectTimeout; + } + var client = new HttpClient(handler) { Timeout = System.Threading.Timeout.InfiniteTimeSpan }; return new SmooFetch(client, ownsHttpClient: true, options, logger: null); } @@ -108,6 +115,9 @@ public static SmooFetch Create(Action? configure = null) internal static SmooFetch CreateFromFactory(HttpClient client, SmooFetchOptions options, ILogger logger) { // HttpClientFactory-managed clients must not have their Timeout set (we enforce it per-request via CTS). + // Caveat: SmooFetchOptions.ConnectTimeout is NOT applied here — the factory owns the handler. + // Configure the connect timeout on the SocketsHttpHandler at DI registration + // (e.g. .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { ConnectTimeout = ... })). client.Timeout = System.Threading.Timeout.InfiniteTimeSpan; return new SmooFetch(client, ownsHttpClient: false, options, logger); } diff --git a/dotnet/SmooAI.Fetch/SmooFetchBuilder.cs b/dotnet/SmooAI.Fetch/SmooFetchBuilder.cs index 9270b81..72010e5 100644 --- a/dotnet/SmooAI.Fetch/SmooFetchBuilder.cs +++ b/dotnet/SmooAI.Fetch/SmooFetchBuilder.cs @@ -65,6 +65,17 @@ public SmooFetchBuilder WithTimeout(TimeSpan timeout) return this; } + /// + /// Set a bounded connect timeout (maps to ). + /// Mirrors the Rust with_connect_timeout port. Default-off; only affects + /// self-managed clients (not -owned handlers). + /// + public SmooFetchBuilder WithConnectTimeout(TimeSpan connectTimeout) + { + _options.ConnectTimeout = connectTimeout; + return this; + } + /// Register an async auth-token provider. public SmooFetchBuilder WithAuthTokenProvider(AuthTokenProvider provider, string scheme = "Bearer") { @@ -219,6 +230,7 @@ public SmooFetch Build() o.BaseUrl = _options.BaseUrl; o.RetryPolicy = _options.RetryPolicy; o.Timeout = _options.Timeout; + o.ConnectTimeout = _options.ConnectTimeout; o.AuthTokenProvider = _options.AuthTokenProvider; o.AuthScheme = _options.AuthScheme; o.JsonOptions = _options.JsonOptions; diff --git a/dotnet/SmooAI.Fetch/SmooFetchOptions.cs b/dotnet/SmooAI.Fetch/SmooFetchOptions.cs index 8e3a18b..33bae2b 100644 --- a/dotnet/SmooAI.Fetch/SmooFetchOptions.cs +++ b/dotnet/SmooAI.Fetch/SmooFetchOptions.cs @@ -22,6 +22,16 @@ public sealed class SmooFetchOptions /// Per-request timeout. Defaults to 10 seconds, matching the TS implementation. public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10); + /// + /// Optional bounded timeout for establishing the TCP/TLS connection, mapped to + /// . Mirrors the Rust + /// with_connect_timeout port. Defaults to null (unset) so existing + /// consumers are behaviorally unchanged — a black-holed connect otherwise stalls until + /// the whole-request before a retry can land on a live endpoint. + /// Ignored when is true (the factory owns the handler). + /// + public TimeSpan? ConnectTimeout { get; set; } + /// Optional auth token provider. When set, the returned token is added as Authorization: Bearer {token}. public AuthTokenProvider? AuthTokenProvider { get; set; }