diff --git a/tests/NATS.Server.Tests/Gateways/GatewayReconnectionTests.cs b/tests/NATS.Server.Tests/Gateways/GatewayReconnectionTests.cs new file mode 100644 index 0000000..d779496 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayReconnectionTests.cs @@ -0,0 +1,213 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; +using NATS.Server.Gateways; +using Shouldly; + +namespace NATS.Server.Tests.Gateways; + +/// +/// Tests for GatewayReconnectPolicy and GatewayManager reconnection tracking. +/// Go reference: server/gateway.go reconnectGateway / solicitGateway (gateway.go:1700+). +/// +public class GatewayReconnectionTests +{ + // ── GatewayReconnectPolicy delay calculation ────────────────────────── + + // Go: server/gateway.go solicitGateway delay=0 on first attempt + [Fact] + public void CalculateDelay_first_attempt_is_initial_delay() + { + var policy = new GatewayReconnectPolicy + { + InitialDelay = TimeSpan.FromSeconds(1), + MaxDelay = TimeSpan.FromSeconds(30), + }; + + var delay = policy.CalculateDelay(0); + + delay.ShouldBe(TimeSpan.FromSeconds(1)); + } + + // Go: server/gateway.go reconnectGateway exponential back-off doubling + [Fact] + public void CalculateDelay_doubles_each_attempt() + { + var policy = new GatewayReconnectPolicy + { + InitialDelay = TimeSpan.FromSeconds(1), + MaxDelay = TimeSpan.FromSeconds(1000), + }; + + var delay0 = policy.CalculateDelay(0); + var delay1 = policy.CalculateDelay(1); + var delay2 = policy.CalculateDelay(2); + var delay3 = policy.CalculateDelay(3); + + delay1.ShouldBe(delay0 * 2); + delay2.ShouldBe(delay0 * 4); + delay3.ShouldBe(delay0 * 8); + } + + // Go: server/gateway.go reconnectGateway maxDelay cap + [Fact] + public void CalculateDelay_caps_at_max_delay() + { + var policy = new GatewayReconnectPolicy + { + InitialDelay = TimeSpan.FromSeconds(1), + MaxDelay = TimeSpan.FromSeconds(5), + }; + + // At attempt=10 (2^10 = 1024 seconds * 1s initial), should be capped + var delay = policy.CalculateDelay(10); + + delay.ShouldBe(TimeSpan.FromSeconds(5)); + } + + // Go: server/gateway.go reconnectGateway jitter added to avoid thundering herd + [Fact] + public void CalculateDelayWithJitter_adds_jitter() + { + var policy = new GatewayReconnectPolicy + { + InitialDelay = TimeSpan.FromSeconds(1), + MaxDelay = TimeSpan.FromSeconds(30), + JitterFactor = 0.2, + }; + + var baseDelay = policy.CalculateDelay(3); + + // Run several times to increase the chance of observing jitter + var observed = false; + for (var i = 0; i < 20; i++) + { + var jittered = policy.CalculateDelayWithJitter(3); + jittered.ShouldBeGreaterThanOrEqualTo(baseDelay); + jittered.ShouldBeLessThanOrEqualTo(TimeSpan.FromMilliseconds( + baseDelay.TotalMilliseconds * (1 + policy.JitterFactor) + 1)); + + if (jittered > baseDelay) + observed = true; + } + + observed.ShouldBeTrue("at least one jittered delay should exceed base delay"); + } + + // ── GatewayManager reconnect attempt tracking ────────────────────────── + + // Go: server/gateway.go initial state has no reconnect history + [Fact] + public void GetReconnectAttempts_starts_at_zero() + { + var manager = BuildManager(); + + manager.GetReconnectAttempts("gw-east").ShouldBe(0); + manager.GetReconnectAttempts("gw-west").ShouldBe(0); + } + + // Go: server/gateway.go reconnectGateway increments attempt counter each cycle + [Fact] + public async Task ReconnectAttempts_incremented_on_reconnect() + { + var manager = BuildManager(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately so Task.Delay throws before any real wait + + // Counter is incremented before the delay, so it reaches 1 even when cancelled. + await Should.ThrowAsync( + () => manager.ReconnectGatewayAsync("gw-east", cts.Token)); + + manager.GetReconnectAttempts("gw-east").ShouldBe(1); + } + + // Go: server/gateway.go solicitGateway resets counter after successful connect + [Fact] + public async Task ResetReconnectAttempts_clears_count() + { + var manager = BuildManager(); + + // Seed the counter with one cancelled attempt + await ReconnectAsync(manager, "gw-east"); + + manager.GetReconnectAttempts("gw-east").ShouldBe(1); + + manager.ResetReconnectAttempts("gw-east"); + + manager.GetReconnectAttempts("gw-east").ShouldBe(0); + } + + // Go: server/gateway.go configurable initial delay via options + [Fact] + public void Custom_initial_delay_respected() + { + var policy = new GatewayReconnectPolicy + { + InitialDelay = TimeSpan.FromMilliseconds(500), + MaxDelay = TimeSpan.FromSeconds(30), + }; + + policy.CalculateDelay(0).ShouldBe(TimeSpan.FromMilliseconds(500)); + policy.CalculateDelay(1).ShouldBe(TimeSpan.FromMilliseconds(1000)); + policy.CalculateDelay(2).ShouldBe(TimeSpan.FromMilliseconds(2000)); + } + + // Go: server/gateway.go configurable max delay cap + [Fact] + public void Custom_max_delay_caps_correctly() + { + var policy = new GatewayReconnectPolicy + { + InitialDelay = TimeSpan.FromSeconds(2), + MaxDelay = TimeSpan.FromSeconds(10), + }; + + // 2s * 2^10 = 2048s >> 10s cap + policy.CalculateDelay(10).ShouldBe(TimeSpan.FromSeconds(10)); + // 2s * 2^2 = 8s < 10s cap + policy.CalculateDelay(2).ShouldBe(TimeSpan.FromSeconds(8)); + } + + // Go: server/gateway.go independent reconnect state per remote gateway + [Fact] + public async Task Multiple_gateways_tracked_independently() + { + var manager = BuildManager(); + + // Increment east twice, west once + await ReconnectAsync(manager, "gw-east"); + await ReconnectAsync(manager, "gw-east"); + await ReconnectAsync(manager, "gw-west"); + + manager.GetReconnectAttempts("gw-east").ShouldBe(2); + manager.GetReconnectAttempts("gw-west").ShouldBe(1); + + // Reset east should not affect west + manager.ResetReconnectAttempts("gw-east"); + manager.GetReconnectAttempts("gw-east").ShouldBe(0); + manager.GetReconnectAttempts("gw-west").ShouldBe(1); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private static GatewayManager BuildManager() => + new GatewayManager( + new GatewayOptions { Name = "TEST", Host = "127.0.0.1", Port = 0 }, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + /// + /// Triggers a single ReconnectGatewayAsync cycle with an immediately-cancelled token so + /// the attempt counter is incremented without waiting for any real delay. + /// The expected OperationCanceledException is asserted via Shouldly. + /// + private static async Task ReconnectAsync(GatewayManager manager, string gatewayName) + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Should.ThrowAsync( + () => manager.ReconnectGatewayAsync(gatewayName, cts.Token)); + } +}