SeliseBlocks.RateLimiting 10.1.1.1
SeliseBlocks.RateLimiting
Per-endpoint rate limiting for SeliseBlocks Genesis web services. One AddRateLimiting() call, then
every limit is declared in appsettings.{environment}.json or with attributes.
Limits can be targeted at:
| Scope | How |
|---|---|
| Every endpoint | ApplyToAllEndpoints: true + Default |
| Every protected / every anonymous endpoint | AuthKinds:Protected / AuthKinds:Anonymous |
| Every endpoint on a controller | Controllers:{Controller} or [RateLimit] on the controller |
| A named group of endpoints | Groups:{name} with Members patterns, or [RateLimitGroup("name")] |
| One endpoint | Endpoints:{Controller}/{Action} or [RateLimit] on the action |
| Nothing (exempt) | [DisableRateLimit], or Enabled: false on any selector |
Why a filter and not AddRateLimiter
BlocksWebApiPipelineBuilder calls AddMvcCore with EnableEndpointRouting = false and ends the
pipeline in legacy UseMvc(...). HttpContext.GetEndpoint() therefore returns null in middleware,
so the built-in .NET limiter (AddRateLimiter / [EnableRateLimiting]), which reads its policy from
endpoint metadata, can never resolve a per-action policy. This package applies itself as a global MVC
resource filter instead, and reads attributes by reflection off the ControllerActionDescriptor.
Resource filters run after authorization filters, so an unauthenticated call to a protected
endpoint is answered 401 and never counted. Rate limiting here protects against authenticated
abuse and runaway clients, not against an anonymous flood.
Install
dotnet add package SeliseBlocks.RateLimiting
Requires .NET 8 or .NET 10, and a Genesis service (SeliseBlocks.Genesis.Framework).
Wire up
private static void AddApplicationServices(IServiceCollection container, IAppSettings appSettings)
{
container.AddRateLimiting();
// ... the rest of the service registrations
}
AddApplicationServices is the right hook: the framework invokes it after AddMvcCore, so
services.Configure<MvcOptions> composes with the framework's own MVC setup instead of replacing it.
Do not use ConfigureMiddlewares — it replaces the default pipeline, CORS and UseMvc included.
Overloads:
// A different configuration section (e.g. per-service keys in a shared config file)
container.AddRateLimiting("ServiceRateLimits");
// Code defaults that appsettings can still override, or a forced value
container.AddRateLimiting(configure: options => options.KeyPrefix = "rl-query");
To replace a component, register your own IRateLimitStore, IRateLimitKeyProvider or
IRateLimitPolicyResolver before calling AddRateLimiting — the defaults are added with
TryAddSingleton.
Configuration
{
"RateLimiting": {
"Enabled": true, // master switch
"Store": "Redis", // Redis (shared across pods) | InMemory (per process)
"FailOpen": true, // Redis down -> allow (true) or reject (false)
"TrustForwardedHeaders": false, // only behind a proxy that overwrites X-Forwarded-For
"EmitHeaders": true, // X-RateLimit-Limit / -Remaining / -Reset
"KeyPrefix": "rl",
// "ServiceName": "SharedBudget", // only to make two services share a budget
// 1. every endpoint
"ApplyToAllEndpoints": true,
"Default": { "PermitLimit": 100, "WindowSeconds": 60, "Algorithm": "FixedWindow", "Scope": "User" },
// 2. by authentication kind
"AuthKinds": {
"Protected": { "PermitLimit": 120 },
"Anonymous": { "PermitLimit": 20, "Scope": "Ip" }
},
// 3. per controller (name without the "Controller" suffix)
"Controllers": {
"Contracts": { "PermitLimit": 200 }
},
// 4. named groups
"Groups": {
"external-api": {
"Members": [ "Contracts/GetPortfolio*", "SelectNetworkQuery/GetPortfolioHistoricalData", "Drive/*" ],
"PermitLimit": 30,
"WindowSeconds": 60,
"Algorithm": "SlidingWindow",
"Shared": true
},
"search": {
"Members": [ "SelectNetworkQuery/Search*", "SelectNetworkQuery/GetUsers" ],
"PermitLimit": 60,
"Algorithm": "SlidingWindow"
}
},
// 5. one endpoint
"Endpoints": {
"SelectNetworkQuery/SearchPerson": { "PermitLimit": 30 },
"Contracts/GetContractCount": { "Enabled": false }
}
}
}
Precedence
Most specific wins, and each selector contributes only the values it sets — anything it leaves
unset falls through to the next one, and finally to Default:
[DisableRateLimit] -> never limited, wins over everything
Endpoints["Controller/Action"]
[RateLimit] on the action
Groups (attribute first, then the most specific Members pattern)
Controllers["Controller"] (legacy Endpoints["Controller/*"] is equivalent)
[RateLimit] on the controller
AuthKinds:Protected | Anonymous
Default (only makes an endpoint limited when ApplyToAllEndpoints is true)
So Endpoints:{ "Plain/Search": { "PermitLimit": 30 } } over
[RateLimit(PermitLimit = 60, WindowSeconds = 120)] yields 30 per 120s.
Enabled follows the same rule: the most specific selector that sets it decides, so an endpoint
entry can re-enable what a controller entry switched off.
An action matched by no selector is not limited. Until a service sets ApplyToAllEndpoints: true,
the package is opt-in.
Attributes
[HttpPost]
[ProtectedEndPoint]
[RateLimit(PermitLimit = 30, WindowSeconds = 60, Algorithm = RateLimitAlgorithm.SlidingWindow)]
public Task<QueryResponse<SearchPersonQueryResponse>> SearchPerson([FromBody] SearchPersonQuery query)
[RateLimitGroup("external-api")] // joins a configured group
public Task<object> GetPortfolioHistoricalData([FromBody] PortfolioHistoricalDataQuery query)
[DisableRateLimit] // exempt, wins over all configuration
public Task<QueryResponse<int>> GetMyPendingSignaureCount(...)
Unset attribute properties inherit (PermitLimit = 0 means "not set"), and both attributes work on a
controller class to cover all of its actions.
Groups: Shared or not
Shared: false(default) — the group is a convenient way to give many endpoints the same numbers. Each member still gets its own counter.Shared: true— every member counts against one budget. Use it to cap total load on a shared downstream (a third-party API, a slow collection). KeepPermitLimit/WindowSecondson the group itself; if a more specific selector supplies them the effective limit depends on which member is called first, and the resolver logs a warning saying so.
Member patterns match {Controller}/{Action} case-insensitively; * matches any run of characters.
When two groups match, the more specific pattern wins (exact > Contracts/Get* > Contracts/* > *);
an explicit attribute always beats a pattern.
Scope, algorithm, store
Scope:User(falls back to the client IP when there is no principal),Tenant,Ip,Global(one budget for all callers).Algorithm:FixedWindowis one atomicINCRbut allows up to 2x the limit across a window boundary — fine for capacity protection.SlidingWindowcosts a sorted-set entry per request and holds the limit at the edges — use it where the limit is the security control.Store:RedisusesIAppSettings.KeyStorageConnectionStringviaStackExchange.Redis(IRedisClienthas no atomicINCR). The connection is lazy, so a Redis outage at startup cannot stop the service from booting.InMemoryis per-process — the effective limit becomesPermitLimit x replica count.
Counter keys are {KeyPrefix}:{ServiceName}:{bucket}:{scope}:{identity}, where bucket is
Controller/Action or group:{name}.
Rejections
429 Too Many Requests, Retry-After, and:
{ "error": "rate_limit_exceeded", "message": "Too many requests. Please retry later.", "retryAfterSeconds": 30 }
Returned as an ObjectResult, not thrown, so GlobalExceptionHandlerMiddleware does not rewrite it
into a 500.
Operating it
- Configuration is read with
reloadOnChange: false, so changes take effect on restart (a normal rollout), not live. - At startup the whole policy table is logged — one
Rate limit active for {RateLimitEndpoint} via {RateLimitSelector}line per limited action, then a summary ({LimitedCount} of {ActionCount} action(s) limited). Read the limits in force off the log instead of inferring them from traffic. - Selectors matching no action are logged as warnings. A typo like
SelectNetworkQuery/SearchCustomersotherwise leaves the endpoint silently unlimited. - Rejections log
Rate limit exceeded for {RateLimitEndpoint} on partition {RateLimitPartition}. Watch that in stg/uat before tightening prod.
Suggested starting points
| Endpoint kind | Suggestion |
|---|---|
| Ordinary authenticated read | 100 / 60s, FixedWindow, User |
| Search / enumeration (returns other people's data by term) | 60 / 60s, SlidingWindow |
| Backed by a third-party API or a slow store | 30 / 60s, SlidingWindow, ideally a Shared group |
| Cheap counter the UI polls | 300 / 60s |
| Anonymous-reachable | 20 / 60s, Scope: Ip |
Budgets are per endpoint per caller, so a page load hitting ten endpoints spends one request from each of ten budgets, not ten from one.
Build, test, publish
dotnet test
dotnet pack src/SeliseBlocks.RateLimiting/SeliseBlocks.RateLimiting.csproj -c Release -p:Version=1.0.0 -o artifacts
dotnet nuget push artifacts/SeliseBlocks.RateLimiting.1.0.0.nupkg -s https://nuget.seliseblocks.com/v3/index.json -k $env:SELISE_NUGET_API_KEY
Publish to the feed the services actually restore from. Container builds use only what is in
each service's config/NuGetPackageSource.Config — for the query service that is nuget.org plus
https://nuget.seliseblocks.com/v3/index.json. A package pushed anywhere else (for example the
nuget.selise.biz/nuget source registered on developer machines) resolves locally and then fails in
Docker. If the package belongs on a different feed, add that feed to each consuming service's
NuGetPackageSource.Config in the same change.
Bump Version in Directory.Build.props (or pass -p:Version=) for every publish; the feed will
reject a duplicate version. azure-pipelines.yml does the same on a tag.
Consuming it before it is published
To try a change against a service without pushing, pack locally and restore through a config that adds the output folder as a source:
dotnet pack src/SeliseBlocks.RateLimiting/SeliseBlocks.RateLimiting.csproj -c Release -p:Version=1.0.0 -o artifacts
dotnet restore <service>.sln --configfile local-nuget.config # local-nuget.config adds .../artifacts
No packages depend on SeliseBlocks.RateLimiting.
.NET 10.0
- SeliseBlocks.Genesis.Framework (>= 10.0.0.2)
- StackExchange.Redis (>= 2.7.10)
.NET 8.0
- SeliseBlocks.Genesis.Framework (>= 8.0.1.1)
- StackExchange.Redis (>= 2.7.10)
| Version | Downloads | Last updated |
|---|---|---|
| 10.1.1.1 | 428 | 09/13/2026 |