Skip to content

Make RateLimitingMiddleware endpoint aware #42417

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

Merged
merged 40 commits into from
Jul 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ef9b7c4
Add IEndpointConventionBuilder extensions for RateLimitingMiddleware
wtgodbe May 20, 2022
df78df1
Some changes
wtgodbe Jun 2, 2022
6a4c91c
More
wtgodbe Jun 7, 2022
982cdfb
Rename
wtgodbe Jun 9, 2022
12ce9d6
More
wtgodbe Jun 21, 2022
a38aee3
Merge remote-tracking branch 'upstream/main' into wtgodbe/RateLimit2
wtgodbe Jun 21, 2022
858e7d5
More fixes
wtgodbe Jun 21, 2022
54ef30e
More stuff
wtgodbe Jun 22, 2022
b608c2d
Example of activating
wtgodbe Jun 22, 2022
e42912c
Merge remote-tracking branch 'upstream/main' into wtgodbe/RateLimit2
wtgodbe Jun 22, 2022
c15482c
Changes
wtgodbe Jun 23, 2022
ee4f648
Merge remote-tracking branch 'upstream/main' into wtgodbe/RateLimit2
wtgodbe Jun 23, 2022
e0a8c70
More stuff
wtgodbe Jun 23, 2022
7dce149
Name change
wtgodbe Jun 23, 2022
3cc73cf
Fix some stuff
wtgodbe Jun 24, 2022
ec829e6
Fixup
wtgodbe Jun 24, 2022
2fe189a
Add some tests
wtgodbe Jun 24, 2022
4e7c3a3
More tests
wtgodbe Jun 24, 2022
0cdf1fd
Renames
wtgodbe Jun 24, 2022
6389bc2
Minor
wtgodbe Jun 25, 2022
0cbbf35
Some feedback
wtgodbe Jun 27, 2022
9194a94
No more reflection
wtgodbe Jun 27, 2022
8d15768
Try to satisfy linuc
wtgodbe Jun 27, 2022
acb204d
Suppress linker warning
wtgodbe Jun 28, 2022
aeaac56
Fix linkability bug
wtgodbe Jun 28, 2022
3fb235d
Add sample project (not yet usable)
wtgodbe Jun 28, 2022
6755538
Fix sample, fix some feedback
wtgodbe Jun 29, 2022
59839bd
Feedback
wtgodbe Jun 29, 2022
23b1cc9
Merge from main
wtgodbe Jun 29, 2022
0664773
Some feedback
wtgodbe Jul 1, 2022
5ebe00b
Different key strategy
wtgodbe Jul 1, 2022
bf4f7ae
New DefaultKeyType strategy
wtgodbe Jul 1, 2022
e0a277f
HashCode.Combine
wtgodbe Jul 1, 2022
1373e87
Feedback again
wtgodbe Jul 11, 2022
286b827
Small fixes
wtgodbe Jul 11, 2022
17c496d
Merge remote-tracking branch 'upstream/main' into wtgodbe/RateLimit2
wtgodbe Jul 11, 2022
10173ec
Update compilah
wtgodbe Jul 11, 2022
a90c2f2
Feedbacks
wtgodbe Jul 11, 2022
e1871ee
Fix comment
wtgodbe Jul 11, 2022
3c3115b
Feedbock
wtgodbe Jul 12, 2022
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
20 changes: 19 additions & 1 deletion AspNetCore.sln
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31606.5
Expand Down Expand Up @@ -1736,6 +1735,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CustomElements", "CustomEle
EndProject
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}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimitingSample", "src\Middleware\RateLimiting\samples\RateLimitingSample\RateLimitingSample.csproj", "{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -10497,6 +10498,22 @@ Global
{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9}.Release|x64.Build.0 = Release|Any CPU
{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9}.Release|x86.ActiveCfg = Release|Any CPU
{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9}.Release|x86.Build.0 = Release|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|arm64.ActiveCfg = Debug|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|arm64.Build.0 = Debug|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|x64.ActiveCfg = Debug|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|x64.Build.0 = Debug|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|x86.ActiveCfg = Debug|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Debug|x86.Build.0 = Debug|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|Any CPU.Build.0 = Release|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|arm64.ActiveCfg = Release|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|arm64.Build.0 = Release|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x64.ActiveCfg = Release|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x64.Build.0 = Release|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x86.ActiveCfg = Release|Any CPU
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -11365,6 +11382,7 @@ Global
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD} = {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F}
{0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF}
{7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9} = {08D53E58-4AAE-40C4-8497-63EC8664F304}
{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9} = {1D865E78-7A66-4CA9-92EE-2B350E45281F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}
Expand Down
6 changes: 6 additions & 0 deletions eng/Versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
<UsingToolNetFrameworkReferenceAssemblies Condition="'$(OS)' != 'Windows_NT'">true</UsingToolNetFrameworkReferenceAssemblies>
<!-- Disable XLIFF tasks -->
<UsingToolXliff>false</UsingToolXliff>
<!-- Use custom version of Roslyn Compiler -->
<UsingToolMicrosoftNetCompilers>true</UsingToolMicrosoftNetCompilers>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to allow for the required keyword I use in many places in this PR

