Skip to content
2 changes: 1 addition & 1 deletion lib/servus.akka
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ namespace GaudiHTTP.Client
public System.Net.Security.RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; set; }
public int? SocketReceiveBufferSize { get; set; }
public int? SocketSendBufferSize { get; set; }
public double StreamRetryBackoffJitter { get; set; }
public double StreamRetryBackoffMultiplier { get; set; }
public System.TimeSpan StreamRetryInitialBackoff { get; set; }
public System.TimeSpan StreamRetryMaxBackoff { get; set; }
Expand Down Expand Up @@ -146,6 +147,10 @@ namespace GaudiHTTP.Client
public int MaxResponseHeaderCount { get; set; }
public int MaxResponseHeaderLineLength { get; set; }
public int MaxResponseHeadersLength { get; set; }
public double ReconnectBackoffJitter { get; set; }
public double ReconnectBackoffMultiplier { get; set; }
public System.TimeSpan ReconnectInitialBackoff { get; set; }
public System.TimeSpan ReconnectMaxBackoff { get; set; }
public int? RequestBodyChunkSize { get; set; }
}
public sealed class Http2ClientOptions
Expand All @@ -167,6 +172,10 @@ namespace GaudiHTTP.Client
public int MaxReconnectBufferSize { get; set; }
public int MaxResponseHeaderListSize { get; set; }
public int MaxStreamWindowSize { get; set; }
public double ReconnectBackoffJitter { get; set; }
public double ReconnectBackoffMultiplier { get; set; }
public System.TimeSpan ReconnectInitialBackoff { get; set; }
public System.TimeSpan ReconnectMaxBackoff { get; set; }
public int? RequestBodyChunkSize { get; set; }
public double WindowScaleThresholdMultiplier { get; set; }
}
Expand All @@ -184,6 +193,10 @@ namespace GaudiHTTP.Client
public int MaxReconnectBufferSize { get; set; }
public int QpackBlockedStreams { get; set; }
public int QpackMaxTableCapacity { get; set; }
public double ReconnectBackoffJitter { get; set; }
public double ReconnectBackoffMultiplier { get; set; }
public System.TimeSpan ReconnectInitialBackoff { get; set; }
public System.TimeSpan ReconnectMaxBackoff { get; set; }
public int? RequestBodyChunkSize { get; set; }
}
public interface IGaudiHttpClient : System.IDisposable
Expand Down
6 changes: 4 additions & 2 deletions src/GaudiHTTP.AcceptanceTests/H10/ResilienceSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ public async Task Resilience_should_fail_gracefully_on_corrupt_gzip()
var response = await SendDecompressingAsync(request, (_, _) => responseBytes);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<HttpRequestException>(async () =>
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}

[Fact(Timeout = 5000)]
Expand All @@ -122,7 +123,8 @@ public async Task Resilience_should_fail_gracefully_on_corrupt_brotli()
var response = await SendDecompressingAsync(request, (_, _) => responseBytes);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<HttpRequestException>(async () =>
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}

[Fact(Timeout = 10000)]
Expand Down
6 changes: 4 additions & 2 deletions src/GaudiHTTP.AcceptanceTests/H11/ResilienceSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ public async Task Resilience_should_fail_gracefully_on_corrupt_gzip()
var response = await SendDecompressingAsync(request, (_, _) => responseBytes);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<HttpRequestException>(async () =>
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}

[Fact(Timeout = 5000)]
Expand All @@ -127,7 +128,8 @@ public async Task Resilience_should_fail_gracefully_on_corrupt_brotli()
var response = await SendDecompressingAsync(request, (_, _) => responseBytes);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<HttpRequestException>(async () =>
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}

[Fact(Timeout = 5000)]
Expand Down
13 changes: 0 additions & 13 deletions src/GaudiHTTP.AcceptanceTests/ModuleInit.cs

This file was deleted.

6 changes: 4 additions & 2 deletions src/GaudiHTTP.AcceptanceTests/TLS/ResilienceSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ public async Task Resilience_should_fail_gracefully_on_corrupt_gzip_over_https()
var response = await SendDecompressingAsync(request, (_, _) => responseBytes);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<HttpRequestException>(async () =>
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}

