Skip to content
Open
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
1 change: 1 addition & 0 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
</ItemGroup>
Expand Down
229 changes: 219 additions & 10 deletions RateLimiter.Tests/RateLimiterTest.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,222 @@
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Microsoft.Extensions.Logging;
using RateLimiter.Rules;

namespace RateLimiter.Tests;

[TestFixture]
public class RateLimiterTest
namespace RateLimiter.Tests
{
[Test]
public void Example()
{
Assert.That(true, Is.True);
}
[TestFixture]
public class FixedWindowRuleTests
{
[Test]
public void IsAllowed_WithinLimit_ReturnsTrue()
{
var rule = new FixedWindowRule(2, TimeSpan.FromMinutes(1));
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
}

[Test]
public void IsAllowed_ExceedsLimit_ReturnsFalse()
{
var rule = new FixedWindowRule(2, TimeSpan.FromMinutes(1));
rule.IsAllowed("client1", "res1");
rule.IsAllowed("client1", "res1");
Assert.That(rule.IsAllowed("client1", "res1"), Is.False);
}

[Test]
public void IsAllowed_AfterWindowReset_AllowsAgain()
{
var rule = new FixedWindowRule(1, TimeSpan.FromMilliseconds(50));
rule.IsAllowed("client1", "res1");
Thread.Sleep(100);
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
}

[Test]
public void Cleanup_RemovesExpiredEntries()
{
var rule = new FixedWindowRule(1, TimeSpan.FromMilliseconds(50));
rule.IsAllowed("client1", "res1");
Thread.Sleep(100);
rule.Cleanup();
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
}
}

[TestFixture]
public class SlidingWindowRuleTests
{
[Test]
public void IsAllowed_WithinLimit_ReturnsTrue()
{
var rule = new SlidingWindowRule(3, TimeSpan.FromMinutes(1));
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
}

[Test]
public void IsAllowed_ExceedsLimit_ReturnsFalse()
{
var rule = new SlidingWindowRule(2, TimeSpan.FromMinutes(1));
rule.IsAllowed("client1", "res1");
rule.IsAllowed("client1", "res1");
Assert.That(rule.IsAllowed("client1", "res1"), Is.False);
}

[Test]
public void IsAllowed_AfterSlidingWindow_AllowsAgain()
{
var rule = new SlidingWindowRule(2, TimeSpan.FromMilliseconds(100));
rule.IsAllowed("client1", "res1");
Thread.Sleep(50);
rule.IsAllowed("client1", "res1");
Thread.Sleep(60);
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
}

[Test]
public void Cleanup_RemovesOldRequests()
{
var rule = new SlidingWindowRule(2, TimeSpan.FromMilliseconds(50));
rule.IsAllowed("client1", "res1");
Thread.Sleep(100);
rule.Cleanup();
Assert.That(rule.IsAllowed("client1", "res1"), Is.True);
}
}

[TestFixture]
public class RegionalRuleTests
{
[Test]
public void IsAllowed_UsesCorrectRegionRule()
{
var usRule = new Mock<IRateLimitRule>();
usRule.Setup(r => r.IsAllowed("US-123", "res1")).Returns(true);

var euRule = new Mock<IRateLimitRule>();
euRule.Setup(r => r.IsAllowed("EU-456", "res1")).Returns(true);

var rule = new RegionalRule(new Dictionary<string, IRateLimitRule>
{
["US"] = usRule.Object,
["EU"] = euRule.Object
});

rule.IsAllowed("US-123", "res1");
usRule.Verify(r => r.IsAllowed("US-123", "res1"), Times.Once);

rule.IsAllowed("EU-456", "res1");
euRule.Verify(r => r.IsAllowed("EU-456", "res1"), Times.Once);
}

[Test]
public void IsAllowed_UnknownRegion_ReturnsFalse()
{
var rule = new RegionalRule(new Dictionary<string, IRateLimitRule>());
Assert.That(rule.IsAllowed("ASIA-789", "res1"), Is.False);
}
}

[TestFixture]
public class RateLimiterTests
{
private RateLimiter _rateLimiter;
private Mock<ILogger<RateLimiter>> _loggerMock;

[SetUp]
public void Setup()
{
_loggerMock = new Mock<ILogger<RateLimiter>>();
_rateLimiter = new RateLimiter(_loggerMock.Object);
}

[Test]
public void ResourceWithSingleFixedWindowRule_ShouldAllowWithinLimit()
{
var rule = new FixedWindowRule(3, TimeSpan.FromSeconds(10));
_rateLimiter.AddRule("resourceA", rule);

for (int i = 0; i < 3; i++)
{
Assert.IsTrue(_rateLimiter.IsRequestAllowed("client1", "resourceA"));
}
Assert.IsFalse(_rateLimiter.IsRequestAllowed("client1", "resourceA"));
}

[Test]
public void ResourceWithSingleSlidingWindowRule_ShouldAllowWithinLimit()
{
var rule = new SlidingWindowRule(2, TimeSpan.FromSeconds(5));
_rateLimiter.AddRule("resourceB", rule);

Assert.IsTrue(_rateLimiter.IsRequestAllowed("client2", "resourceB"));
Assert.IsTrue(_rateLimiter.IsRequestAllowed("client2", "resourceB"));
Assert.IsFalse(_rateLimiter.IsRequestAllowed("client2", "resourceB"));
}

[Test]
public void ResourceWithMultipleRules_ShouldDenyIfAnyRuleFails()
{
var fixedRule = new FixedWindowRule(2, TimeSpan.FromSeconds(10));
var slidingRule = new SlidingWindowRule(3, TimeSpan.FromSeconds(5));

_rateLimiter.AddRule("resourceC", fixedRule);
_rateLimiter.AddRule("resourceC", slidingRule);

Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC"));
Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC"));

Assert.IsFalse(_rateLimiter.IsRequestAllowed("client3", "resourceC"));

Thread.Sleep(10000);
Assert.IsTrue(_rateLimiter.IsRequestAllowed("client3", "resourceC"));
}

[Test]
public void RegionalRule_ShouldApplyCorrectRegionalLimits()
{
var regionalRule = new RegionalRule(new Dictionary<string, IRateLimitRule>
{
{ "US", new FixedWindowRule(1, TimeSpan.FromSeconds(10)) },
{ "ASIA", new SlidingWindowRule(2, TimeSpan.FromSeconds(5)) }
});

_rateLimiter.AddRule("resourceD", regionalRule);

Assert.IsTrue(_rateLimiter.IsRequestAllowed("US-client", "resourceD"));
Assert.IsFalse(_rateLimiter.IsRequestAllowed("US-client", "resourceD"));

Assert.IsTrue(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD"));
Assert.IsTrue(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD"));
Assert.IsFalse(_rateLimiter.IsRequestAllowed("ASIA-client", "resourceD"));
}
}

[TestFixture]
public class ConcurrencyTests
{
[Test]
public async Task FixedWindowRule_WithConcurrentRequests_EnforcesLimit()
{
var rule = new FixedWindowRule(10, TimeSpan.FromSeconds(1));
var tasks = new List<Task>();

for (int i = 0; i < 15; i++)
{
tasks.Add(Task.Run(() => rule.IsAllowed("client1", "res1")));
}

await Task.WhenAll(tasks);

Assert.That(rule.IsAllowed("client1", "res1"), Is.False);
}
}
}
50 changes: 50 additions & 0 deletions RateLimiter/RateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using RateLimiter.Rules;

namespace RateLimiter
{
public class RateLimiter
{
private readonly ConcurrentDictionary<string, List<IRateLimitRule>> _resourceRules = new();
private readonly ILogger<RateLimiter> _logger;

public RateLimiter(ILogger<RateLimiter> logger)
{
_logger = logger;
}

public void AddRule(string resource, IRateLimitRule rule)
{
_resourceRules.AddOrUpdate(resource, _ => new List<IRateLimitRule> { rule }, (_, rules) => { rules.Add(rule); return rules; });
}

public bool IsRequestAllowed(string clientId, string resource)
{
if (!_resourceRules.TryGetValue(resource, out var rules))
return true;

foreach (var rule in rules)
{
if (!rule.IsAllowed(clientId, resource))
{
_logger.LogWarning($"Request blocked: Client {clientId}, Resource {resource}");
return false;
}
}
return true;
}

public void Cleanup()
{
foreach (var rules in _resourceRules.Values)
{
foreach (var rule in rules)
{
rule.Cleanup();
}
}
}
}
}
3 changes: 3 additions & 0 deletions RateLimiter/RateLimiter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.1" />
</ItemGroup>
</Project>
45 changes: 45 additions & 0 deletions RateLimiter/Rules/FixedWindowRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System;
using System.Collections.Concurrent;

namespace RateLimiter.Rules
{
public class FixedWindowRule : IRateLimitRule
{
private readonly int _limit;
private readonly TimeSpan _window;
private readonly ConcurrentDictionary<string, (int count, DateTime resetTime)> _requests = new();

public FixedWindowRule(int limit, TimeSpan window)
{
_limit = limit;
_window = window;
}

public bool IsAllowed(string clientId, string resource)
{
var key = $"{clientId}:{resource}";
var now = DateTime.UtcNow;

_requests.AddOrUpdate(key, _ => (1, now + _window), (_, entry) =>
{
if (entry.resetTime <= now)
return (1, now + _window);
return (entry.count + 1, entry.resetTime);
});

return _requests[key].count <= _limit;
}

public void Cleanup()
{
var now = DateTime.UtcNow;
foreach (var key in _requests.Keys)
{
if (_requests.TryGetValue(key, out var entry) && entry.resetTime <= now)
{
_requests.TryRemove(key, out _);
}
}
}
}
}
8 changes: 8 additions & 0 deletions RateLimiter/Rules/IRateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RateLimiter.Rules
{
public interface IRateLimitRule
{
bool IsAllowed(string clientId, string resource);
void Cleanup();
}
}
36 changes: 36 additions & 0 deletions RateLimiter/Rules/RegionalRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Collections.Generic;

namespace RateLimiter.Rules
{
public class RegionalRule : IRateLimitRule
{
private readonly Dictionary<string, IRateLimitRule> _regionRules;

public RegionalRule(Dictionary<string, IRateLimitRule> regionRules)
{
_regionRules = regionRules;
}

public bool IsAllowed(string clientId, string resource)
{
var region = GetRegionFromClient(clientId);
return _regionRules.TryGetValue(region, out var rule) && rule.IsAllowed(clientId, resource);
}

private string GetRegionFromClient(string clientId)
{
if (clientId.StartsWith("US-")) return "US";
if (clientId.StartsWith("EU-")) return "EU";
if (clientId.StartsWith("ASIA-")) return "ASIA";
return "GLOBAL";
}

public void Cleanup()
{
foreach (var rule in _regionRules.Values)
{
rule.Cleanup();
}
}
}
}
Loading