</PropertyGroup>
<!--

Expand Down Expand Up @@ -194,6 +196,10 @@
framework in current .NET SDKs.
-->
<MicrosoftNETTestSdkVersion>17.1.0-preview-20211109-03</MicrosoftNETTestSdkVersion>
<!--
Use a compiler new enough to use required keyword
-->
<MicrosoftNetCompilersToolsetVersion>4.4.0-1.22358.14</MicrosoftNetCompilersToolsetVersion>
<!--
Versions of Microsoft.CodeAnalysis packages referenced by analyzers shipped in the SDK.
This need to be pinned since they're used in 3.1 apps and need to be loadable in VS 2019.
Expand Down
1 change: 1 addition & 0 deletions src/Middleware/Middleware.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj",
"src\\Middleware\\OutputCaching\\test\\Microsoft.AspNetCore.OutputCaching.Tests.csproj",
"src\\Middleware\\RateLimiting\\src\\Microsoft.AspNetCore.RateLimiting.csproj",
"src\\Middleware\\RateLimiting\\samples\\RateLimitingSample\\RateLimitingSample.csproj",
"src\\Middleware\\RateLimiting\\test\\Microsoft.AspNetCore.RateLimiting.Tests.csproj",
"src\\Middleware\\RequestDecompression\\perf\\Microbenchmarks\\Microsoft.AspNetCore.RequestDecompression.Microbenchmarks.csproj",
"src\\Middleware\\RequestDecompression\\sample\\RequestDecompressionSample.csproj",
Expand Down
108 changes: 108 additions & 0 deletions src/Middleware/RateLimiting/samples/RateLimitingSample/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
using RateLimitingSample;
using Microsoft.Extensions.Logging.Abstractions;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
// Inject an ILogger<SampleRateLimiterPolicy>
builder.Services.AddLogging();

var app = builder.Build();

var todoName = "todoPolicy";
var completeName = "completePolicy";
var helloName = "helloPolicy";

// Define endpoint limiters and a global limiter.
var options = new RateLimiterOptions()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't this using services?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the sample to inject an ILogger - anything else you'd like to see?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was asking why the options was being newed up instead of a call to AddRateLimiting on builder.Services.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We removed that extension in the last API review: #37384 (comment), in favor of just the UseRateLimiter(options) extension on IApplicationBuilder. We could bring it back in the next iteration

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK that doesn't look ideal at all. It doesn't look like any of the other middleware that is configured with this level of policy.

@Tratcher @halter73 @BrennanConroy What was the thinking here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need a do over here to align with the patterns we have in the rest of the stack. The newly authored output caching middleware also follows this pattern

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few other places we do it this way:

public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, StaticFileOptions options)