[Fact(Timeout = 5000)]
Expand All @@ -125,7 +126,8 @@ public async Task Resilience_should_fail_gracefully_on_corrupt_brotli_over_https
var response = await SendDecompressingAsync(request, (_, _) => responseBytes);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<HttpRequestException>(async () =>
await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
}

[Fact(Timeout = 5000)]
Expand Down
4 changes: 4 additions & 0 deletions src/GaudiHTTP.Tests.Shared/FakeClientOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ internal sealed class FakeClientOps : IClientStageOperations
public List<HttpResponseMessage> Responses { get; } = [];
public List<ITransportOutbound> Outbound { get; } = [];
public List<object> BodyMessages { get; } = [];
public List<(string Name, TimeSpan Duration)> ScheduledTimers { get; } = [];
public List<string> CancelledTimers { get; } = [];

public FakeClientOps()
{
Expand All @@ -20,10 +22,12 @@ public FakeClientOps()

public void OnScheduleTimer(string name, TimeSpan duration)
{
ScheduledTimers.Add((name, duration));
}

public void OnCancelTimer(string name)
{
CancelledTimers.Add(name);
}

public IActorRef StageActor { get; }
Expand Down
13 changes: 0 additions & 13 deletions src/GaudiHTTP.Tests/ModuleInit.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.IO.Compression;
using System.Net.Http;
using GaudiHTTP.Internal;

namespace GaudiHTTP.Tests.Protocol.Semantics.Encoding;
Expand Down Expand Up @@ -36,29 +37,31 @@ public void SerializeToStream_should_decompress_deflate()
}

[Fact(Timeout = 5000)]
public async Task SerializeToStreamAsync_should_silently_handle_corrupt_gzip()
public async Task SerializeToStreamAsync_should_throw_on_corrupt_gzip()
{
var corrupt = new byte[] { 0x00, 0x01, 0x02, 0x03, 0xFF };
var inner = new ByteArrayContent(corrupt);
using var content = new DecompressingContent(inner, "gzip");

using var ms = new MemoryStream();
await content.CopyToAsync(ms, TestContext.Current.CancellationToken);

Assert.Equal(0, ms.Length);
// A corrupt/truncated compressed body must surface as an error, not silently truncate.
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
content.CopyToAsync(ms, TestContext.Current.CancellationToken));
Assert.NotNull(ex.InnerException);
}

[Fact(Timeout = 5000)]
public void SerializeToStream_should_silently_handle_corrupt_gzip()
public void SerializeToStream_should_throw_on_corrupt_gzip()
{
var corrupt = new byte[] { 0x00, 0x01, 0x02, 0x03, 0xFF };
var inner = new ByteArrayContent(corrupt);
using var content = new DecompressingContent(inner, "gzip");

using var ms = new MemoryStream();
content.CopyTo(ms, null, CancellationToken.None);

Assert.Equal(0, ms.Length);
var ex = Assert.Throws<HttpRequestException>(() => content.CopyTo(ms, null, CancellationToken.None));
Assert.NotNull(ex.InnerException);
}

[Fact(Timeout = 5000)]
Expand Down
53 changes: 53 additions & 0 deletions src/GaudiHTTP.Tests/Protocol/Semantics/ReconnectBackoffSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using GaudiHTTP.Protocol.Semantics;

namespace GaudiHTTP.Tests.Protocol.Semantics;

