Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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
6 changes: 6 additions & 0 deletions samples/Ocelot.Samples.sln
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ocelot.Samples.Configuratio
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ocelot.Samples.Metadata", "Metadata\Ocelot.Samples.Metadata.csproj", "{80EE7EA9-BB02-4F2D-B7E3-BB6A42F50658}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ocelot.Samples.RateLimiter", "RateLimiter\Ocelot.Samples.RateLimiter.csproj", "{C4B2D4B9-D568-42DA-A203-6C33BA2E055D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -99,6 +101,10 @@ Global
{80EE7EA9-BB02-4F2D-B7E3-BB6A42F50658}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80EE7EA9-BB02-4F2D-B7E3-BB6A42F50658}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80EE7EA9-BB02-4F2D-B7E3-BB6A42F50658}.Release|Any CPU.Build.0 = Release|Any CPU
{C4B2D4B9-D568-42DA-A203-6C33BA2E055D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C4B2D4B9-D568-42DA-A203-6C33BA2E055D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C4B2D4B9-D568-42DA-A203-6C33BA2E055D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C4B2D4B9-D568-42DA-A203-6C33BA2E055D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
13 changes: 13 additions & 0 deletions samples/RateLimiter/Ocelot.Samples.RateLimiter.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Ocelot\Ocelot.csproj" />
</ItemGroup>

</Project>
11 changes: 11 additions & 0 deletions samples/RateLimiter/Ocelot.Samples.RateLimiter.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@RateLimiterSample_HostAddress = http://localhost:5202

GET {{RateLimiterSample_HostAddress}}/laura
Accept: application/json

###

GET {{RateLimiterSample_HostAddress}}/tom
Accept: application/json

###
26 changes: 26 additions & 0 deletions samples/RateLimiter/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.RateLimiting;
using Ocelot.DependencyInjection;
using Ocelot.Middleware;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("ocelot.json");
builder.Services.AddOcelot();

builder.Services.AddRateLimiter(op =>
{
op.AddFixedWindowLimiter(policyName: "fixed", options =>
{
options.PermitLimit = 2;
options.Window = TimeSpan.FromSeconds(12);
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 0;
});
});
Comment on lines +10 to +19
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I disagree! In this case, we haven't proposed anything new.
I think we should provide the user with an option to choose between configuration approaches. We could consider having a separate Ocelot rate-limiting rule to manage app auto-configurations.

Copy link
Copy Markdown
Collaborator

@ggnaegi ggnaegi Sep 25, 2025

Choose a reason for hiding this comment

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

@raman-m it's basically what i mentioned:
Yes, that's the main concern; we essentially need to reinvent the wheel for the middleware part. I've tried to "dynamize" the policies myself, but I believe it’s not possible.
The policies are setup during startup, you can't modify them after that, and you can't mix them (as of 2024, maybe now it's possible).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can't mix policies, but we can define as many as needed, right?
From your words, did you mean that the "fixed" policy is global in this code?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes, it's global and they can't be modified.


var app = builder.Build();
app.UseHttpsRedirection();

await app.UseOcelot();

app.Run();
41 changes: 41 additions & 0 deletions samples/RateLimiter/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:12083",
"sslPort": 44358
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "http://localhost:5202",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "https://localhost:7116;http://localhost:5202",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
8 changes: 8 additions & 0 deletions samples/RateLimiter/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions samples/RateLimiter/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
46 changes: 46 additions & 0 deletions samples/RateLimiter/ocelot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"Routes": [
{
"UpstreamHttpMethod": [ "Get" ],
"UpstreamPathTemplate": "/laura",
"DownstreamPathTemplate": "/fact",
"DownstreamScheme": "https",
"DownstreamHostAndPorts": [
{ "Host": "catfact.ninja", "Port": 443 }
],
"Key": "Laura",
"RateLimitOptions": {
"EnableRateLimiting": true,
"Period": "5s",
"PeriodTimespan": 1,
"Limit": 1
}
},
{
"UpstreamHttpMethod": [ "Get" ],
"UpstreamPathTemplate": "/tom",
"DownstreamPathTemplate": "/fact",
"DownstreamScheme": "https",
"DownstreamHostAndPorts": [
{ "Host": "catfact.ninja", "Port": 443 }
],
"Key": "Tom",
"RateLimitOptions": {
"EnableRateLimiting": true,
"Policy": "fixed"
}
}
],
"Aggregates": [
{
"UpstreamPathTemplate": "/",
"RouteKeys": [ "Tom", "Laura" ]
}
],
"GlobalConfiguration": {
"RateLimitOptions": {
"QuotaExceededMessage": "Customize Tips!",
"HttpStatusCode": 418 // I'm a teapot
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ protected virtual RateLimitOptions MergeHeaderRules(FileRateLimitByHeaderRule ru
rule.Wait = rule.Wait.IfEmpty(globalRule.Wait.IfEmpty(RateLimitRule.ZeroWait));

rule.Limit ??= globalRule.Limit ?? RateLimitRule.ZeroLimit;
rule.Policy = rule.Policy.IfEmpty(globalRule.Policy);
return new(rule);
}
}
13 changes: 13 additions & 0 deletions src/Ocelot/Configuration/File/FileRateLimitRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public FileRateLimitRule(FileRateLimitRule from)
Limit = from.Limit;
Period = from.Period;
PeriodTimespan = from.PeriodTimespan;
Policy = from.Policy;
Wait = from.Wait;
StatusCode = from.StatusCode;
QuotaMessage = from.QuotaMessage;
Expand Down Expand Up @@ -74,6 +75,14 @@ public FileRateLimitRule(FileRateLimitRule from)
/// <value>A <see cref="string"/> object which value defaults to "Ocelot.RateLimiting", see the <see cref="RateLimitOptions.DefaultCounterPrefix"/> property.</value>
public string KeyPrefix { get; set; }

/// <summary>
/// Rate limit policy name. It only takes effect if rate limit middleware type is set to DotNet.
/// </summary>
/// <value>
/// A string of rate limit policy name.
/// </value>
public string Policy { get; set; }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You might want to consider removing this property and using FileRateLimitByAspNetRule.Policy instead.


/// <summary>
/// Returns a string that represents the current rule in the format, which defaults to empty string if rate limiting is disabled (<see cref="EnableRateLimiting"/> is <see langword="false"/>).
/// </summary>
Expand All @@ -85,6 +94,10 @@ public override string ToString()
{
return string.Empty;
}
else if (!string.IsNullOrWhiteSpace(Policy))
{
return $"{nameof(Policy)}:{Policy}";
}

char hdrSign = EnableHeaders == false ? '-' : '+';
string waitWindow = PeriodTimespan.HasValue ? PeriodTimespan.Value.ToString("F3") + 's' : Wait.IfEmpty(None);
Expand Down
10 changes: 8 additions & 2 deletions src/Ocelot/Configuration/RateLimitOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public RateLimitOptions(bool enableRateLimiting) : this()
}

public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, IList<string> clientWhitelist, bool enableHeaders,
string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode)
string quotaExceededMessage, string rateLimitCounterPrefix, RateLimitRule rateLimitRule, int httpStatusCode,
string policy = null)
{
ClientIdHeader = clientIdHeader.IfEmpty(DefaultClientHeader);
ClientWhitelist = clientWhitelist ?? [];
Expand All @@ -45,8 +46,9 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, IList<st
QuotaMessage = quotaExceededMessage.IfEmpty(DefaultQuotaMessage);
Rule = rateLimitRule;
StatusCode = httpStatusCode;
Policy = policy;
}

