-
Notifications
You must be signed in to change notification settings - Fork 232
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add RateLimitingService + tests + sample
- Loading branch information
dmitry.knyazev
committed
Jul 14, 2024
1 parent
73f3a7c
commit 19f0b37
Showing
26 changed files
with
773 additions
and
42 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,27 +1,25 @@ | ||
**Rate-limiting pattern** | ||
**Rate-limiting pattern** | ||
|
||
Rate limiting involves restricting the number of requests that can be made by a client. | ||
A client is identified with an access token, which is used for every request to a resource. | ||
To prevent abuse of the server, APIs enforce rate-limiting techniques. | ||
Based on the client, the rate-limiting application can decide whether to allow the request to go through or not. | ||
The client makes an API call to a particular resource; the server checks whether the request for this client is within the limit. | ||
If the request is within the limit, then the request goes through. | ||
Otherwise, the API call is restricted. | ||
Based on RateLimiting service from [ocelot repo](https://github.com/ThreeMammals/Ocelot). | ||
You can see an example of using in the [SimpleSample](https://github.com/DAKnyazev/rate-limiter/tree/master/Samples/SimpleSample) project. | ||
|
||
Some examples of request-limiting rules (you could imagine any others) | ||
* X requests per timespan; | ||
* a certain timespan passed since the last call; | ||
* for US-based tokens, we use X requests per timespan, for EU-based - certain timespan passed since the last call. | ||
## Usage | ||
|
||
The goal is to design a class(-es) that manage rate limits for every provided API resource by a set of provided *configurable and extendable* rules. For example, for one resource you could configure the limiter to use Rule A, for another one - Rule B, for a third one - both A + B, etc. Any combinations of rules should be possible, keep this fact in mind when designing the classes. | ||
To access rate limiting counter you need to resolve service 'IRateLimitingService' | ||
|
||
We're more interested in the design itself than in some smart and tricky rate limiting algorithm. There is no need to use neither database (in-memory storage is fine) nor any web framework. Do not waste time on preparing complex environment, reusable class library covered by a set of tests is more than enough. | ||
### With memory cache | ||
```csharp | ||
.AddRateLimitingServiceWithMemoryCache(); | ||
``` | ||
|
||
There is a Test Project set up for you to use. You are welcome to create your own test project and use whatever test runner you would like. | ||
### With distributed cache | ||
```csharp | ||
.AddRateLimitingServiceWithDistributedCache(); | ||
``` | ||
|
||
You are welcome to ask any questions regarding the requirements - treat us as product owners/analysts/whoever who knows the business. | ||
Should you have any questions or concerns, submit them as a [GitHub issue](https://github.com/crexi-dev/rate-limiter/issues). | ||
|
||
You should [fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) the project, and [create a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) once you are finished. | ||
|
||
Good luck! | ||
### With custom cache | ||
```csharp | ||
.AddRateLimitingServiceCore() | ||
.AddSingleton<IRateLimitStorageService, CustomRateLimitStorageService>(); | ||
``` | ||
You need to implement IRateLimitStorageService by yourself. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
using System; | ||
using FluentAssertions; | ||
using Moq; | ||
using RateLimiter.Extensions; | ||
using RateLimiter.Models; | ||
using RateLimiter.Services; | ||
using RateLimiter.Services.Interfaces; | ||
using Xunit; | ||
|
||
namespace RateLimiter.Tests; | ||
|
||
public class RateLimitingServiceTests | ||
{ | ||
private const long MaxRequestsPerPeriod = 3; | ||
private static readonly TimeSpan Period = TimeSpan.FromSeconds(5); | ||
private static readonly DateTime Now = DateTime.Now; | ||
|
||
private readonly IRateLimitingService _rateLimitingService; | ||
|
||
private readonly Mock<IRateLimitStorageService> _rateLimitStorageServiceMock; | ||
private readonly Mock<IDateTimeProvider> _dateTimeProviderMock; | ||
private readonly RateLimitRule _rateLimitRule; | ||
|
||
public RateLimitingServiceTests() | ||
{ | ||
_rateLimitStorageServiceMock = new Mock<IRateLimitStorageService>(); | ||
_dateTimeProviderMock = new Mock<IDateTimeProvider>(MockBehavior.Strict); | ||
_dateTimeProviderMock.SetupGet(x => x.UtcNow).Returns(Now); | ||
|
||
_rateLimitingService = new RateLimitingService( | ||
_rateLimitStorageServiceMock.Object, | ||
_dateTimeProviderMock.Object); | ||
|
||
_rateLimitRule = new RateLimitRule(Period, MaxRequestsPerPeriod); | ||
} | ||
|
||
[Fact] | ||
public void GetRateLimitCounter_FirstRequest_ShouldReturnDefaultRateLimitCounter() | ||
{ | ||
// Arrange | ||
var identity = new ClientRequestIdentity(Guid.NewGuid().ToString(), "/create", "POST"); | ||
var key = identity.GetStorageKey(Period); | ||
_rateLimitStorageServiceMock.Setup(x => x.Get(key)).Returns((RateLimitCounter?)null); | ||
|
||
// Act | ||
var result = _rateLimitingService.GetRateLimitCounter(identity, _rateLimitRule); | ||
|
||
// Assert | ||
result.StartedAt.Should().Be(Now); | ||
result.ExceededAt.Should().BeNull(); | ||
result.TotalRequests.Should().Be(1); | ||
_rateLimitStorageServiceMock.VerifyAll(); | ||
_rateLimitStorageServiceMock | ||
.Verify(x => | ||
x.Set( | ||
key, | ||
It.Is<RateLimitCounter>(c => | ||
c.StartedAt == Now | ||
&& c.ExceededAt.HasValue == false | ||
&& c.TotalRequests == 1), | ||
Period), | ||
Times.Once); | ||
_rateLimitStorageServiceMock.VerifyNoOtherCalls(); | ||
} | ||
|
||
[Fact] | ||
public void GetRateLimitCounter_OneRequestUntilBan_ShouldReturnRateLimitCounterWithMaxRequestsPerPeriod() | ||
{ | ||
// Arrange | ||
var identity = new ClientRequestIdentity(Guid.NewGuid().ToString(), "/create", "POST"); | ||
var key = identity.GetStorageKey(Period); | ||
var halfOfPeriod = Period / 2; | ||
var startedAt = Now.Add(-halfOfPeriod); | ||
var exceededAt = Now.Add(halfOfPeriod); | ||
_rateLimitStorageServiceMock | ||
.Setup(x => x.Get(key)) | ||
.Returns(new RateLimitCounter( | ||
startedAt, | ||
exceededAt, | ||
MaxRequestsPerPeriod - 1)); | ||
|
||
// Act | ||
var result = _rateLimitingService.GetRateLimitCounter(identity, _rateLimitRule); | ||
|
||
// Assert | ||
result.StartedAt.Should().Be(startedAt); | ||
result.ExceededAt.Should().Be(exceededAt); | ||
result.TotalRequests.Should().Be(MaxRequestsPerPeriod); | ||
_rateLimitStorageServiceMock.VerifyAll(); | ||
_rateLimitStorageServiceMock | ||
.Verify(x => | ||
x.Set( | ||
key, | ||
It.Is<RateLimitCounter>(c => | ||
c.StartedAt == startedAt | ||
&& c.ExceededAt == exceededAt | ||
&& c.TotalRequests == MaxRequestsPerPeriod), | ||
Period), | ||
Times.Once); | ||
_rateLimitStorageServiceMock.VerifyNoOtherCalls(); | ||
} | ||
|
||
[Fact] | ||
public void GetRateLimitCounter_BanExpired_ShouldReturnDefaultRateLimitCounter() | ||
{ | ||
// Arrange | ||
var identity = new ClientRequestIdentity(Guid.NewGuid().ToString(), "/create", "POST"); | ||
var key = identity.GetStorageKey(Period); | ||
var startedAt = Now.Add(-Period).AddSeconds(-1); | ||
var exceededAt = Now.Add(-Period).AddMilliseconds(-1); | ||
_rateLimitStorageServiceMock | ||
.Setup(x => x.Get(key)) | ||
.Returns(new RateLimitCounter( | ||
startedAt, | ||
exceededAt, | ||
MaxRequestsPerPeriod)); | ||
|
||
// Act | ||
var result = _rateLimitingService.GetRateLimitCounter(identity, _rateLimitRule); | ||
|
||
// Assert | ||
result.StartedAt.Should().Be(Now); | ||
result.ExceededAt.Should().BeNull(); | ||
result.TotalRequests.Should().Be(1); | ||
_rateLimitStorageServiceMock.VerifyAll(); | ||
_rateLimitStorageServiceMock | ||
.Verify(x => | ||
x.Set( | ||
key, | ||
It.Is<RateLimitCounter>(c => | ||
c.StartedAt == Now | ||
&& c.ExceededAt.HasValue == false | ||
&& c.TotalRequests == 1), | ||
Period), | ||
Times.Once); | ||
_rateLimitStorageServiceMock.VerifyNoOtherCalls(); | ||
} | ||
|
||
[Fact] | ||
public void GetRateLimitCounter_BanNotExpired_ShouldReturnRateLimitCounterWithBan() | ||
{ | ||
// Arrange | ||
var identity = new ClientRequestIdentity(Guid.NewGuid().ToString(), "/create", "POST"); | ||
var key = identity.GetStorageKey(Period); | ||
var startedAt = Now.Add(-Period).AddSeconds(-1); | ||
var exceededAt = Now.Add(-Period).AddMilliseconds(1); | ||
|
||
_rateLimitStorageServiceMock | ||
.Setup(x => x.Get(key)) | ||
.Returns(new RateLimitCounter( | ||
startedAt, | ||
exceededAt, | ||
MaxRequestsPerPeriod)); | ||
|
||
// Act | ||
var result = _rateLimitingService.GetRateLimitCounter(identity, _rateLimitRule); | ||
|
||
// Assert | ||
result.StartedAt.Should().Be(startedAt); | ||
result.ExceededAt.Should().Be(exceededAt); | ||
result.TotalRequests.Should().Be(MaxRequestsPerPeriod + 1); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
using System; | ||
using System.Security.Cryptography; | ||
using System.Text; | ||
using RateLimiter.Models; | ||
|
||
namespace RateLimiter.Extensions; | ||
|
||
internal static class ClientRequestIdentityExtensions | ||
{ | ||
internal static string GetStorageKey(this ClientRequestIdentity identity, TimeSpan period) | ||
{ | ||
var key = $"{identity.ClientId}_{period}_{identity.HttpVerb}_{identity.Path}"; | ||
var idBytes = Encoding.UTF8.GetBytes(key); | ||
|
||
byte[] hashBytes; | ||
using (var algorithm = SHA1.Create()) | ||
{ | ||
hashBytes = algorithm.ComputeHash(idBytes); | ||
} | ||
|
||
return BitConverter.ToString(hashBytes).Replace("-", string.Empty); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
using RateLimiter.Models; | ||
|
||
namespace RateLimiter; | ||
|
||
/// <summary> | ||
/// A service, providing information about count of requests that clients made to the endpoint | ||
/// </summary> | ||
public interface IRateLimitingService | ||
{ | ||
RateLimitCounter GetRateLimitCounter(ClientRequestIdentity identity, RateLimitRule rule); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
namespace RateLimiter.Models; | ||
|
||
public sealed class ClientRequestIdentity | ||
{ | ||
public ClientRequestIdentity(string clientId, string path, string httpverb) | ||
{ | ||
ClientId = clientId; | ||
Path = path; | ||
HttpVerb = httpverb; | ||
} | ||
|
||
public string ClientId { get; } | ||
public string Path { get; } | ||
public string HttpVerb { get; } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
using System; | ||
|
||
namespace RateLimiter.Models; | ||
|
||
/// <summary> | ||
/// Stores the initial access time and the numbers of calls made from that point. | ||
/// </summary> | ||
public struct RateLimitCounter | ||
{ | ||
public RateLimitCounter(DateTime startedAt, DateTime? exceededAt, long totalRequests) | ||
{ | ||
StartedAt = startedAt; | ||
ExceededAt = exceededAt; | ||
TotalRequests = totalRequests; | ||
} | ||
|
||
public DateTime StartedAt { get; } | ||
|
||
public DateTime? ExceededAt { get; } | ||
|
||
public long TotalRequests { get; set; } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
using System; | ||
|
||
namespace RateLimiter.Models; | ||
|
||
public sealed class RateLimitRule | ||
{ | ||
public RateLimitRule(TimeSpan period, long limit) | ||
{ | ||
Period = period; | ||
|
||
if (limit < 1) | ||
throw new ArgumentOutOfRangeException($"{nameof(Limit)} must be positive."); | ||
|
||
Limit = limit; | ||
} | ||
|
||
public TimeSpan Period { get; } | ||
|
||
public long Limit { get; } | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.