public sealed class ReconnectBackoffSpec
{
[Fact(Timeout = 5000)]
public void Compute_with_zero_jitter_should_grow_geometrically()
{
var initial = TimeSpan.FromMilliseconds(100);
var max = TimeSpan.FromSeconds(30);

var r1 = ReconnectBackoff.Compute(1, initial, max, 2.0, 0.0, Random.Shared);
var r2 = ReconnectBackoff.Compute(2, initial, max, 2.0, 0.0, Random.Shared);
var r3 = ReconnectBackoff.Compute(3, initial, max, 2.0, 0.0, Random.Shared);

Assert.Equal(100, r1.TotalMilliseconds, 3);
Assert.Equal(200, r2.TotalMilliseconds, 3);
Assert.Equal(400, r3.TotalMilliseconds, 3);
}

[Fact(Timeout = 5000)]
public void Compute_should_cap_at_max()
{
var max = TimeSpan.FromSeconds(5);

var delay = ReconnectBackoff.Compute(20, TimeSpan.FromMilliseconds(100), max, 2.0, 0.0, Random.Shared);

Assert.Equal(max.TotalMilliseconds, delay.TotalMilliseconds, 3);
}

[Fact(Timeout = 5000)]
public void Compute_should_apply_symmetric_jitter_within_bounds()
{
var initial = TimeSpan.FromMilliseconds(1000);
var max = TimeSpan.FromSeconds(30);

for (var i = 0; i < 200; i++)
{
var delay = ReconnectBackoff.Compute(1, initial, max, 2.0, 0.2, Random.Shared).TotalMilliseconds;
Assert.InRange(delay, 800, 1200);
}
}

[Fact(Timeout = 5000)]
public void Compute_should_never_return_below_one_millisecond()
{
var delay = ReconnectBackoff.Compute(1, TimeSpan.FromMilliseconds(0.5),
TimeSpan.FromSeconds(1), 2.0, 0.2, Random.Shared);

Assert.True(delay >= TimeSpan.FromMilliseconds(1), $"expected >= 1ms, was {delay.TotalMilliseconds}ms");
}
}
61 changes: 61 additions & 0 deletions src/GaudiHTTP.Tests/Protocol/Semantics/ReconnectPolicySpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,67 @@ public void OnAttemptFailed_should_exhaust_and_disconnect_once_max_attempts_is_r
Assert.IsType<DisconnectTransport>(ops.Outbound[^1]);
}

[Fact(Timeout = 5000)]
public void OnAttemptFailed_should_defer_the_retry_behind_a_backoff_timer_when_backoff_is_configured()
{
var ops = new FakeClientOps();
var policy = new ReconnectPolicy<string>(ops, maxAttempts: 3,
initialBackoff: TimeSpan.FromMilliseconds(100), maxBackoff: TimeSpan.FromSeconds(5));
policy.Start("buffered-request", MakeTransportOptions());
ops.Outbound.Clear();

var exhausted = policy.OnAttemptFailed(MakeTransportOptions(), out _);

Assert.False(exhausted);
// The retry must NOT connect immediately — that is the zero-delay busy-loop we are fixing.
Assert.DoesNotContain(ops.Outbound, o => o is ConnectTransport);
var timer = Assert.Single(ops.ScheduledTimers);
Assert.Equal(ReconnectPolicy<string>.BackoffTimerName, timer.Name);
Assert.True(timer.Duration > TimeSpan.Zero);
Assert.True(timer.Duration <= TimeSpan.FromSeconds(5));
}

[Fact(Timeout = 5000)]
public void OnReconnectTimerFired_should_emit_the_connect_transport_for_its_own_timer()
{
var ops = new FakeClientOps();
var policy = new ReconnectPolicy<string>(ops, maxAttempts: 3,
initialBackoff: TimeSpan.FromMilliseconds(100), maxBackoff: TimeSpan.FromSeconds(5));
policy.Start("buffered-request", MakeTransportOptions());
policy.OnAttemptFailed(MakeTransportOptions(), out _);
ops.Outbound.Clear();

var handled = policy.OnReconnectTimerFired(ReconnectPolicy<string>.BackoffTimerName);

Assert.True(handled);
Assert.Single(ops.Outbound, o => o is ConnectTransport);
}

[Fact(Timeout = 5000)]
public void OnReconnectTimerFired_should_ignore_unrelated_timer_names()
{
var ops = new FakeClientOps();
var policy = new ReconnectPolicy<string>(ops, maxAttempts: 3,
initialBackoff: TimeSpan.FromMilliseconds(100), maxBackoff: TimeSpan.FromSeconds(5));

Assert.False(policy.OnReconnectTimerFired("keep-alive-timeout"));
Assert.Empty(ops.Outbound);
}

[Fact(Timeout = 5000)]
public void OnAttemptFailed_without_backoff_should_connect_immediately()
{
var ops = new FakeClientOps();
var policy = new ReconnectPolicy<string>(ops, maxAttempts: 3);
policy.Start("buffered-request", MakeTransportOptions());
ops.Outbound.Clear();

policy.OnAttemptFailed(MakeTransportOptions(), out _);

Assert.Single(ops.Outbound, o => o is ConnectTransport);
Assert.Empty(ops.ScheduledTimers);
}

[Fact(Timeout = 5000)]
public void CanReconnect_should_reflect_whether_max_attempts_is_positive()
{
Expand Down
Loading
Loading