Skip to content
Closed
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
.vs
.idea
packages
bin
obj
obj
*.DotSettings.user
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
13 changes: 0 additions & 13 deletions RateLimiter.Tests/RateLimiterTest.cs

This file was deleted.

76 changes: 76 additions & 0 deletions RateLimiter.Tests/RequestRateLimiterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
namespace RateLimiter.Tests;

using System;
using System.Threading.Tasks;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Rules;
using RateLimiter.Store;

[TestFixture]
public class RequestRateLimiterTests
{
[Test]
public async Task IsRequestAllowed_WhenAllRateLimitRulesPass_ReturnsTrue()
{
// Arrange
var rateLimiterStore = new RateLimitRuleStore();
rateLimiterStore.AddRules(
"resourceA",
new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromMinutes(1)),
new RegionRateLimitRule(Region.EU, new FixedWindowRateLimitRule(TimeSpan.FromMinutes(1), 5)),
new RegionRateLimitRule(Region.US, new FixedWindowRateLimitRule(TimeSpan.FromMinutes(1), 100)));
var rateLimiter = new RequestRateLimiter(rateLimiterStore);

var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123", ClientRegion: Region.EU);

// Act
var result = await rateLimiter.IsRequestAllowedAsync(request);

// Assert
Assert.That(result, Is.True);
}

[Test]
public async Task IsRequestAllowed_WhenAnyLimitRuleFails_ReturnsFalse()
{
// Arrange
var rateLimiterStore = new RateLimitRuleStore();
rateLimiterStore.AddRules(
"resourceA",
new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromMinutes(5)),
new RegionRateLimitRule(Region.US, new FixedWindowRateLimitRule(TimeSpan.FromMinutes(1), 100)),
new RegionRateLimitRule(Region.EU, new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromMinutes(1))));
var rateLimiter = new RequestRateLimiter(rateLimiterStore);

var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123", ClientRegion: Region.EU);

// Act
for (int i = 0; i < 10; i++)
{
await rateLimiter.IsRequestAllowedAsync(request);
}

var result = await rateLimiter.IsRequestAllowedAsync(request);

// Assert
Assert.That(result, Is.False);
}

[Test]
public async Task IsRequestAllowed_WhenNoRulesConfigured_ReturnsTrue()
{
// Arrange
var rateLimiterStore = new RateLimitRuleStore();
rateLimiterStore.AddRules("resourceA", new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromMinutes(1)));
var rateLimiter = new RequestRateLimiter(rateLimiterStore);

var request = new RequestContext(ResourceId: "resourceB", AccessToken: "123");

// Act
var result = await rateLimiter.IsRequestAllowedAsync(request);

// Assert
Assert.That(result, Is.True);
}
}
44 changes: 44 additions & 0 deletions RateLimiter.Tests/Rules/FixedWindowRateLimitRuleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace RateLimiter.Tests.Rules;

using System;
using System.Threading.Tasks;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Rules;

[TestFixture]
public class FixedWindowRateLimitRuleTests
{
[Test]
public async Task IsRequestAllowed_WhenCallCountWithInLimits_ReturnsTrue()
{
// Arrange
var rule = new FixedWindowRateLimitRule(TimeSpan.FromSeconds(1), 10);
var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123");

// Act
for (int i = 0; i < 5; i++)
{
await rule.IsRequestAllowedAsync(request);
}
var result = await rule.IsRequestAllowedAsync(request);

// Assert
Assert.That(result, Is.True);
}

[Test]
public async Task IsRequestAllowed_WhenCallCountExceedsLimits_ReturnsFalse()
{
// Arrange
var rule = new FixedWindowRateLimitRule(TimeSpan.FromSeconds(1), 1);
var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123");

// Act
await rule.IsRequestAllowedAsync(request);
var result = await rule.IsRequestAllowedAsync(request);

// Assert
Assert.That(result, Is.False);
}
}
42 changes: 42 additions & 0 deletions RateLimiter.Tests/Rules/RegionRateLimitRuleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace RateLimiter.Tests.Rules;

using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Rules;

