Skip to content
Merged
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
136 changes: 136 additions & 0 deletions src/FluentHttpClient.Tests/FluentTimeoutExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(
() => 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<ArgumentOutOfRangeException>(
() => 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);
}
}
}

213 changes: 213 additions & 0 deletions src/FluentHttpClient.Tests/HttpRequestBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HttpResponseMessage> 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<TaskCanceledException>(
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<TaskCanceledException>(
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<TaskCanceledException>(
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<TaskCanceledException>(
async () => await builder.SendAsync(HttpMethod.Get, cts.Token));

handler.CallCount.ShouldBe(1);
}
}

}
11 changes: 11 additions & 0 deletions src/FluentHttpClient/FluentTimeoutExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ public static class FluentTimeoutExtensions
{
internal static readonly string MessageInvalidTimeout = "Timeout must be a positive value.";

/// <summary>
/// Clears any per-request timeout that has been set.
/// </summary>
/// <param name="builder"></param>
/// <returns></returns>
public static HttpRequestBuilder ClearTimeout(this HttpRequestBuilder builder)
{
builder.Timeout = null;
return builder;
}

/// <summary>
/// Sets a per-request timeout using the specified number of seconds. Must be positive.
/// </summary>
Expand Down