diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 07ade58a6..79049094f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -243,6 +243,7 @@ const config: UserConfig = { {text: 'Fluent Validation', link: '/guide/http/fluentvalidation'}, {text: 'Problem Details', link: '/guide/http/problemdetails'}, {text: 'Caching', link: '/guide/http/caching'}, + {text: 'Rate Limiting', link: '/guide/http/rate-limiting'}, {text: 'Streaming and SSE', link: '/guide/http/streaming'}, {text: 'HTTP Messaging Transport', link: '/guide/http/transport'}, {text: 'Integration Testing with Alba', link: '/guide/http/integration-testing'} diff --git a/docs/guide/http/rate-limiting.md b/docs/guide/http/rate-limiting.md new file mode 100644 index 000000000..b9c99b166 --- /dev/null +++ b/docs/guide/http/rate-limiting.md @@ -0,0 +1,112 @@ +# Rate Limiting + +Wolverine.HTTP endpoints are standard ASP.NET Core endpoints, so ASP.NET Core's [built-in rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) works directly with no additional Wolverine-specific setup required. + +## Setup + +First, register the rate limiting services and define one or more rate limiting policies in your application's service configuration: + + + +```cs +builder.Services.AddRateLimiter(options => +{ + options.AddFixedWindowLimiter("fixed", opt => + { + opt.PermitLimit = 1; + opt.Window = TimeSpan.FromSeconds(10); + opt.QueueLimit = 0; + }); + options.RejectionStatusCode = 429; +}); +``` +snippet source | anchor + + +Then add the rate limiting middleware to the request pipeline. This must be placed **before** `MapWolverineEndpoints()`: + + + +```cs +app.UseRateLimiter(); +``` +snippet source | anchor + + +## Per-Endpoint Rate Limiting + +Apply the `[EnableRateLimiting]` attribute to individual Wolverine.HTTP endpoints to enforce a named rate limiting policy: + + + +```cs +[WolverineGet("/api/rate-limited")] +[EnableRateLimiting("fixed")] +public static string GetRateLimited() +{ + return "OK"; +} +``` +snippet source | anchor + + +When the rate limit is exceeded, the middleware returns a `429 Too Many Requests` response automatically. + +## Disabling Rate Limiting + +If you have a global rate limiting policy applied, you can opt specific endpoints out using the `[DisableRateLimiting]` attribute: + +```cs +[WolverineGet("/api/health")] +[DisableRateLimiting] +public static string HealthCheck() +{ + return "Healthy"; +} +``` + +## Global Rate Limiting + +You can apply a global rate limiting policy that applies to all endpoints by using a global limiter instead of (or in addition to) named policies: + +```cs +builder.Services.AddRateLimiter(options => +{ + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + options.RejectionStatusCode = 429; +}); +``` + +## Applying Rate Limiting via ConfigureEndpoints + +You can use Wolverine's `ConfigureEndpoints()` to apply rate limiting metadata to all Wolverine.HTTP endpoints programmatically: + +```cs +app.MapWolverineEndpoints(opts => +{ + opts.ConfigureEndpoints(httpChain => + { + // Apply rate limiting to all Wolverine endpoints + httpChain.WithMetadata(new EnableRateLimitingAttribute("fixed")); + }); +}); +``` + +## Available Rate Limiting Algorithms + +ASP.NET Core provides several built-in rate limiting algorithms: + +- **Fixed window** -- limits requests in fixed time intervals +- **Sliding window** -- uses a sliding time window for smoother rate limiting +- **Token bucket** -- allows bursts of traffic up to a configured limit +- **Concurrency** -- limits the number of concurrent requests + +See the [ASP.NET Core rate limiting documentation](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) for full details on each algorithm and advanced configuration options. diff --git a/src/Http/Wolverine.Http.Tests/RateLimiting/RateLimitingTests.cs b/src/Http/Wolverine.Http.Tests/RateLimiting/RateLimitingTests.cs new file mode 100644 index 000000000..bd783d432 --- /dev/null +++ b/src/Http/Wolverine.Http.Tests/RateLimiting/RateLimitingTests.cs @@ -0,0 +1,42 @@ +using Alba; +using Shouldly; + +namespace Wolverine.Http.Tests.RateLimiting; + +public class RateLimitingTests : IntegrationContext +{ + public RateLimitingTests(AppFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task rate_limited_endpoint_returns_429_when_limit_exceeded() + { + // First request should succeed + await Host.Scenario(s => + { + s.Get.Url("/api/rate-limited"); + s.StatusCodeShouldBeOk(); + }); + + // Second request within the window should be rate limited + await Host.Scenario(s => + { + s.Get.Url("/api/rate-limited"); + s.StatusCodeShouldBe(429); + }); + } + + [Fact] + public async Task non_rate_limited_endpoint_always_succeeds() + { + for (int i = 0; i < 5; i++) + { + await Host.Scenario(s => + { + s.Get.Url("/api/not-rate-limited"); + s.StatusCodeShouldBeOk(); + }); + } + } +} diff --git a/src/Http/WolverineWebApi/Program.cs b/src/Http/WolverineWebApi/Program.cs index 1e0fd3097..aca819f7b 100644 --- a/src/Http/WolverineWebApi/Program.cs +++ b/src/Http/WolverineWebApi/Program.cs @@ -1,3 +1,4 @@ +using System.Threading.RateLimiting; using IntegrationTests; using JasperFx; using JasperFx.CodeGeneration; @@ -9,6 +10,7 @@ using Marten; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Wolverine; using Wolverine.AdminApi; @@ -67,6 +69,19 @@ public static async Task Main(string[] args) builder.Services.AddAuthorization(); + #region sample_rate_limiting_configuration + builder.Services.AddRateLimiter(options => + { + options.AddFixedWindowLimiter("fixed", opt => + { + opt.PermitLimit = 1; + opt.Window = TimeSpan.FromSeconds(10); + opt.QueueLimit = 0; + }); + options.RejectionStatusCode = 429; + }); + #endregion + builder.Services.AddDbContextWithWolverineIntegration(x => x.UseNpgsql(Servers.PostgresConnectionString)); @@ -191,6 +206,10 @@ public static async Task Main(string[] args) app.UseAuthorization(); + #region sample_use_rate_limiter_middleware + app.UseRateLimiter(); + #endregion + // These routes are for doing OpenApiEndpoints.BuildComparisonRoutes(app); diff --git a/src/Http/WolverineWebApi/RateLimiting/RateLimitedEndpoints.cs b/src/Http/WolverineWebApi/RateLimiting/RateLimitedEndpoints.cs new file mode 100644 index 000000000..3ad490802 --- /dev/null +++ b/src/Http/WolverineWebApi/RateLimiting/RateLimitedEndpoints.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.RateLimiting; +using Wolverine.Http; + +namespace WolverineWebApi.RateLimiting; + +public static class RateLimitedEndpoints +{ + #region sample_rate_limited_endpoint + [WolverineGet("/api/rate-limited")] + [EnableRateLimiting("fixed")] + public static string GetRateLimited() + { + return "OK"; + } + #endregion + + [WolverineGet("/api/not-rate-limited")] + public static string GetNotRateLimited() + { + return "OK"; + } +}