diff --git a/src/FluentHttpClient.Tests/FluentTimeoutExtensionsTests.cs b/src/FluentHttpClient.Tests/FluentTimeoutExtensionsTests.cs new file mode 100644 index 0000000..c2c5ce4 --- /dev/null +++ b/src/FluentHttpClient.Tests/FluentTimeoutExtensionsTests.cs @@ -0,0 +1,136 @@ +namespace FluentHttpClient.Tests; + +public class FluentTimeoutExtensionsTests +{ + private static HttpRequestBuilder CreateBuilder() + { + return new HttpRequestBuilder(new HttpClient(), "https://example.com"); + } + + public class ClearTimeout + { + [Fact] + public void ClearTimeout_SetsTimeoutToNull() + { + var builder = CreateBuilder() + .WithTimeout(60); + + builder.Timeout.ShouldNotBeNull(); + builder.Timeout.ShouldBe(TimeSpan.FromSeconds(60)); + + builder.ClearTimeout(); + + builder.Timeout.ShouldBeNull(); + } + } + + public class WithTimeout_Int + { + [Fact] + public void WithTimeout_SetsTimeoutFromSeconds_WhenValidSecondsProvided() + { + var builder = CreateBuilder() + .WithTimeout(30); + + builder.Timeout.ShouldNotBeNull(); + builder.Timeout.ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void WithTimeout_ThrowsArgumentOutOfRangeException_WhenSecondsIsZero() + { + var builder = CreateBuilder(); + + var exception = Should.Throw(() => builder.WithTimeout(0)); + + exception.ParamName.ShouldBe("seconds"); + exception.Message.ShouldContain(FluentTimeoutExtensions.MessageInvalidTimeout); + } + + [Fact] + public void WithTimeout_ThrowsArgumentOutOfRangeException_WhenSecondsIsNegative() + { + var builder = CreateBuilder(); + + var exception = Should.Throw(() => builder.WithTimeout(-1)); + + exception.ParamName.ShouldBe("seconds"); + exception.Message.ShouldContain(FluentTimeoutExtensions.MessageInvalidTimeout); + } + + [Fact] + public void WithTimeout_OverwritesPreviousTimeout_WhenCalledMultipleTimes() + { + var builder = CreateBuilder(); + + builder.WithTimeout(10); + builder.Timeout.ShouldBe(TimeSpan.FromSeconds(10)); + + builder.WithTimeout(20); + builder.Timeout.ShouldBe(TimeSpan.FromSeconds(20)); + } + } + + public class WithTimeout_TimeSpan + { + [Fact] + public void WithTimeout_SetsTimeoutFromTimeSpan_WhenValidTimeSpanProvided() + { + var builder = CreateBuilder(); + var timeout = TimeSpan.FromMinutes(2); + + builder.WithTimeout(timeout); + + builder.Timeout.ShouldNotBeNull(); + builder.Timeout.ShouldBe(timeout); + } + + [Fact] + public void WithTimeout_ThrowsArgumentOutOfRangeException_WhenTimeSpanIsZero() + { + var builder = CreateBuilder(); + + var exception = Should.Throw( + () => builder.WithTimeout(TimeSpan.Zero)); + + exception.ParamName.ShouldBe("timeout"); + exception.Message.ShouldContain(FluentTimeoutExtensions.MessageInvalidTimeout); + } + + [Fact] + public void WithTimeout_ThrowsArgumentOutOfRangeException_WhenTimeSpanIsNegative() + { + var builder = CreateBuilder(); + + var exception = Should.Throw( + () => builder.WithTimeout(TimeSpan.FromSeconds(-10))); + + exception.ParamName.ShouldBe("timeout"); + exception.Message.ShouldContain(FluentTimeoutExtensions.MessageInvalidTimeout); + } + + [Fact] + public void WithTimeout_OverwritesPreviousTimeout_WhenCalledMultipleTimesWithTimeSpan() + { + var builder = CreateBuilder(); + + builder.WithTimeout(TimeSpan.FromSeconds(10)); + builder.WithTimeout(TimeSpan.FromMinutes(5)); + + builder.Timeout.ShouldBe(TimeSpan.FromMinutes(5)); + } + + [Fact] + public void WithTimeout_SupportsMillisecondPrecision() + { + var builder = CreateBuilder(); + var timeout = TimeSpan.FromMilliseconds(1500); + + builder.WithTimeout(timeout); + + builder.Timeout.ShouldBe(timeout); + builder.Timeout!.Value.TotalMilliseconds.ShouldBe(1500); + } + } +} + diff --git a/src/FluentHttpClient.Tests/HttpRequestBuilderTests.cs b/src/FluentHttpClient.Tests/HttpRequestBuilderTests.cs index 82e89a1..8521ab9 100644 --- a/src/FluentHttpClient.Tests/HttpRequestBuilderTests.cs +++ b/src/FluentHttpClient.Tests/HttpRequestBuilderTests.cs @@ -156,6 +156,40 @@ public void Cookies_HeaderConfigurators_And_OptionConfigurators_AreInitialized() builder.HeaderConfigurators.ShouldNotBeNull(); builder.OptionConfigurators.ShouldNotBeNull(); } + + [Fact] + public void Timeout_IsNull_ByDefault() + { + using var client = CreateClient(); + + var builder = new HttpRequestBuilder(client); + + builder.Timeout.ShouldBeNull(); + } + + [Fact] + public void Timeout_CanBeSet_WithValidTimeSpan() + { + using var client = CreateClient(); + var builder = new HttpRequestBuilder(client); + var timeout = TimeSpan.FromSeconds(30); + + builder.Timeout = timeout; + + builder.Timeout.ShouldBe(timeout); + } + + [Fact] + public void Timeout_CanBeCleared_BySettingToNull() + { + using var client = CreateClient(); + var builder = new HttpRequestBuilder(client); + builder.Timeout = TimeSpan.FromSeconds(30); + + builder.Timeout = null; + + builder.Timeout.ShouldBeNull(); + } } public class DeferredConfiguratorTests @@ -845,4 +879,183 @@ public async Task SendAsync_ThrowsArgumentException_WhenMethodIsEmpty() } } + public class SendAsyncTimeoutTests + { + private sealed class DelayedHttpMessageHandler : HttpMessageHandler + { + private readonly TimeSpan _delay; + private readonly HttpStatusCode _statusCode; + + public DelayedHttpMessageHandler(TimeSpan delay, HttpStatusCode statusCode = HttpStatusCode.OK) + { + _delay = delay; + _statusCode = statusCode; + } + + public int CallCount { get; private set; } + + public CancellationToken LastCancellationToken { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + CallCount++; + LastCancellationToken = cancellationToken; + + await Task.Delay(_delay, cancellationToken); + + return new HttpResponseMessage(_statusCode); + } + } + + [Fact] + public async Task SendAsync_DoesNotTimeout_WhenTimeoutIsNotSet() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromMilliseconds(50)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + + var response = await builder.SendAsync(HttpMethod.Get); + + response.ShouldNotBeNull(); + handler.CallCount.ShouldBe(1); + } + + [Fact] + public async Task SendAsync_CompletesSuccessfully_WhenRequestCompletesBeforeTimeout() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromMilliseconds(50)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + builder.Timeout = TimeSpan.FromSeconds(5); + + var response = await builder.SendAsync(HttpMethod.Get); + + response.ShouldNotBeNull(); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + handler.CallCount.ShouldBe(1); + } + + [Fact] + public async Task SendAsync_ThrowsTaskCanceledException_WhenRequestExceedsTimeout() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromSeconds(10)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + builder.Timeout = TimeSpan.FromMilliseconds(100); + + await Should.ThrowAsync( + async () => await builder.SendAsync(HttpMethod.Get)); + + handler.CallCount.ShouldBe(1); + } + + [Fact] + public async Task SendAsync_UsesLinkedCancellationToken_WhenTimeoutIsSet() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromMilliseconds(50)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + builder.Timeout = TimeSpan.FromSeconds(5); + + using var cts = new CancellationTokenSource(); + + var response = await builder.SendAsync(HttpMethod.Get, cts.Token); + + response.ShouldNotBeNull(); + handler.CallCount.ShouldBe(1); + handler.LastCancellationToken.ShouldNotBe(cts.Token); + } + + [Fact] + public async Task SendAsync_CancelsImmediately_WhenCallerTokenIsAlreadyCanceled() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromSeconds(10)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + builder.Timeout = TimeSpan.FromSeconds(5); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Should.ThrowAsync( + async () => await builder.SendAsync(HttpMethod.Get, cts.Token)); + } + + [Fact] + public async Task SendAsync_CompletesSuccessfully_WhenTimeoutIsNull() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromMilliseconds(50)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + builder.Timeout = null; + + using var cts = new CancellationTokenSource(); + + var response = await builder.SendAsync(HttpMethod.Get, cts.Token); + + response.ShouldNotBeNull(); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + handler.CallCount.ShouldBe(1); + } + + [Fact] + public async Task SendAsync_RespectsTimeoutAcrossMultipleRequests() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromMilliseconds(50)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + builder.Timeout = TimeSpan.FromSeconds(5); + + var response1 = await builder.SendAsync(HttpMethod.Get); + var response2 = await builder.SendAsync(HttpMethod.Get); + + response1.ShouldNotBeNull(); + response2.ShouldNotBeNull(); + handler.CallCount.ShouldBe(2); + } + + [Fact] + public async Task SendAsync_CanCancelViaTimeout_EvenWithLongerCallerTimeout() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromSeconds(10)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + builder.Timeout = TimeSpan.FromMilliseconds(100); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + await Should.ThrowAsync( + async () => await builder.SendAsync(HttpMethod.Get, cts.Token)); + + handler.CallCount.ShouldBe(1); + } + + [Fact] + public async Task SendAsync_CanCancelViaCallerToken_EvenWithLongerBuilderTimeout() + { + var handler = new DelayedHttpMessageHandler(TimeSpan.FromSeconds(10)); + using var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com/") }; + + var builder = new HttpRequestBuilder(client, "items"); + builder.Timeout = TimeSpan.FromSeconds(30); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Should.ThrowAsync( + async () => await builder.SendAsync(HttpMethod.Get, cts.Token)); + + handler.CallCount.ShouldBe(1); + } + } + } diff --git a/src/FluentHttpClient/FluentTimeoutExtensions.cs b/src/FluentHttpClient/FluentTimeoutExtensions.cs index 272b8f8..9ad8740 100644 --- a/src/FluentHttpClient/FluentTimeoutExtensions.cs +++ b/src/FluentHttpClient/FluentTimeoutExtensions.cs @@ -7,6 +7,17 @@ public static class FluentTimeoutExtensions { internal static readonly string MessageInvalidTimeout = "Timeout must be a positive value."; + /// + /// Clears any per-request timeout that has been set. + /// + /// + /// + public static HttpRequestBuilder ClearTimeout(this HttpRequestBuilder builder) + { + builder.Timeout = null; + return builder; + } + /// /// Sets a per-request timeout using the specified number of seconds. Must be positive. ///