public RateLimitOptions(FileRateLimitByHeaderRule fromRule)
{
ArgumentNullException.ThrowIfNull(fromRule);
Expand All @@ -63,6 +65,7 @@ public RateLimitOptions(FileRateLimitByHeaderRule fromRule)
fromRule.Period.IfEmpty(RateLimitRule.DefaultPeriod),
fromRule.PeriodTimespan.HasValue ? $"{fromRule.PeriodTimespan.Value}s" : fromRule.Wait,
fromRule.Limit ?? RateLimitRule.ZeroLimit);
Policy = fromRule.Policy;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

An option of a flawed rule 😄 Consider removing!

}

public RateLimitOptions(RateLimitOptions fromOptions)
Expand All @@ -77,6 +80,7 @@ public RateLimitOptions(RateLimitOptions fromOptions)
QuotaMessage = fromOptions.QuotaMessage.IfEmpty(DefaultQuotaMessage);
KeyPrefix = fromOptions.KeyPrefix.IfEmpty(DefaultCounterPrefix);
Rule = fromOptions.Rule ?? RateLimitRule.Empty;
Policy = fromOptions.Policy;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's fine because of the copy constructor ✔️

}

/// <summary>Gets a Rate Limit rule.</summary>
Expand Down Expand Up @@ -121,4 +125,6 @@ public RateLimitOptions(RateLimitOptions fromOptions)
/// <summary>Enables or disables <c>X-RateLimit-*</c> and <c>Retry-After</c> headers.</summary>
/// <value>A <see cref="bool"/> value.</value>
public bool EnableHeaders { get; init; }

