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). Keep PermitLimit/WindowSeconds on 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: FixedWindow is one atomic INCR but allows up to 2x the limit across a window boundary — fine for capacity protection. SlidingWindow costs a sorted-set entry per request and holds the limit at the edges — use it where the limit is the security control.
  • Store: Redis uses IAppSettings.KeyStorageConnectionString via StackExchange.Redis (IRedisClient has no atomic INCR). The connection is lazy, so a Redis outage at startup cannot stop the service from booting. InMemory is per-process — the effective limit becomes PermitLimit 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/SearchCustomers otherwise 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.

Version Downloads Last updated
10.1.1.1 428 09/13/2026