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; }