Skip to content

Commit

Permalink
rate-limiter
Browse files Browse the repository at this point in the history
  • Loading branch information
BRIMIT\sla committed Jun 8, 2024
1 parent 73f3a7c commit f519828
Show file tree
Hide file tree
Showing 10 changed files with 380 additions and 16 deletions.
5 changes: 2 additions & 3 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2" />
<PackageReference Include="xunit" Version="2.8.1" />
</ItemGroup>
</Project>
</Project>
171 changes: 171 additions & 0 deletions RateLimiter.Tests/RateLimiterControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
using System;
using Microsoft.AspNetCore.Mvc;
using RateLimiter.Api;
using Xunit;

namespace RateLimiter.Tests;

public class RateLimiterControllerTests
{
private readonly RateLimiterApiController _controller;

public RateLimiterControllerTests()
{
_controller = new RateLimiterApiController();
}

[Theory]
[InlineData(1)]
[InlineData(9)]
public void GetFixedLimit_AllowsRequest_ReturnsOk(int rateLimit)
{
// Arrange
const string clientId = "testClient1";

// Act
IActionResult? result = null;
for (int i = 0; i < rateLimit; i++) // <=10 calls in a min
{
result = _controller.GetFixedLimit(clientId);
}

// Assert
Assert.IsType<OkResult>(result);
Assert.Equal(200, ((OkResult)result).StatusCode);
}

[Fact]
public void GetFixedLimit_BlocksRequest_ReturnsTooManyRequests()
{
// Arrange
const string clientId = "testClient1";

// Act
for (int i = 0; i < 10; i++)
{
_controller.GetFixedLimit(clientId);
}
var result = _controller.GetFixedLimit(clientId); // 11th call in a min

// Assert
Assert.IsType<ObjectResult>(result);
Assert.Equal(429, ((ObjectResult)result).StatusCode);
Assert.Equal(RateLimiterApiController.TooManyRequestsMessage, ((ObjectResult)result).Value);
}

[Fact]
public void GetSlidingLimit_AllowsRequest_ReturnsOk()
{
// Arrange
const string clientId = "testClient2";

// Act
_controller.GetSlidingLimit(clientId);

var endTime = DateTime.UtcNow.AddSeconds(5); // delay 5 sec
while (DateTime.UtcNow < endTime) { }

var result = _controller.GetSlidingLimit(clientId);

// Assert
Assert.IsType<OkResult>(result);
Assert.Equal(200, ((OkResult)result).StatusCode);
}

[Fact]
public void GetSlidingLimit_BlocksRequest_ReturnsTooManyRequests()
{
// Arrange
const string clientId = "testClient2";

// Act
IActionResult? result = null;
for (int i = 0; i < 2; i++) // 2 calls in 10 sec
{
result = _controller.GetSlidingLimit(clientId);
}

// Assert
Assert.IsType<ObjectResult>(result);
Assert.Equal(429, ((ObjectResult)result).StatusCode);
Assert.Equal(RateLimiterApiController.TooManyRequestsMessage, ((ObjectResult)result).Value);
}

[Theory]
[InlineData(1)]
[InlineData(9)]
public void GetRegionBasedLimit_US_AllowsRequest_ReturnsOk(int rateLimit)
{
// Arrange
const string clientId = "US_testClient3";

// Act
IActionResult? result = null;
for (int i = 0; i < rateLimit; i++) // <=10 calls in a min
{
result = _controller.GetRegionBasedLimit(clientId);
}

// Assert
Assert.IsType<OkResult>(result);
Assert.Equal(200, ((OkResult)result).StatusCode);
}

[Fact]
public void GetRegionBasedLimit_US_BlocksRequest_ReturnsTooManyRequests()
{
// Arrange
const string clientId = "US_testClient3";

// Act
for (int i = 0; i < 20; i++)
{
_controller.GetRegionBasedLimit(clientId);
}

var result = _controller.GetRegionBasedLimit(clientId); // 21th call in a min

// Assert
Assert.IsType<ObjectResult>(result);
Assert.Equal(429, ((ObjectResult)result).StatusCode);
Assert.Equal(RateLimiterApiController.TooManyRequestsMessage, ((ObjectResult)result).Value);
}

[Fact]
public void GetRegionBasedLimit_EU_AllowsRequest_ReturnsOk()
{
// Arrange
const string clientId = "EU_testClient3";

// Act
_controller.GetRegionBasedLimit(clientId);

var endTime = DateTime.UtcNow.AddSeconds(5); // delay 5 sec
while (DateTime.UtcNow < endTime) { }

var result = _controller.GetRegionBasedLimit(clientId);

// Assert
Assert.IsType<OkResult>(result);
Assert.Equal(200, ((OkResult)result).StatusCode);
}

[Fact]
public void GetRegionBasedLimit_EU_BlocksRequest_ReturnsTooManyRequests()
{
// Arrange
const string clientId = "EU_testClient3";

// Act
IActionResult? result = null;
for (int i = 0; i < 2; i++) // 2 calls in 10 sec
{
result = _controller.GetRegionBasedLimit(clientId);
}

// Assert
Assert.IsType<ObjectResult>(result);
Assert.Equal(429, ((ObjectResult)result).StatusCode);
Assert.Equal(RateLimiterApiController.TooManyRequestsMessage, ((ObjectResult)result).Value);
}
}
13 changes: 0 additions & 13 deletions RateLimiter.Tests/RateLimiterTest.cs

