Skip to content

Commit 450671c

Browse files
authored
Make RateLimitingMiddleware endpoint aware (#42417)
* Add IEndpointConventionBuilder extensions for RateLimitingMiddleware * Some changes * More * Rename * More * More fixes * More stuff * Example of activating * Changes * More stuff * Name change * Fix some stuff * Fixup * Add some tests * More tests * Renames * Minor * Some feedback * No more reflection * Try to satisfy linuc * Suppress linker warning * Fix linkability bug * Add sample project (not yet usable) * Fix sample, fix some feedback * Feedback * Some feedback * Different key strategy * New DefaultKeyType strategy * HashCode.Combine * Feedback again * Small fixes * Update compilah * Feedbacks * Fix comment * Feedbock
1 parent 048a3fb commit 450671c

31 files changed

+1409
-127
lines changed

AspNetCore.sln

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-

21
Microsoft Visual Studio Solution File, Format Version 12.00
32
# Visual Studio Version 17
43
VisualStudioVersion = 17.0.31606.5
@@ -1736,6 +1735,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CustomElements", "CustomEle
17361735
EndProject
17371736
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Templates.Blazor.WebAssembly.Tests", "src\ProjectTemplates\test\Templates.Blazor.WebAssembly.Tests\Templates.Blazor.WebAssembly.Tests.csproj", "{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9}"
17381737
EndProject
1738+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimitingSample", "src\Middleware\RateLimiting\samples\RateLimitingSample\RateLimitingSample.csproj", "{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}"
1739+
EndProject
17391740
Global
17401741
GlobalSection(SolutionConfigurationPlatforms) = preSolution
17411742
Debug|Any CPU = Debug|Any CPU
@@ -10417,6 +10418,22 @@ Global
1041710418
{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9}.Release|x64.Build.0 = Release|Any CPU
1041810419
{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9}.Release|x86.ActiveCfg = Release|Any CPU
1041910420
{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9}.Release|x86.Build.0 = Release|Any CPU
10421+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
10422+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
10423+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|arm64.ActiveCfg = Debug|Any CPU
10424+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|arm64.Build.0 = Debug|Any CPU
10425+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|x64.ActiveCfg = Debug|Any CPU
10426+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|x64.Build.0 = Debug|Any CPU
10427+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|x86.ActiveCfg = Debug|Any CPU
10428+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|x86.Build.0 = Debug|Any CPU
10429+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
10430+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|Any CPU.Build.0 = Release|Any CPU
10431+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|arm64.ActiveCfg = Release|Any CPU
10432+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|arm64.Build.0 = Release|Any CPU
10433+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x64.ActiveCfg = Release|Any CPU
10434+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x64.Build.0 = Release|Any CPU
10435+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x86.ActiveCfg = Release|Any CPU
10436+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x86.Build.0 = Release|Any CPU
1042010437
EndGlobalSection
1042110438
GlobalSection(SolutionProperties) = preSolution
1042210439
HideSolutionNode = FALSE
@@ -11274,6 +11291,7 @@ Global
1127411291
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD} = {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F}
1127511292
{0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF}
1127611293
{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9} = {08D53E58-4AAE-40C4-8497-63EC8664F304}
11294+
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9} = {1D865E78-7A66-4CA9-92EE-2B350E45281F}
1127711295
EndGlobalSection
1127811296
GlobalSection(ExtensibilityGlobals) = postSolution
1127911297
SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}

eng/Versions.props

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
<UsingToolNetFrameworkReferenceAssemblies Condition="'$(OS)' != 'Windows_NT'">true</UsingToolNetFrameworkReferenceAssemblies>
5151
<!-- Disable XLIFF tasks -->
5252
<UsingToolXliff>false</UsingToolXliff>
53+
<!-- Use custom version of Roslyn Compiler -->
54+
<UsingToolMicrosoftNetCompilers>true</UsingToolMicrosoftNetCompilers>
5355
</PropertyGroup>
5456
<!--
5557
@@ -194,6 +196,10 @@
194196
framework in current .NET SDKs.
195197
-->
196198
<MicrosoftNETTestSdkVersion>17.1.0-preview-20211109-03</MicrosoftNETTestSdkVersion>
199+
<!--
200+
Use a compiler new enough to use required keyword
201+
-->
202+
<MicrosoftNetCompilersToolsetVersion>4.4.0-1.22358.14</MicrosoftNetCompilersToolsetVersion>
197203
<!--
198204
Versions of Microsoft.CodeAnalysis packages referenced by analyzers shipped in the SDK.
199205
This need to be pinned since they're used in 3.1 apps and need to be loadable in VS 2019.

