Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Test: Knyazev Dmitry #199

Closed
wants to merge 1 commit into from
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
40 changes: 19 additions & 21 deletions README.md
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.
11 changes: 8 additions & 3 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
<ProjectReference Include="..\RateLimiter\RateLimiter.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<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="Moq" Version="4.18.4" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
</Project>
13 changes: 0 additions & 13 deletions RateLimiter.Tests/RateLimiterTest.cs

This file was deleted.

163 changes: 163 additions & 0 deletions RateLimiter.Tests/RateLimitingServiceTests.cs
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);
}
}
17 changes: 13 additions & 4 deletions RateLimiter.sln
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26730.15
# Visual Studio Version 17
VisualStudioVersion = 17.9.34902.65
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B206889-9841-4B5E-B79B-D5B2610CCCFF}"
ProjectSection(SolutionItems) = preProject
README.md = README.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleSample", "Samples\SimpleSample\SimpleSample.csproj", "{DCC2194E-DB5B-4823-9347-E8A6728C59EB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -26,10 +28,17 @@ Global
{C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Release|Any CPU.Build.0 = Release|Any CPU
{DCC2194E-DB5B-4823-9347-E8A6728C59EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DCC2194E-DB5B-4823-9347-E8A6728C59EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DCC2194E-DB5B-4823-9347-E8A6728C59EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DCC2194E-DB5B-4823-9347-E8A6728C59EB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{DCC2194E-DB5B-4823-9347-E8A6728C59EB} = {9B206889-9841-4B5E-B79B-D5B2610CCCFF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {67D05CB6-8603-4C96-97E5-C6CEFBEC6134}
EndGlobalSection
Expand Down
23 changes: 23 additions & 0 deletions RateLimiter/Extensions/ClientRequestIdentityExtensions.cs
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);
}
}
11 changes: 11 additions & 0 deletions RateLimiter/IRateLimitingService.cs
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);
}
15 changes: 15 additions & 0 deletions RateLimiter/Models/ClientRequestIdentity.cs
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; }
}
22 changes: 22 additions & 0 deletions RateLimiter/Models/RateLimitCounter.cs
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; }
}
20 changes: 20 additions & 0 deletions RateLimiter/Models/RateLimitRule.cs
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; }
}
11 changes: 10 additions & 1 deletion RateLimiter/RateLimiter.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,13 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="RateLimiter.Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
Loading