This file was deleted.

49 changes: 49 additions & 0 deletions RateLimiter/Api/RateLimiterApiController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Mvc;

namespace RateLimiter.Api
{
public class RateLimiterApiController : ControllerBase
{
private readonly RateLimiter _rateLimiter;

public const string TooManyRequestsMessage = "Too many requests";

public RateLimiterApiController()
{
_rateLimiter = RateLimiter.Instance;
}

[HttpGet("fixedLimit")]
public IActionResult GetFixedLimit(string clientId)
{
if (!_rateLimiter.IsRequestAllowed(clientId, "fixedLimit"))
{
return StatusCode(429, TooManyRequestsMessage);
}

return Ok();
}

[HttpGet("slidingLimit")]
public IActionResult GetSlidingLimit(string clientId)
{
if (!_rateLimiter.IsRequestAllowed(clientId, "slidingLimit"))
{
return StatusCode(429, TooManyRequestsMessage);
}

return Ok();
}

[HttpGet("regionBasedLimit")]
public IActionResult GetRegionBasedLimit(string clientId)
{
if (!_rateLimiter.IsRequestAllowed(clientId, "regionLimit"))
{
return StatusCode(429, TooManyRequestsMessage);
}

return Ok();
}
}
}
47 changes: 47 additions & 0 deletions RateLimiter/RateLimiter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using RateLimiter.Rules;
using RateLimiter.Rules.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;

namespace RateLimiter
{
public class RateLimiter : Dictionary<string, List<ILimitRule>>
{
private static readonly Lazy<RateLimiter> RateLimiterInstance = new(() => new RateLimiter());

private RateLimiter()
{
LoadRules();
}

public static RateLimiter Instance => RateLimiterInstance.Value;

public void Add(string ruleName, ILimitRule rule)
{
if (!this.ContainsKey(ruleName))
{
this[ruleName] = new List<ILimitRule>();
}

this[ruleName].Add(rule);
}

public bool IsRequestAllowed(string clientId, string ruleName)
{
return !this.ContainsKey(ruleName) ||
this[ruleName].All(rule => rule.IsRequestAllowed(clientId, ruleName));
}

private void LoadRules()
{
this.Add("fixedLimit", new FixedLimitRule(10, TimeSpan.FromMinutes(1)));
this.Add("slidingLimit", new SlidingLimitRule(TimeSpan.FromSeconds(5)));
this.Add(
"regionLimit",
new RegionBasedRule(
new FixedLimitRule(20, TimeSpan.FromMinutes(1)),
new SlidingLimitRule(TimeSpan.FromSeconds(5))));
}
}
}
4 changes: 4 additions & 0 deletions RateLimiter/RateLimiter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.2.5" />
</ItemGroup>
</Project>
39 changes: 39 additions & 0 deletions RateLimiter/Rules/FixedLimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using RateLimiter.Rules.Interfaces;

namespace RateLimiter.Rules
{
public class FixedLimitRule : ILimitRule
{
private readonly int _limit;
private readonly TimeSpan _timeSpan;
private readonly Dictionary<string, List<DateTime>> _clientRequests = new();

public FixedLimitRule(int limit, TimeSpan timeSpan)
{
_limit = limit;
_timeSpan = timeSpan;
}

public bool IsRequestAllowed(string clientId, string ruleName)
{
if (!_clientRequests.ContainsKey(clientId))
{
_clientRequests[clientId] = new List<DateTime>();
}

var now = DateTime.UtcNow;
_clientRequests[clientId].RemoveAll(entry => now - entry > _timeSpan);

if (_clientRequests[clientId].Count >= _limit)
{
return false;
}

_clientRequests[clientId].Add(now);

return true;
}
}
}
7 changes: 7 additions & 0 deletions RateLimiter/Rules/Interfaces/ILimitRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RateLimiter.Rules.Interfaces
{
public interface ILimitRule
{
bool IsRequestAllowed(string clientId, string ruleName);
}
}
30 changes: 30 additions & 0 deletions RateLimiter/Rules/RegionBasedRule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using RateLimiter.Rules.Interfaces;

namespace RateLimiter.Rules
{
public class RegionBasedRule : ILimitRule
{
private readonly ILimitRule _usRule;
private readonly ILimitRule _euRule;

public RegionBasedRule(ILimitRule usRule, ILimitRule euRule)
{
_usRule = usRule;
_euRule = euRule;
}

public bool IsRequestAllowed(string clientId, string ruleName)
{
string region = GetRegionFromClientId(clientId);

return region switch
{
"US" => _usRule.IsRequestAllowed(clientId, ruleName),
"EU" => _euRule.IsRequestAllowed(clientId, ruleName),
_ => true
};
}

private static string GetRegionFromClientId(string clientId) => clientId.StartsWith("US") ? "US" : "EU";
}
}
Loading

0 comments on commit f519828

Please sign in to comment.