[TestFixture]
public class RegionRateLimitRuleTests
{
[Test]
public async Task IsRequestAllowed_WhenRegionMatched_CallsAppropriateRateLimitRule()
{
// Arrange
var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123", ClientRegion: Region.US);
var rateLimitRule = new Mock<IRateLimitRule>();
var rule = new RegionRateLimitRule(Region.US, rateLimitRule.Object);

// Act

var result = await rule.IsRequestAllowedAsync(request);

// Assert
rateLimitRule.Verify(it => it.IsRequestAllowedAsync(request), Times.Once);
}

[Test]
public async Task IsRequestAllowed_WhenRegionDoesNotMatch_ReturnsTrue()
{
// Arrange
var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123", ClientRegion: Region.US);
var rule = new RegionRateLimitRule(Region.US, new Mock<IRateLimitRule>().Object);

// Act

var result = await rule.IsRequestAllowedAsync(request);

// Assert
Assert.That(result, Is.True);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace RateLimiter.Tests.Rules;

using System;
using System.Threading.Tasks;
using NUnit.Framework;
using RateLimiter.Models;
using RateLimiter.Rules;

[TestFixture]
public class TimePassedSinceLastCallRateLimitRuleTests
{
[Test]
public async Task IsRequestAllowed_WhenRequiredTimePassed_ReturnsTrue()
{
// Arrange
var rule = new TimePassedSinceLastCallRateLimitRule(TimeSpan.Zero);
var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123");

// Act
var result = await rule.IsRequestAllowedAsync(request);

// Assert
Assert.That(result, Is.True);
}

[Test]
public async Task IsRequestAllowed_WhenRequiredTimeNotPassed_ReturnsFalse()
{
// Arrange
var rule = new TimePassedSinceLastCallRateLimitRule(TimeSpan.FromSeconds(1));
var request = new RequestContext(ResourceId: "resourceA", AccessToken: "123");

// Act
await rule.IsRequestAllowedAsync(request);
var result = await rule.IsRequestAllowedAsync(request);

// Assert
Assert.That(result, Is.False);
}
}
9 changes: 9 additions & 0 deletions RateLimiter/IRateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace RateLimiter;

using System.Threading.Tasks;
using RateLimiter.Models;

public interface IRateLimiter
{
Task<bool> IsRequestAllowedAsync(RequestContext request);
}
8 changes: 8 additions & 0 deletions RateLimiter/Models/Region.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace RateLimiter.Models;

public enum Region
{
Unknown,
US = 1,
EU = 2,
}
3 changes: 3 additions & 0 deletions RateLimiter/Models/RequestContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace RateLimiter.Models;

public record RequestContext(string ResourceId, string AccessToken, Region ClientRegion = Region.Unknown);
3 changes: 3 additions & 0 deletions RateLimiter/Models/RequestKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace RateLimiter.Models;

public record RequestKey(string ResourceId, string Token);
29 changes: 29 additions & 0 deletions RateLimiter/RequestRateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace RateLimiter;

using System.Threading.Tasks;
using RateLimiter.Models;
using RateLimiter.Store;

public class RequestRateLimiter : IRateLimiter
{
private readonly IRateLimitRuleStore _ruleStore;

public RequestRateLimiter(IRateLimitRuleStore ruleStore)
{
_ruleStore = ruleStore;
}

public async Task<bool> IsRequestAllowedAsync(RequestContext request)
{
var rules = _ruleStore.GetRules(request.ResourceId);
foreach (var rule in rules)
{
if (!await rule.IsRequestAllowedAsync(request))
{
return false;
}
}

return true;
}
}
61 changes: 61 additions & 0 deletions RateLimiter/Rules/FixedWindowRateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace RateLimiter.Rules;

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using RateLimiter.Models;

public class FixedWindowRateLimitRule : IRateLimitRule
{

private class FixedWindow
{
private int _count;

public FixedWindow(DateTime timestamp, int count)
{
Timestamp = timestamp;
_count = count;
}

public DateTime Timestamp { get; }
public int Count => _count;

public void IncrementCount() => Interlocked.Increment(ref _count);
};


private readonly TimeSpan _windowDuration;
private readonly int _maxRequestsCount;
private readonly ConcurrentDictionary<RequestKey, FixedWindow> _clientWindows = new();

public FixedWindowRateLimitRule(TimeSpan windowDuration, int maxRequestsCount)
{
_windowDuration = windowDuration;
_maxRequestsCount = maxRequestsCount;
}

public Task<bool> IsRequestAllowedAsync(RequestContext request)
{
var requestKey = new RequestKey(request.ResourceId, request.AccessToken);
var now = DateTime.UtcNow;
var fixedWindow = _clientWindows.GetValueOrDefault(requestKey);

if (fixedWindow is null || fixedWindow.Timestamp < now - _windowDuration)
{
fixedWindow = new FixedWindow(now, 0);
_clientWindows[requestKey] = fixedWindow;
}

if (fixedWindow.Count >= _maxRequestsCount)
{
return Task.FromResult(false);
}

fixedWindow.IncrementCount();

return Task.FromResult(true);
}
}
9 changes: 9 additions & 0 deletions RateLimiter/Rules/IRateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace RateLimiter.Rules;

using System.Threading.Tasks;
using RateLimiter.Models;

public interface IRateLimitRule
{
Task<bool> IsRequestAllowedAsync(RequestContext request);
}
23 changes: 23 additions & 0 deletions RateLimiter/Rules/RegionRateLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace RateLimiter.Rules;

using System.Threading.Tasks;
using RateLimiter.Models;

public class RegionRateLimitRule : IRateLimitRule
{
private readonly Region _region;
private readonly IRateLimitRule _rateLimitRule;

public RegionRateLimitRule(Region region, IRateLimitRule rateLimitRule)
{
_region = region;
_rateLimitRule = rateLimitRule;
}

public Task<bool> IsRequestAllowedAsync(RequestContext request)
{
return _region == request.ClientRegion
? _rateLimitRule.IsRequestAllowedAsync(request)
: Task.FromResult(true);
}
}
Loading