Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions dotnet/SmooAI.Fetch.Tests/ConnectTimeoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Diagnostics;
using SmooAI.Fetch;

namespace SmooAI.Fetch.Tests;

/// <summary>
/// Mirrors the Rust <c>connect_timeout_tests.rs</c> 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.
/// </summary>
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<Exception>(() => fetch.GetAsync<Reply>(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}");
}
}
12 changes: 11 additions & 1 deletion dotnet/SmooAI.Fetch/SmooFetch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,24 @@ public static SmooFetch Create(Action<SmooFetchOptions>? 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);
}

internal static SmooFetch CreateFromFactory(HttpClient client, SmooFetchOptions options, ILogger<SmooFetch> 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);
}
Expand Down
12 changes: 12 additions & 0 deletions dotnet/SmooAI.Fetch/SmooFetchBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@ public SmooFetchBuilder WithTimeout(TimeSpan timeout)
return this;
}

/// <summary>
/// Set a bounded connect timeout (maps to <see cref="System.Net.Http.SocketsHttpHandler.ConnectTimeout"/>).
/// Mirrors the Rust <c>with_connect_timeout</c> port. Default-off; only affects
/// self-managed clients (not <see cref="IHttpClientFactory"/>-owned handlers).
/// </summary>
public SmooFetchBuilder WithConnectTimeout(TimeSpan connectTimeout)
{
_options.ConnectTimeout = connectTimeout;
return this;
}

/// <summary>Register an async auth-token provider.</summary>
public SmooFetchBuilder WithAuthTokenProvider(AuthTokenProvider provider, string scheme = "Bearer")
{
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions dotnet/SmooAI.Fetch/SmooFetchOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ public sealed class SmooFetchOptions
/// <summary>Per-request timeout. Defaults to 10 seconds, matching the TS implementation.</summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(10);

/// <summary>
/// Optional bounded timeout for establishing the TCP/TLS connection, mapped to
/// <see cref="System.Net.Http.SocketsHttpHandler.ConnectTimeout"/>. Mirrors the Rust
/// <c>with_connect_timeout</c> port. Defaults to <c>null</c> (unset) so existing
/// consumers are behaviorally unchanged — a black-holed connect otherwise stalls until
/// the whole-request <see cref="Timeout"/> before a retry can land on a live endpoint.
/// Ignored when <see cref="RequireHttpClientFactory"/> is true (the factory owns the handler).
/// </summary>
public TimeSpan? ConnectTimeout { get; set; }

/// <summary>Optional auth token provider. When set, the returned token is added as <c>Authorization: Bearer {token}</c>.</summary>
public AuthTokenProvider? AuthTokenProvider { get; set; }

Expand Down
Loading