mirror of https://github.com/abpframework/abp.git
committed by
GitHub
2 changed files with 188 additions and 0 deletions
@ -0,0 +1,188 @@ |
|||
# Operation Rate Limiting in ABP Framework |
|||
|
|||
Almost every user-facing system eventually runs into the same problem: **some operations cannot be allowed to run without limits**. |
|||
|
|||
Sometimes it's a cost issue — sending an SMS costs money, and generating a report hammers the database. Sometimes it's security — a login endpoint with no attempt limit is an open invitation for brute-force attacks. And sometimes it's a matter of fairness — your paid plan says "up to 100 data exports per month," and you need to actually enforce that. |
|||
|
|||
What all these cases have in common is that the thing being limited isn't an HTTP request — it's a *business operation*, performed by a specific *who*, doing a specific *what*, against a specific *resource*. |
|||
|
|||
ASP.NET Core ships with a built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) that sits in the HTTP pipeline. It's excellent for broad API protection — throttling requests per IP to fend off bots or DDoS traffic. But it only sees HTTP requests. It can tell you how many requests came from an IP address; it cannot tell you: |
|||
|
|||
- **"How many verification codes has this phone number received today?"** The moment the user switches networks, the counter resets — completely useless |
|||
- **"How many reports has this user exported today?"** Switching from mobile to desktop gives them a fresh counter |
|||
- **"How many times has someone tried to log in as `alice`?"** An attacker rotating through dozens of IPs will never hit the per-IP limit |
|||
|
|||
There's another gap: some rate-limiting logic has no corresponding HTTP endpoint at all — it lives inside an application service method called by multiple endpoints, or triggered by a background job. HTTP middleware has no place to hook in. |
|||
|
|||
Real-world requirements tend to look like this: |
|||
|
|||
- The same phone number can receive at most 3 verification codes per hour, regardless of which device or IP the request comes from |
|||
- Each user can generate at most 2 monthly sales reports per day, because a single report query scans millions of records |
|||
- Login attempts are limited to 5 failures per username per 5 minutes, *and* 20 failures per IP per hour — two independent counters, both enforced simultaneously |
|||
- Free-tier users get 50 AI calls per month, paid users get 500 — this is a product-defined quota, not a security measure |
|||
- Your system integrates with an LLM provider (OpenAI, Azure OpenAI, etc.) where every call has a real dollar cost. Without per-user or per-tenant limits, a single user can exhaust your monthly budget overnight |
|||
|
|||
The pattern is clear: the identity being throttled is a **business identity** — a user, a phone number, a resource ID — not an IP address. And the action being throttled is a **business operation**, not an HTTP request. |
|||
|
|||
ABP Framework's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what. |
|||
|
|||
Add the package to your project: |
|||
|
|||
```bash |
|||
abp add-package Volo.Abp.OperationRateLimiting |
|||
``` |
|||
|
|||
> Operation Rate Limiting is available starting from **ABP Framework 10.3**. See the [pull request](https://github.com/abpframework/abp/pull/25024) for details. |
|||
|
|||
## Defining a Policy |
|||
|
|||
The model is straightforward: define a named policy in `ConfigureServices`, then call `CheckAsync` wherever you need to enforce it. |
|||
|
|||
Name your policies after the business action they protect — `"SendSmsCode"`, `"GenerateReport"`, `"CallAI"`. A clear name makes the intent obvious at the call site, and avoids the mystery of something like `"policy1"`. |
|||
|
|||
```csharp |
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
options.AddPolicy("SendSmsCode", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByParameter(); |
|||
}); |
|||
}); |
|||
``` |
|||
|
|||
- `WithFixedWindow` sets the time window and maximum count — here, at most 1 call per minute |
|||
- `PartitionByParameter` means each distinct value you pass at call time (such as a phone number) gets its own independent counter |
|||
|
|||
Then inject `IOperationRateLimitingChecker` and call `CheckAsync` at the top of the method you want to protect: |
|||
|
|||
```csharp |
|||
public class SmsAppService : ApplicationService |
|||
{ |
|||
private readonly IOperationRateLimitingChecker _rateLimitChecker; |
|||
|
|||
public SmsAppService(IOperationRateLimitingChecker rateLimitChecker) |
|||
{ |
|||
_rateLimitChecker = rateLimitChecker; |
|||
} |
|||
|
|||
public async Task SendCodeAsync(string phoneNumber) |
|||
{ |
|||
await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber); |
|||
|
|||
// Limit not exceeded — proceed with sending the SMS |
|||
} |
|||
} |
|||
``` |
|||
|
|||
`CheckAsync` checks the current usage against the limit and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is already exceeded. If the check passes, it then increments the counter and proceeds. ABP's exception pipeline catches this automatically and returns a standard error response. Put `CheckAsync` first — the rate limit check is the gate, and everything else only runs if it passes. |
|||
|
|||
## Choosing a Partition Type |
|||
|
|||
The partition type controls **how counters are isolated from each other** — it's the most important decision when setting up a policy, because it determines *what dimension you're counting across*. |
|||
|
|||
Getting this wrong can make your rate limiting completely ineffective. Using `PartitionByClientIp` for SMS verification? An attacker just needs to switch networks. Using `PartitionByCurrentUser` for a login endpoint? There's no current user before login, so the counter has nowhere to land. |
|||
|
|||
- **`PartitionByParameter`** — uses the value you explicitly pass as the partition key. This is the most flexible option. Pass a phone number, an email address, a resource ID, or any business identifier you have at hand. It's the right choice whenever you know exactly what the "who" is. |
|||
- **`PartitionByCurrentUser`** — uses the authenticated user's ID, with no value to pass. Perfect for "each user gets N per day" scenarios where user identity is all you need. |
|||
- **`PartitionByClientIp`** — uses the client's IP address. Don't rely on this alone — it's too easy to rotate. Use it as a secondary layer alongside another partition type, as in the login example below. |
|||
- **`PartitionByEmail`** and **`PartitionByPhoneNumber`** — designed for pre-authentication flows where the user isn't logged in yet. They prefer the `Parameter` value you explicitly pass, and fall back to the current user's email or phone number if none is provided. |
|||
- **`PartitionBy`** — a custom async delegate that can produce any partition key you need. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string. |
|||
|
|||
> The rule of thumb: partition by the identity of whoever's behavior you're trying to limit. |
|||
|
|||
## Combining Rules in One Policy |
|||
|
|||
A single rule covers most cases, but sometimes you need to enforce limits across multiple dimensions simultaneously. Login protection is the textbook example: throttling by username alone doesn't stop an attacker from targeting many accounts; throttling by IP alone doesn't stop an attacker with a botnet. You need both, at the same time. |
|||
|
|||
```csharp |
|||
options.AddPolicy("Login", policy => |
|||
{ |
|||
// Rule 1: at most 5 attempts per username per 5-minute window |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5) |
|||
.PartitionByParameter()); |
|||
|
|||
// Rule 2: at most 20 attempts per IP per hour, counted independently |
|||
policy.AddRule(rule => rule |
|||
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) |
|||
.PartitionByClientIp()); |
|||
}); |
|||
``` |
|||
|
|||
The two counters are completely independent. If `alice` fails 5 times, her account is locked — but other accounts from the same IP are unaffected. If an IP accumulates 20 failures, it's blocked — but `alice` can still be targeted from other IPs until their own counters fill up. |
|||
|
|||
When multiple rules are present, the module uses a two-phase approach: it checks all rules first, and only increments counters if every rule passes. This prevents a rule from consuming quota on a request that would have been rejected by another rule anyway. |
|||
|
|||
## Beyond Just Checking |
|||
|
|||
Not every scenario calls for throwing an exception. `IOperationRateLimitingChecker` provides three additional methods for more nuanced control. |
|||
|
|||
**`IsAllowedAsync`** performs a read-only check — it returns `true` or `false` without touching any counter. The most common use case is UI pre-checking: when a user opens the "send verification code" page, check the limit first. If they've already hit it, disable the button and show a countdown immediately, rather than making them click and get an error. That's a meaningfully better experience. |
|||
|
|||
```csharp |
|||
var isAllowed = await _rateLimitChecker.IsAllowedAsync("SendSmsCode", phoneNumber); |
|||
``` |
|||
|
|||
**`GetStatusAsync`** also reads without incrementing, but returns richer data: `RemainingCount`, `RetryAfter`, and `CurrentCount`. This is what you need to build quota displays — "You have 2 exports remaining today" or "Please try again in 47 seconds" — which are far friendlier than a raw 429. |
|||
|
|||
```csharp |
|||
var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", phoneNumber); |
|||
// status.RemainingCount, status.RetryAfter, status.IsAllowed ... |
|||
``` |
|||
|
|||
**`ResetAsync`** clears the counter for a given policy and context. Useful in admin panels where support staff can manually unblock a user, or in test environments where you need to reset state between runs. |
|||
|
|||
```csharp |
|||
await _rateLimitChecker.ResetAsync("SendSmsCode", phoneNumber); |
|||
``` |
|||
|
|||
## When the Limit Is Hit |
|||
|
|||
When `CheckAsync` triggers, it throws `AbpOperationRateLimitingException`, which: |
|||
|
|||
- Inherits from `BusinessException` and maps to HTTP **429 Too Many Requests** |
|||
- Is handled automatically by ABP's exception pipeline |
|||
- Carries useful metadata: `RetryAfterSeconds`, `RemainingCount`, `MaxCount`, `CurrentCount` |
|||
|
|||
By default, the error code sent to the client is a generic one from the module. If you want each operation to produce its own localized message — "Too many verification code requests, please wait before trying again" instead of a generic error — assign a custom error code to the policy: |
|||
|
|||
```csharp |
|||
options.AddPolicy("SendSmsCode", policy => |
|||
{ |
|||
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
|||
.PartitionByParameter() |
|||
.WithErrorCode("App:SmsCodeLimit"); |
|||
}); |
|||
``` |
|||
|
|||
> For details on mapping error codes to localized messages, see [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) in the ABP docs. |
|||
|
|||
## Turning It Off in Development |
|||
|
|||
Rate limiting and local development don't mix well. When you're iterating quickly and calling the same endpoint a dozen times to test something, getting blocked by a 429 every few seconds is genuinely painful. Disable the module in your development environment: |
|||
|
|||
```csharp |
|||
public override void ConfigureServices(ServiceConfigurationContext context) |
|||
{ |
|||
var hostEnvironment = context.Services.GetHostingEnvironment(); |
|||
|
|||
Configure<AbpOperationRateLimitingOptions>(options => |
|||
{ |
|||
if (hostEnvironment.IsDevelopment()) |
|||
{ |
|||
options.IsEnabled = false; |
|||
} |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
## Summary |
|||
|
|||
ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Call `CheckAsync` wherever you need it. Counter storage, distributed locking, and exception handling are all taken care of. |
|||
|
|||
## References |
|||
|
|||
- [Operation Rate Limiting](https://abp.io/docs/latest/framework/infrastructure/operation-rate-limiting) |
|||
- [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) |
|||
- [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) |
|||
|
After Width: | Height: | Size: 126 KiB |
Loading…
Reference in new issue