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

Andrew Webster #262

Closed
wants to merge 2 commits 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
383 changes: 383 additions & 0 deletions RateLimiter.Tests/Middleware/CustomRateLimiterMiddleware.UnitTests.cs

Large diffs are not rendered by default.

29 changes: 22 additions & 7 deletions RateLimiter.Tests/RateLimiter.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="HttpContextMoq" Version="1.6.0" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\RateLimiter\RateLimiter.csproj" />
</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" />
<Using Include="Xunit" />
</ItemGroup>
</Project>

</Project>
13 changes: 0 additions & 13 deletions RateLimiter.Tests/RateLimiterTest.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using RateLimiter.Enums;
using RateLimiter.Models;
using RateLimiter.Rules;

namespace RateLimiter.Tests.Rules;

public class TooCloseToLastRequestRateLimitRuleUnitTests
{
private readonly TooCloseToLastRequestRateLimitRuleApplicator rule;

public TooCloseToLastRequestRateLimitRuleUnitTests()
{
rule = new TooCloseToLastRequestRateLimitRuleApplicator();
}

[Fact]
public void ApplyShouldReturnSuccessWhenThereAreNoRequestsForTheGivenKey()
{
// arrange
var configuration = new RateLimitRuleConfiguration
{
TooCloseToLastRequestRateLimitRuleMinimumTimeBetweenRequestsInSeconds = 10,
Type = RateLimitRules.TooCloseToLastRequest
};

// act
var result = rule.Apply(configuration, []);

// assert
Assert.Equal(RateLimitResultStatuses.Success, result.Status);
}

[Fact]
public void ApplyShouldReturnSuccessWhenTheLastRequestIsFarEnoughInThePast()
{
// arrange
var configuration = new RateLimitRuleConfiguration
{
TooCloseToLastRequestRateLimitRuleMinimumTimeBetweenRequestsInSeconds = 10,
Type = RateLimitRules.TooCloseToLastRequest
};

// act
var result = rule.Apply(configuration, [DateTime.UtcNow.AddSeconds(-15)]);

// assert
Assert.Equal(RateLimitResultStatuses.Success, result.Status);
}

[Fact]
public void ApplyShouldReturnFailureWhenTheLastRequestIsNotFarEnoughInThePast()
{
// arrange
var configuration = new RateLimitRuleConfiguration
{
TooCloseToLastRequestRateLimitRuleMinimumTimeBetweenRequestsInSeconds = 10,
Type = RateLimitRules.TooCloseToLastRequest
};

// act
var result = rule.Apply(configuration, [DateTime.UtcNow.AddSeconds(-5)]);

// assert
Assert.Equal(RateLimitResultStatuses.Failure, result.Status);
Assert.Equal("Please allow at least 10 seconds between requests.", result.Message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using RateLimiter.Enums;
using RateLimiter.Models;
using RateLimiter.Rules;

namespace RateLimiter.Tests.Rules;

public class TooManyInTimeSpanRateLimitRuleUnitTests
{ private readonly TooManyInTimeSpanRateLimitRuleApplicator rule;

public TooManyInTimeSpanRateLimitRuleUnitTests()
{
rule = new TooManyInTimeSpanRateLimitRuleApplicator();
}

[Fact]
public void ApplyShouldReturnSuccessWhenThereAreNoRequestsForTheGivenKey()
{
// arrange
var configuration = new RateLimitRuleConfiguration
{
TooManyInTimeSpanRateLimitRuleMaximumRequestsInTimeSpan = 10,
TooManyInTimeSpanRateLimitRuleTimeSpanInSeconds = 10,
Type = RateLimitRules.TooManyInTimeSpan
};

// act
var result = rule.Apply(configuration, []);

// assert
Assert.Equal(RateLimitResultStatuses.Success, result.Status);
}

[Fact]
public void ApplyShouldReturnSuccessWhenThereAreFewerThanTheAllowedNumberOfRequestsMadeInTheConfiguredTimeFrame()
{
// arrange
var configuration = new RateLimitRuleConfiguration
{
TooManyInTimeSpanRateLimitRuleMaximumRequestsInTimeSpan = 3,
TooManyInTimeSpanRateLimitRuleTimeSpanInSeconds = 10,
Type = RateLimitRules.TooManyInTimeSpan
};

// act
var result = rule.Apply(configuration, [DateTime.UtcNow.AddSeconds(-1), DateTime.UtcNow.AddSeconds(-2)]);

// assert
Assert.Equal(RateLimitResultStatuses.Success, result.Status);
}

[Fact]
public void ApplyShouldReturnFailureWhenThereAreMoreThanTwoRequestsInTheLastMinute()
{
// arrange
var configuration = new RateLimitRuleConfiguration
{
TooManyInTimeSpanRateLimitRuleMaximumRequestsInTimeSpan = 3,
TooManyInTimeSpanRateLimitRuleTimeSpanInSeconds = 60,
Type = RateLimitRules.TooManyInTimeSpan
};

// act
var result = rule.Apply(configuration, [DateTime.UtcNow.AddSeconds(-30), DateTime.UtcNow.AddSeconds(-15), DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow.AddSeconds(-5)]);

// assert
Assert.Equal(RateLimitResultStatuses.Failure, result.Status);
Assert.Equal("Only 3 requests may be made within 60 seconds.", result.Message);
}
}
2 changes: 2 additions & 0 deletions RateLimiter.Tests/global.usings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
global using Moq;
global using HttpContextMoq;
24 changes: 15 additions & 9 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.34723.18
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "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", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}"
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("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WeatherForecastAPI", "WeatherForecastAPI\WeatherForecastAPI.csproj", "{7F59C3C4-044D-46A2-A383-50EABE2F5CE9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{143CE499-B572-4066-BC62-7B555A641BFF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -22,10 +24,14 @@ Global
{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}.Release|Any CPU.Build.0 = Release|Any CPU
{C4F9249B-010E-46BE-94B8-DD20D82F1E60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
{7F59C3C4-044D-46A2-A383-50EABE2F5CE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7F59C3C4-044D-46A2-A383-50EABE2F5CE9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7F59C3C4-044D-46A2-A383-50EABE2F5CE9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7F59C3C4-044D-46A2-A383-50EABE2F5CE9}.Release|Any CPU.Build.0 = Release|Any CPU
{143CE499-B572-4066-BC62-7B555A641BFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{143CE499-B572-4066-BC62-7B555A641BFF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{143CE499-B572-4066-BC62-7B555A641BFF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{143CE499-B572-4066-BC62-7B555A641BFF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
7 changes: 7 additions & 0 deletions RateLimiter/Enums/RateLimitResultStatuses.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RateLimiter.Enums;

public enum RateLimitResultStatuses
{
Success,
Failure
}
7 changes: 7 additions & 0 deletions RateLimiter/Enums/RateLimitRules.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace RateLimiter.Enums;

public enum RateLimitRules
{
TooManyInTimeSpan = 1,
TooCloseToLastRequest = 2
}
104 changes: 104 additions & 0 deletions RateLimiter/Middleware/CustomRateLimiterMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using Microsoft.AspNetCore.Http;
using RateLimiter.Models;
using RateLimiter.Persistence;
using RateLimiter.RuleApplicators;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace RateLimiter.Middleware;

public class CustomRateLimiterMiddleware(RequestDelegate next)
{
private readonly RequestDelegate _next = next;

public async Task InvokeAsync(HttpContext context, IEnumerable<IApplyARateLimit> rateLimiters, IProvideAccessToCachedData repository)
{
// Try to get the x-authentication-key header
if (!context.Request.Headers.TryGetValue("x-authentication-key", out var headerValues))
{
// If there is no x-authentication-key in the headers then the request is unauthorized so stop processing this check as well as any future middleware
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}

var keyValue = headerValues.First();
var resource = context.Request.Path;

if (keyValue is null)
{
// If there is no x-authentication-key in the headers then the request is unauthorized so stop processing this check as well as any future middleware
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}

// This applies a separate rate limit per resource; to apply the rate limits only to the client (key) just set `key` to be equal to `keyValue`
var key = $"{keyValue}_{resource}";

// Each client/resource combo may have different rules to apply, so fetch those rules from persisted storage
var ruleConfigurations = repository.GetRuleConfigurationsByKey(key);
// Each client/resource combo has a dedicated locking resource to avoid multithread collisions where the list of past requests is modified while being evaluated
repository.Lock(key);
// Retrieve the list of past requests only once for each client/resource combo so that all past requests can be evaluated by all applicable rules
var requests = repository.GetRequestsByKey(key);
bool shouldContinue = true;

// Iterate the rules and apply them one at a time
foreach (var ruleConfiguration in ruleConfigurations)
{
// Find the (injected) rate limiter that matches the type of the current configuration in the iteration
var rateLimiter = rateLimiters.FirstOrDefault(rl => rl.Type == ruleConfiguration.Type);

if (rateLimiter is null)
{
// If there is no rate limiter matching the rule then something has gone wrong and there's no way to proceed so stop processing
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
shouldContinue = false;
break;
}

RateLimitResult result;
try
{
// Apply the rate limiter rule
result = rateLimiter.Apply(ruleConfiguration, requests);
}
catch (Exception ex)
{
// TODO: log the message somewhere more persisted and valuable than Console
Console.WriteLine(ex.Message);
// If something went wrong on our end during the check for rate limiting then assume the rate limit(s) has/have not been met and allow the request to continue
continue;
}

if (!result.IsSuccessful)
{
// If the status code is anything other than Success, disallow the request by writing 429 as the status code
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
// Provide more contextual information back to the caller indicating the reason the request was rejected (although 429 is clear, we may be able to tell them to wait x minutes or this is y requests too many in z time frame)
await context.Response.WriteAsJsonAsync(new { errorMessage = result.Message });
shouldContinue = false;
break;
}
}

if (shouldContinue)
{
// If none of the rate limit checks caused issues, save this request for future checks to reference
// Add the request before unlocking the resource to make sure this request is checked for any pending requests after the resource is unlocked
repository.AddRequestByKey(key);
// Keep the list of past requests as small as possible each time a new request is received
repository.RemoveOldRequests(key);
}

// Release the lock on this resource for this client so the next request in the queue can be processed
repository.Unlock(key);

if (shouldContinue)
{
// If none of the rate limit checks caused issues, continue to process additional middleware and the rest of the request
await _next(context);
}
}
}
28 changes: 28 additions & 0 deletions RateLimiter/Middleware/CustomRateLimiterMiddlewareExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using RateLimiter.Persistence;
using RateLimiter.RuleApplicators;
using RateLimiter.Rules;

namespace RateLimiter.Middleware;

public static class CustomRateLimiterMiddlewareExtensions
{
public static IServiceCollection AddCustomRateLimiterRules(this IServiceCollection services)
{
services.AddScoped<IApplyARateLimit, TooManyInTimeSpanRateLimitRuleApplicator>();
services.AddScoped<IApplyARateLimit, TooCloseToLastRequestRateLimitRuleApplicator>();

// TODO: Change to an implementation that uses RedisCache or some other distributed caching mechanism as the default provider
services.AddSingleton<IProvideAccessToCachedData, InMemoryCacheRepository>();
// TODO: Change to an implementation that gets data from a database or other persisted storage
services.AddSingleton<IProvideAccessToConfigurationData, StaticConfigurationRepository>();

return services;
}

public static IApplicationBuilder UseCustomRateLimiter(this IApplicationBuilder app)
{
return app.UseMiddleware<CustomRateLimiterMiddleware>();
}
}
Loading