public string Policy { get; init; }
}
4 changes: 3 additions & 1 deletion src/Ocelot/Configuration/Validator/RouteFluentValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ public RouteFluentValidator(
.WithMessage("{PropertyName} {PropertyValue} contains scheme");
});

When(route => route.RateLimitOptions != null && route.RateLimitOptions.EnableRateLimiting != false, () =>
When(route => route.RateLimitOptions != null
&& route.RateLimitOptions.EnableRateLimiting != false
&& string.IsNullOrWhiteSpace(route.RateLimitOptions.Policy), () =>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We still need to validate the rule algorithm properties, even if the ASP.NET policy is in place.

{
RuleFor(route => route.RateLimitOptions.Limit)
.Must(limit => !limit.HasValue || (limit.HasValue && limit.Value > 0))
Expand Down
41 changes: 39 additions & 2 deletions src/Ocelot/DependencyInjection/Features.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Ocelot.Cache;
using Ocelot.Configuration.Creator;
using Ocelot.Configuration.File;
Expand All @@ -7,6 +10,11 @@
using Ocelot.RateLimiting;
using FluentValidation;

#if NET7_0_OR_GREATER
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The TMFs for our current project are net8.0 and net9.0. Eliminate the preprocessor directive!

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

namespace Ocelot.DependencyInjection;

public static class Features
Expand All @@ -30,10 +38,39 @@ public static IServiceCollection AddConfigurationValidators(this IServiceCollect
/// Read The Docs: <see href="https://ocelot.readthedocs.io/en/latest/features/ratelimiting.html">Rate Limiting</see>.
/// </remarks>
/// <param name="services">The services collection to add the feature to.</param>
/// <param name="configurationRoot">Root configuration object.</param>
/// <returns>The same <see cref="IServiceCollection"/> object.</returns>
public static IServiceCollection AddRateLimiting(this IServiceCollection services) => services
.AddSingleton<IRateLimiting, RateLimiting.RateLimiting>()
.AddSingleton<IRateLimitStorage, MemoryCacheRateLimitStorage>();

#if NET7_0_OR_GREATER
/// <summary>
/// Ocelot feature: <see href="">AspNet Rate Limiting</see>.
/// </summary>
/// <remarks>
/// Read The Docs: <see href="">Rate Limiting</see>.
/// </remarks>
/// <param name="services">The services collection to add the feature to.</param>
/// <param name="configurationRoot">Root configuration object.</param>
/// <returns>The same <see cref="IServiceCollection"/> object.</returns>
public static IServiceCollection AddAspNetRateLimiting(this IServiceCollection services, IConfiguration configurationRoot)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure, but I have a strong feeling that this adder should be part of the method on line 43. I'm open to discussions.
The goal is that the end user shouldn't need to write any code for adding services, as the current AddRateLimiting is an internal Ocelot feature already included in the DI container by default.

{
var globalRateLimitOptions = configurationRoot.Get<FileConfiguration>()?.GlobalConfiguration?.RateLimitOptions;
var rejectStatusCode = globalRateLimitOptions?.HttpStatusCode ?? StatusCodes.Status429TooManyRequests;
var rejectedMessage = globalRateLimitOptions?.QuotaExceededMessage ?? "API calls quota exceeded!";
Comment on lines +53 to +55
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Merging in the wrong place can be risky.
We've assigned the IRateLimitOptionsCreator service specifically for merging options. Please refer to the RateLimitOptionsCreator class for details.

services.AddRateLimiter(options =>
{
options.OnRejected = async (rejectedContext, token) =>
{
rejectedContext.HttpContext.Response.StatusCode = rejectStatusCode;
await rejectedContext.HttpContext.Response.WriteAsync(rejectedMessage, token);
Comment on lines +60 to +61
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's amusing that we implement the same approach in Ocelot's middleware. Perhaps adding a virtual method in the middleware to connect to the handler could work? 😉

};
});

return services;
}
#endif

/// <summary>
/// Ocelot feature: <see href="https://github.com/ThreeMammals/Ocelot/blob/develop/docs/features/caching.rst">Request Caching</see>.
Expand Down Expand Up @@ -65,6 +102,6 @@ public static IServiceCollection AddHeaderRouting(this IServiceCollection servic
/// </summary>
/// <param name="services">The services collection to add the feature to.</param>
/// <returns>The same <see cref="IServiceCollection"/> object.</returns>
public static IServiceCollection AddOcelotMetadata(this IServiceCollection services) =>
public static IServiceCollection AddOcelotMetadata(this IServiceCollection services) =>
services.AddSingleton<IMetadataCreator, DefaultMetadataCreator>();
}
6 changes: 4 additions & 2 deletions src/Ocelot/DependencyInjection/OcelotBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo
Services.TryAddSingleton<IDownstreamRouteProviderFactory, DownstreamRouteProviderFactory>();
Services.TryAddSingleton<IHttpResponder, HttpContextResponder>();
Services.TryAddSingleton<IErrorsToHttpStatusCodeMapper, ErrorsToHttpStatusCodeMapper>();
Services.AddRateLimiting(); // Feature: Rate Limiting
Services.TryAddSingleton<IRequestMapper, RequestMapper>();
Services.TryAddSingleton<IHttpHandlerOptionsCreator, HttpHandlerOptionsCreator>();
Services.TryAddSingleton<IDownstreamAddressesCreator, DownstreamAddressesCreator>();
Expand All @@ -111,7 +110,10 @@ public OcelotBuilder(IServiceCollection services, IConfiguration configurationRo
Services.AddOcelotCache();
Services.AddOcelotMetadata();
Services.AddOcelotMessageInvokerPool();

Services.AddRateLimiting(); // Feature: Rate Limiting
#if NET7_0_OR_GREATER
Services.AddAspNetRateLimiting(configurationRoot); // Feature: AspNet Rate Limiting
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I strongly believe we shouldn't introduce a new feature, whether public or internal. Since the method is already auto-enabled internally, it seems we can leave it as-is. However, we definitely need to review this design.

#endif
// Chinese developers should read StackOverflow ignoring Microsoft Learn docs -> http://stackoverflow.com/questions/37371264/invalidoperationexception-unable-to-resolve-service-for-type-microsoft-aspnetc
Services.AddHttpContextAccessor();
Services.TryAddSingleton<IRequestScopedDataRepository, HttpDataRepository>();
Expand Down
10 changes: 10 additions & 0 deletions src/Ocelot/RateLimiting/RateLimitingMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Ocelot.Configuration;
Expand Down Expand Up @@ -39,6 +40,15 @@ public Task Invoke(HttpContext context)
return _next.Invoke(context);
}

if (!options.Policy.IsNullOrEmpty())
{
// Add EnableRateLimiting attribute to endpoint, so that .Net rate limiter can pick it up and do its thing
var metadata = new EndpointMetadataCollection(new EnableRateLimitingAttribute(options.Policy));
var endpoint = new Endpoint(null, metadata, "tempEndpoint");
context.SetEndpoint(endpoint);
return _next.Invoke(context);
}

var identity = Identify(context, options, downstreamRoute);
if (IsWhitelisted(identity, options))
{
Expand Down
Loading
Loading