public static IApplicationBuilder UseHttpMethodOverride(this IApplicationBuilder builder, HttpMethodOverrideOptions options)

public static IApplicationBuilder UseWebSockets(this IApplicationBuilder app, WebSocketOptions options)

But I think long term it's probably better to switch to UseRateLimiter on the ServiceCollection w/ an Action. Given that we already shipped this version of the API for preview6, I'd like to get this PR in as-is (modulo the other minor feedback) before the preview7 snap tomorrow, then go through another API review to switch to the action-based approach (as well as a couple other small changes) to be merged later this week

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

@halter73 halter73 Jul 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidfowl You're late to the party! You should read my thoughts on this at #41655 (comment) when @Elfocrash made the same point if you haven't yet. We're way more consistent with this options pattern (or should I say lack of options pattern) for middleware with no services than I would have thought. We're rarely this consistent with anything else 🤣.

But I think long term it's probably better to switch to UseRateLimiter on the ServiceCollection w/ an Action. Given that we already shipped this version of the API for preview6, I'd like to get this PR in as-is (modulo the other minor feedback) before the preview7 snap tomorrow, then go through another API review to switch to the action-based approach (as well as a couple other small changes) to be merged later this week

Completely agree.

I do understand no matter how consistent we are with not using the options pattern in this specific scenario, it's rare to use middleware without any default services but with interesting options must people will want to configure. And this does feel awkward when you're more used to configuring options for middleware that consumes services.

Is it likely that UseRateLimiter will need to register default services for .NET 7? If so, configuring options when adding the services like you do for most things would avoid this controversy entirely!

Copy link
Member

@davidfowl davidfowl Jul 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of those options are this complex. There's no policy, no methods, just properties on pocos.

.AddTokenBucketLimiter(todoName, new TokenBucketRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, TimeSpan.FromSeconds(10), 1))
.AddPolicy<string>(completeName, new SampleRateLimiterPolicy(NullLogger<SampleRateLimiterPolicy>.Instance))
.AddPolicy<string, SampleRateLimiterPolicy>(helloName);
// The global limiter will be a concurrency limiter with a max permit count of 10 and a queue depth of 5.
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
return RateLimitPartition.CreateConcurrencyLimiter<string>("globalLimiter", key => new ConcurrencyLimiterOptions(10, QueueProcessingOrder.NewestFirst, 5));
});
app.UseRateLimiter(options);

// The limiter on this endpoint allows 1 request every 5 seconds
app.MapGet("/", () => "Hello World!").RequireRateLimiting(helloName);

// Requests to this endpoint will be processed in 10 second intervals
app.MapGet("/todoitems", async (TodoDb db) =>
await db.Todos.ToListAsync())
.RequireRateLimiting(todoName);

// The limiter on this endpoint allows 1 request every 5 seconds
app.MapGet("/todoitems/complete", async (TodoDb db) =>
await db.Todos.Where(t => t.IsComplete).ToListAsync())
.RequireRateLimiting(completeName);

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
await db.Todos.FindAsync(id)
is Todo todo
? Results.Ok(todo)
: Results.NotFound());

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
db.Todos.Add(todo);
await db.SaveChangesAsync();

return Results.Created($"/todoitems/{todo.Id}", todo);
});

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
var todo = await db.Todos.FindAsync(id);

if (todo is null)
{
return Results.NotFound();
}

todo.Name = inputTodo.Name;
todo.IsComplete = inputTodo.IsComplete;

await db.SaveChangesAsync();

return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
if (await db.Todos.FindAsync(id) is Todo todo)
{
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return Results.Ok(todo);
}

return Results.NotFound();
});

app.Run();

class Todo
{
public int Id { get; set; }
public string? Name { get; set; }
public bool IsComplete { get; set; }
}

