// Go reference: jetstream_api.go — rate limiting via maxConcurrentRequests semaphore and // request deduplication via the dedup cache keyed by Nats-Msg-Id header. // The Go server uses a configurable semaphore (default 256) to throttle concurrent API // requests, and caches responses for duplicate request IDs within a TTL window. namespace NATS.Server.JetStream.Tests.JetStream.Api; using NATS.Server.JetStream.Api; public class ApiRateLimiterTests : IDisposable { private readonly ApiRateLimiter _limiter = new(maxConcurrent: 4); public void Dispose() => _limiter.Dispose(); // Go reference: jetstream_api.go — semaphore.TryAcquire(0) used for non-blocking attempt. [Fact] public async Task TryAcquire_succeeds_when_slots_available() { var acquired = await _limiter.TryAcquireAsync(); acquired.ShouldBeTrue(); } // Go reference: jetstream_api.go — when all slots are taken, new requests are rejected. [Fact] public async Task TryAcquire_fails_when_all_slots_taken() { // Fill all 4 slots. for (var i = 0; i < 4; i++) (await _limiter.TryAcquireAsync()).ShouldBeTrue(); // 5th attempt should fail. var rejected = await _limiter.TryAcquireAsync(); rejected.ShouldBeFalse(); } // Go reference: jetstream_api.go — releasing a slot allows a subsequent request to proceed. [Fact] public async Task Release_frees_slot_for_next_request() { // Fill all slots. for (var i = 0; i < 4; i++) (await _limiter.TryAcquireAsync()).ShouldBeTrue(); // Currently full. (await _limiter.TryAcquireAsync()).ShouldBeFalse(); // Release one slot. _limiter.Release(); // Now one slot is free. (await _limiter.TryAcquireAsync()).ShouldBeTrue(); } // Go reference: jetstream_api.go — active count reflects in-flight requests. [Fact] public async Task ActiveCount_tracks_concurrent_requests() { _limiter.ActiveCount.ShouldBe(0); await _limiter.TryAcquireAsync(); await _limiter.TryAcquireAsync(); await _limiter.TryAcquireAsync(); _limiter.ActiveCount.ShouldBe(3); } // Go reference: jetstream_api.go — unknown request ID returns null (cache miss). [Fact] public void GetCachedResponse_returns_null_for_unknown_id() { var result = _limiter.GetCachedResponse("nonexistent-id"); result.ShouldBeNull(); } // Go reference: jetstream_api.go — dedup cache stores response keyed by Nats-Msg-Id. [Fact] public void CacheResponse_and_get_returns_cached() { var response = JetStreamApiResponse.SuccessResponse(); _limiter.CacheResponse("req-001", response); var cached = _limiter.GetCachedResponse("req-001"); cached.ShouldNotBeNull(); cached!.Success.ShouldBeTrue(); } // Go reference: jetstream_api.go — dedup window expires after TTL (dedupWindow config). [SlopwatchSuppress("SW004", "TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing a time-based cache eviction")] [Fact] public async Task GetCachedResponse_returns_null_after_ttl_expiry() { using var shortLimiter = new ApiRateLimiter(maxConcurrent: 4, dedupTtl: TimeSpan.FromMilliseconds(50)); var response = JetStreamApiResponse.SuccessResponse(); shortLimiter.CacheResponse("req-ttl", response); // Verify it's cached before expiry. shortLimiter.GetCachedResponse("req-ttl").ShouldNotBeNull(); // Wait for TTL to expire. await Task.Delay(120); // Should be null after expiry. shortLimiter.GetCachedResponse("req-ttl").ShouldBeNull(); } // Go reference: jetstream_api.go — null/empty Nats-Msg-Id is ignored for dedup. [Fact] public void CacheResponse_ignores_null_request_id() { var response = JetStreamApiResponse.SuccessResponse(); // These should not throw and should not increment the cache count. _limiter.CacheResponse(null, response); _limiter.CacheResponse("", response); _limiter.CacheResponse(string.Empty, response); _limiter.DedupCacheCount.ShouldBe(0); _limiter.GetCachedResponse(null).ShouldBeNull(); _limiter.GetCachedResponse("").ShouldBeNull(); } // Go reference: jetstream_api.go — periodic sweep removes expired dedup entries. [SlopwatchSuppress("SW004", "TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing a time-based cache eviction")] [Fact] public async Task PurgeExpired_removes_old_entries() { using var shortLimiter = new ApiRateLimiter(maxConcurrent: 4, dedupTtl: TimeSpan.FromMilliseconds(50)); shortLimiter.CacheResponse("req-a", JetStreamApiResponse.SuccessResponse()); shortLimiter.CacheResponse("req-b", JetStreamApiResponse.SuccessResponse()); shortLimiter.CacheResponse("req-c", JetStreamApiResponse.SuccessResponse()); shortLimiter.DedupCacheCount.ShouldBe(3); // Wait for all entries to expire. await Task.Delay(120); var removed = shortLimiter.PurgeExpired(); removed.ShouldBe(3); shortLimiter.DedupCacheCount.ShouldBe(0); } // Go reference: jetstream_api.go — dedup cache count is observable. [Fact] public void DedupCacheCount_tracks_cached_entries() { _limiter.DedupCacheCount.ShouldBe(0); _limiter.CacheResponse("req-1", JetStreamApiResponse.Ok()); _limiter.CacheResponse("req-2", JetStreamApiResponse.Ok()); _limiter.CacheResponse("req-3", JetStreamApiResponse.Ok()); _limiter.DedupCacheCount.ShouldBe(3); } // Go reference: jetstream_api.go — semaphore enforces max-concurrent across goroutines. [Fact] public async Task Concurrent_acquire_respects_max() { using var limiter = new ApiRateLimiter(maxConcurrent: 5); // Spin up 10 tasks, only 5 should succeed. var results = await Task.WhenAll( Enumerable.Range(0, 10).Select(_ => limiter.TryAcquireAsync())); var acquired = results.Count(r => r); acquired.ShouldBe(5); } // Go reference: jetstream_api.go — default maxConcurrentRequests = 256. [Fact] public async Task Default_max_concurrent_is_256() { using var defaultLimiter = new ApiRateLimiter(); // Acquire 256 slots — all should succeed. var tasks = Enumerable.Range(0, 256).Select(_ => defaultLimiter.TryAcquireAsync()); var results = await Task.WhenAll(tasks); results.ShouldAllBe(r => r); // 257th should fail. var rejected = await defaultLimiter.TryAcquireAsync(); rejected.ShouldBeFalse(); } }