src/Middleware/Middleware.slnf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj",
8181
"src\\Middleware\\OutputCaching\\test\\Microsoft.AspNetCore.OutputCaching.Tests.csproj",
8282
"src\\Middleware\\RateLimiting\\src\\Microsoft.AspNetCore.RateLimiting.csproj",
83+
"src\\Middleware\\RateLimiting\\samples\\RateLimitingSample\\RateLimitingSample.csproj",
8384
"src\\Middleware\\RateLimiting\\test\\Microsoft.AspNetCore.RateLimiting.Tests.csproj",
8485
"src\\Middleware\\RequestDecompression\\perf\\Microbenchmarks\\Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj",
8586
"src\\Middleware\\RequestDecompression\\sample\\RequestDecompressionSample.csproj",
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using Microsoft.EntityFrameworkCore;
8+
using System.Threading.RateLimiting;
9+
using Microsoft.AspNetCore.RateLimiting;
10+
using RateLimitingSample;
11+
using Microsoft.Extensions.Logging.Abstractions;
12+
13+
var builder = WebApplication.CreateBuilder(args);
14+
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
15+
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
16+
// Inject an ILogger<SampleRateLimiterPolicy>
17+
builder.Services.AddLogging();
18+
19+
var app = builder.Build();
20+
21+
var todoName = "todoPolicy";
22+
var completeName = "completePolicy";
23+
var helloName = "helloPolicy";
24+
25+
// Define endpoint limiters and a global limiter.
26+
var options = new RateLimiterOptions()
27+
.AddTokenBucketLimiter(todoName, new TokenBucketRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, TimeSpan.FromSeconds(10), 1))
28+
.AddPolicy<string>(completeName, new SampleRateLimiterPolicy(NullLogger<SampleRateLimiterPolicy>.Instance))
29+
.AddPolicy<string, SampleRateLimiterPolicy>(helloName);
30+
// The global limiter will be a concurrency limiter with a max permit count of 10 and a queue depth of 5.
31+
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
32+
{
33+
return RateLimitPartition.CreateConcurrencyLimiter<string>("globalLimiter", key => new ConcurrencyLimiterOptions(10, QueueProcessingOrder.NewestFirst, 5));
34+
});
35+
app.UseRateLimiter(options);
36+
37+
// The limiter on this endpoint allows 1 request every 5 seconds
38+
app.MapGet("/", () => "Hello World!").RequireRateLimiting(helloName);
39+
40+
// Requests to this endpoint will be processed in 10 second intervals
41+
app.MapGet("/todoitems", async (TodoDb db) =>
42+
await db.Todos.ToListAsync())
43+
.RequireRateLimiting(todoName);
44+
45+
// The limiter on this endpoint allows 1 request every 5 seconds
46+
app.MapGet("/todoitems/complete", async (TodoDb db) =>
47+
await db.Todos.Where(t => t.IsComplete).ToListAsync())
48+
.RequireRateLimiting(completeName);
49+
50+
app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
51+
await db.Todos.FindAsync(id)
52+
is Todo todo
53+
? Results.Ok(todo)
54+
: Results.NotFound());
55+
56+
app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
57+
{
58+
db.Todos.Add(todo);
59+
await db.SaveChangesAsync();
60+
61+
return Results.Created($"/todoitems/{todo.Id}", todo);
62+
});
63+
64+
app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
65+
{
66+
var todo = await db.Todos.FindAsync(id);
67+
68+
if (todo is null)
69+
{
70+
return Results.NotFound();
71+
}
72+
73+
todo.Name = inputTodo.Name;
74+
todo.IsComplete = inputTodo.IsComplete;
75+
76+
await db.SaveChangesAsync();
77+
78+
return Results.NoContent();
79+
});
80+
81+
app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
82+
{
83+
if (await db.Todos.FindAsync(id) is Todo todo)
84+
{
85+
db.Todos.Remove(todo);
86+
await db.SaveChangesAsync();
87+
return Results.Ok(todo);
88+
}
89+
90+
return Results.NotFound();
91+
});
92+
93+
app.Run();
94+
95+
class Todo
96+
{
97+
public int Id { get; set; }
98+
public string? Name { get; set; }
99+
public bool IsComplete { get; set; }
100+
}
101+
102+
class TodoDb : DbContext
103+
{
104+
public TodoDb(DbContextOptions<TodoDb> options)
105+
: base(options) { }
106+
107+
public DbSet<Todo> Todos => Set<Todo>();
108+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"$schema": "https://json.schemastore.org/launchsettings.json",
3+
"iisSettings": {
4+
"windowsAuthentication": false,
5+
"anonymousAuthentication": true,
6+
"iisExpress": {
7+
"applicationUrl": "http://localhost:8855",
8+
"sslPort": 44312
9+
}
10+
},
11+
"profiles": {
12+
"RateLimitingSample": {
13+
"commandName": "Project",
14+
"dotnetRunMessages": true,
15+
"launchBrowser": true,
16+
"launchUrl": "swagger",
17+
"applicationUrl": "https://localhost:7036;http://localhost:5085",
18+
"environmentVariables": {
19+
"ASPNETCORE_ENVIRONMENT": "Development"
20+
}
21+
},
22+
"IIS Express": {
23+
"commandName": "IISExpress",
24+
"launchBrowser": true,
25+
"launchUrl": "swagger",
26+
"environmentVariables": {
27+
"ASPNETCORE_ENVIRONMENT": "Development"
28+
}
29+
}
30+
}
31+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<Reference Include="Microsoft.AspNetCore" />
10+
<Reference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" />
11+
<Reference Include="Microsoft.AspNetCore.HttpsPolicy" />
12+
<Reference Include="Microsoft.AspNetCore.Http.Results" />
13+
<Reference Include="Microsoft.AspNetCore.Mvc" />
14+
<Reference Include="Microsoft.AspNetCore.RateLimiting" />
15+
<Reference Include="Microsoft.EntityFrameworkCore.InMemory" />
16+
<Reference Include="Microsoft.Extensions.DependencyInjection" />
17+
</ItemGroup>
18+
19+
</Project>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Threading.RateLimiting;
5+
using Microsoft.AspNetCore.RateLimiting;
6+
7+
namespace RateLimitingSample;
8+
9+
public class SampleRateLimiterPolicy : IRateLimiterPolicy<string>
10+
{
11+
private Func<OnRejectedContext, CancellationToken, ValueTask>? _onRejected;
12+
13+
public SampleRateLimiterPolicy(ILogger<SampleRateLimiterPolicy> logger)
14+
{
15+
_onRejected = (context, token) =>
16+
{
17+
context.HttpContext.Response.StatusCode = 429;
18+
logger.LogInformation($"Request rejected by {nameof(SampleRateLimiterPolicy)}");
19+
return ValueTask.CompletedTask;
20+
};
21+
}
22+
23+
public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get => _onRejected; }
24+
25+
// Use a sliding window limiter allowing 1 request every 10 seconds
26+
public RateLimitPartition<string> GetPartition(HttpContext httpContext)
27+
{
28+
return RateLimitPartition.CreateSlidingWindowLimiter<string>(string.Empty, key => new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, TimeSpan.FromSeconds(5), 1));
29+
}
30+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
}
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
},
8+
"AllowedHosts": "*"
9+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Threading.RateLimiting;
5+
6+
namespace Microsoft.AspNetCore.RateLimiting;
7+
8+
internal sealed class DefaultCombinedLease : RateLimitLease
9+
{
10+
private readonly RateLimitLease _globalLease;
11+
private readonly RateLimitLease _endpointLease;
12+
private HashSet<string>? _metadataNames;
13+
14+
public DefaultCombinedLease(RateLimitLease globalLease, RateLimitLease endpointLease)
15+
{
16+
_globalLease = globalLease;
17+
_endpointLease = endpointLease;
18+
}
19+
20+
public override bool IsAcquired => true;
21+
22+
public override IEnumerable<string> MetadataNames
23+
{
24+
get
25+
{
26+
if (_metadataNames is null)
27+
{
28+
_metadataNames = new HashSet<string>();
29+
foreach (var metadataName in _globalLease.MetadataNames)
30+
{
31+
_metadataNames.Add(metadataName);
32+
}
33+
foreach (var metadataName in _endpointLease.MetadataNames)
34+
{
35+
_metadataNames.Add(metadataName);
36+
}
37+
}
38+
return _metadataNames;
39+
}
40+
}
41+
42+
public override bool TryGetMetadata(string metadataName, out object? metadata)
43+
{
44+
// Use the first metadata item of a given name, ignore duplicates, we can't reliably merge arbitrary metadata
45+
// Creating an object[] if there are multiple of the same metadataName could work, but makes consumption of metadata messy
46+
// and makes MetadataName.Create<T>(...) uses no longer work
47+
if (_endpointLease.TryGetMetadata(metadataName, out metadata))
48+
{
49+
return true;
50+
}
51+
if (_globalLease.TryGetMetadata(metadataName, out metadata))
52+
{
53+
return true;
54+
}
55+
56+
metadata = null;
57+
return false;
58+
}
59+
60+
protected override void Dispose(bool disposing)
61+
{
62+
List<Exception>? exceptions = null;
63+
64+
// Dispose endpoint lease first, then global lease (reverse order of when they were acquired)
65+
// Avoids issues where dispose might unblock a queued acquire and then the acquire fails when acquiring the next limiter.
66+
// When disposing in reverse order there wont be any issues of unblocking an acquire that affects acquires on limiters in the chain after it
67+
try
68+
{
69+
_endpointLease.Dispose();
70+
}
71+
catch (Exception ex)
72+
{
73+
exceptions ??= new List<Exception>();
74+
exceptions.Add(ex);
75+
}
76+
77+
try
78+
{
79+
_globalLease.Dispose();
80+
}
81+
catch (Exception ex)
82+
{
83+
exceptions ??= new List<Exception>(1);
84+
exceptions.Add(ex);
85+
}
86+
87+
if (exceptions is not null)
88+
{
89+
if (exceptions.Count == 1)
90+
{
91+
throw exceptions[0];
92+
}
93+
else
94+
{
95+
throw new AggregateException(exceptions);
96+
}
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)