class TodoDb : DbContext
{
public TodoDb(DbContextOptions<TodoDb> options)
: base(options) { }

public DbSet<Todo> Todos => Set<Todo>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:8855",
"sslPort": 44312
}
},
"profiles": {
"RateLimitingSample": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7036;http://localhost:5085",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<Reference Include="Microsoft.AspNetCore" />
<Reference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" />
<Reference Include="Microsoft.AspNetCore.HttpsPolicy" />
<Reference Include="Microsoft.AspNetCore.Http.Results" />
<Reference Include="Microsoft.AspNetCore.Mvc" />
<Reference Include="Microsoft.AspNetCore.RateLimiting" />
<Reference Include="Microsoft.EntityFrameworkCore.InMemory" />
<Reference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

namespace RateLimitingSample;

public class SampleRateLimiterPolicy : IRateLimiterPolicy<string>
{
private Func<OnRejectedContext, CancellationToken, ValueTask>? _onRejected;

public SampleRateLimiterPolicy(ILogger<SampleRateLimiterPolicy> logger)
{
_onRejected = (context, token) =>
{
context.HttpContext.Response.StatusCode = 429;
logger.LogInformation($"Request rejected by {nameof(SampleRateLimiterPolicy)}");
return ValueTask.CompletedTask;
};
}

public Func<OnRejectedContext, CancellationToken, ValueTask>? OnRejected { get => _onRejected; }

// Use a sliding window limiter allowing 1 request every 10 seconds
public RateLimitPartition<string> GetPartition(HttpContext httpContext)
{
return RateLimitPartition.CreateSlidingWindowLimiter<string>(string.Empty, key => new SlidingWindowRateLimiterOptions(1, QueueProcessingOrder.OldestFirst, 1, TimeSpan.FromSeconds(5), 1));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
99 changes: 99 additions & 0 deletions src/Middleware/RateLimiting/src/DefaultCombinedLease.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Threading.RateLimiting;

namespace Microsoft.AspNetCore.RateLimiting;

internal sealed class DefaultCombinedLease : RateLimitLease
{
private readonly RateLimitLease _globalLease;
private readonly RateLimitLease _endpointLease;
private HashSet<string>? _metadataNames;

public DefaultCombinedLease(RateLimitLease globalLease, RateLimitLease endpointLease)
{
_globalLease = globalLease;
_endpointLease = endpointLease;
}

public override bool IsAcquired => true;

public override IEnumerable<string> MetadataNames
{
get
{
if (_metadataNames is null)
{
_metadataNames = new HashSet<string>();
foreach (var metadataName in _globalLease.MetadataNames)
{
_metadataNames.Add(metadataName);
}
foreach (var metadataName in _endpointLease.MetadataNames)
{
_metadataNames.Add(metadataName);
}
}
return _metadataNames;
}
}

public override bool TryGetMetadata(string metadataName, out object? metadata)
{
// Use the first metadata item of a given name, ignore duplicates, we can't reliably merge arbitrary metadata
// Creating an object[] if there are multiple of the same metadataName could work, but makes consumption of metadata messy
// and makes MetadataName.Create<T>(...) uses no longer work
if (_endpointLease.TryGetMetadata(metadataName, out metadata))
{
return true;
}
if (_globalLease.TryGetMetadata(metadataName, out metadata))
{
return true;
}

metadata = null;
return false;
}

protected override void Dispose(bool disposing)
{
List<Exception>? exceptions = null;

// Dispose endpoint lease first, then global lease (reverse order of when they were acquired)
// Avoids issues where dispose might unblock a queued acquire and then the acquire fails when acquiring the next limiter.
// When disposing in reverse order there wont be any issues of unblocking an acquire that affects acquires on limiters in the chain after it
try
{
_endpointLease.Dispose();
}
catch (Exception ex)
{
exceptions ??= new List<Exception>();
exceptions.Add(ex);
}

try
{
_globalLease.Dispose();
}
catch (Exception ex)
{
exceptions ??= new List<Exception>(1);
exceptions.Add(ex);
}

if (exceptions is not null)
{
if (exceptions.Count == 1)
{
throw exceptions[0];
}
else
{
throw new AggregateException(exceptions);
}
}
}
}
Loading