mirror of https://github.com/abpframework/abp.git
2 changed files with 484 additions and 0 deletions
@ -0,0 +1,480 @@ |
|||||
|
````json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Learn how to use the Operation Rate Limiting module in ABP Framework to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks." |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
# Operation Rate Limiting |
||||
|
|
||||
|
ABP provides an operation rate limiting system that allows you to control the frequency of specific operations in your application. You may need operation rate limiting for several reasons: |
||||
|
|
||||
|
* Do not allow sending an SMS verification code to the same phone number more than 3 times in an hour. |
||||
|
* Do not allow generating a "monthly sales report" more than 2 times per day for each user (if generating the report is resource-intensive). |
||||
|
* Restrict login attempts per IP address to prevent brute-force attacks. |
||||
|
|
||||
|
> This is not for [ASP.NET Core's built-in rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) which works at the HTTP request pipeline level. This module works at the **application/domain code level** and is called explicitly from your services. See the [Combining with ASP.NET Core Rate Limiting](#combining-with-aspnet-core-rate-limiting) section for a comparison. |
||||
|
|
||||
|
## Installation |
||||
|
|
||||
|
You can open a command-line terminal and type the following command to install the [Volo.Abp.OperationRateLimiting](https://www.nuget.org/packages/Volo.Abp.OperationRateLimiting) package into your project: |
||||
|
|
||||
|
````bash |
||||
|
abp add-package Volo.Abp.OperationRateLimiting |
||||
|
```` |
||||
|
|
||||
|
> If you haven't done it yet, you first need to install the [ABP CLI](../../../cli). |
||||
|
|
||||
|
## Quick Start |
||||
|
|
||||
|
This section shows the basic usage of the operation rate limiting system with a simple example. |
||||
|
|
||||
|
### Defining a Policy |
||||
|
|
||||
|
First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md): |
||||
|
|
||||
|
````csharp |
||||
|
Configure<AbpOperationRateLimitingOptions>(options => |
||||
|
{ |
||||
|
options.AddPolicy("SendSmsCode", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
* `"SendSmsCode"` is a unique name for this policy. |
||||
|
* `WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)` means at most **1 request per minute**. |
||||
|
* `PartitionByParameter()` means the counter is keyed by the parameter you pass at check time (e.g., a phone number), so different phone numbers have independent counters. |
||||
|
|
||||
|
### Checking the Limit |
||||
|
|
||||
|
Then inject `IOperationRateLimitingChecker` and call `CheckAsync` in your service: |
||||
|
|
||||
|
````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", |
||||
|
new OperationRateLimitingContext { Parameter = phoneNumber }); |
||||
|
|
||||
|
// If we reach here, the limit was not exceeded. |
||||
|
// Send the SMS code... |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
* `CheckAsync` increments the counter and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded. |
||||
|
* Each phone number has its own counter because we used `PartitionByParameter()`. |
||||
|
|
||||
|
That's the basic usage. The following sections explain each concept in detail. |
||||
|
|
||||
|
## Defining Policies |
||||
|
|
||||
|
Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy. |
||||
|
|
||||
|
### Single-Rule Policies |
||||
|
|
||||
|
For simple scenarios, use the `WithFixedWindow` shortcut directly on the policy builder: |
||||
|
|
||||
|
````csharp |
||||
|
options.AddPolicy("SendSmsCode", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
### Multi-Rule Policies |
||||
|
|
||||
|
Use `AddRule` to combine multiple rules. All rules are checked together (**AND** logic) — a request is allowed only when **all** rules pass: |
||||
|
|
||||
|
````csharp |
||||
|
options.AddPolicy("Login", policy => |
||||
|
{ |
||||
|
// Rule 1: Max 5 attempts per 5 minutes per username |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
// Rule 2: Max 20 attempts per hour per IP |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) |
||||
|
.PartitionByClientIp()); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
> When multiple rules are present, the module uses a **two-phase check**: it first verifies all rules without incrementing counters, then increments only if all rules pass. This prevents wasted quota when one rule would block the request. |
||||
|
|
||||
|
### Custom Error Code |
||||
|
|
||||
|
By default, the exception uses the error code `Volo.Abp.OperationRateLimiting:010001`. You can override it per policy: |
||||
|
|
||||
|
````csharp |
||||
|
options.AddPolicy("SendSmsCode", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
||||
|
.PartitionByParameter() |
||||
|
.WithErrorCode("App:SmsCodeLimit"); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
## Partition Types |
||||
|
|
||||
|
Each rule must specify a **partition type** that determines how requests are grouped. Requests with different partition keys have independent counters. |
||||
|
|
||||
|
### PartitionByParameter |
||||
|
|
||||
|
Uses the `Parameter` value from the context you pass to `CheckAsync`: |
||||
|
|
||||
|
````csharp |
||||
|
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
||||
|
.PartitionByParameter(); |
||||
|
|
||||
|
// Each phone number has its own counter |
||||
|
await checker.CheckAsync("SendSmsCode", |
||||
|
new OperationRateLimitingContext { Parameter = phoneNumber }); |
||||
|
```` |
||||
|
|
||||
|
### PartitionByCurrentUser |
||||
|
|
||||
|
Uses `ICurrentUser.Id` as the partition key. The user must be authenticated: |
||||
|
|
||||
|
````csharp |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10) |
||||
|
.PartitionByCurrentUser(); |
||||
|
```` |
||||
|
|
||||
|
> If you need to check rate limits for a specific user (e.g., admin checking another user's limit), use `PartitionByParameter()` and pass the user ID as the `Parameter`. |
||||
|
|
||||
|
### PartitionByCurrentTenant |
||||
|
|
||||
|
Uses `ICurrentTenant.Id` as the partition key. Uses `"host"` for the host side when no tenant is active: |
||||
|
|
||||
|
````csharp |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) |
||||
|
.PartitionByCurrentTenant(); |
||||
|
```` |
||||
|
|
||||
|
### PartitionByClientIp |
||||
|
|
||||
|
Uses `IWebClientInfoProvider.ClientIpAddress` as the partition key: |
||||
|
|
||||
|
````csharp |
||||
|
policy.WithFixedWindow(TimeSpan.FromMinutes(15), maxCount: 10) |
||||
|
.PartitionByClientIp(); |
||||
|
```` |
||||
|
|
||||
|
> This requires an ASP.NET Core environment. In non-web scenarios, the IP address cannot be determined and an exception will be thrown. Use `PartitionByParameter()` if you need to pass the IP explicitly. |
||||
|
|
||||
|
### PartitionByEmail |
||||
|
|
||||
|
Resolves from `context.Parameter` first, then falls back to `ICurrentUser.Email`: |
||||
|
|
||||
|
````csharp |
||||
|
policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) |
||||
|
.PartitionByEmail(); |
||||
|
|
||||
|
// For unauthenticated users, pass the email explicitly: |
||||
|
await checker.CheckAsync("SendEmailCode", |
||||
|
new OperationRateLimitingContext { Parameter = email }); |
||||
|
```` |
||||
|
|
||||
|
### PartitionByPhoneNumber |
||||
|
|
||||
|
Works the same way as `PartitionByEmail`: resolves from `context.Parameter` first, then falls back to `ICurrentUser.PhoneNumber`. |
||||
|
|
||||
|
### Custom Partition (PartitionBy) |
||||
|
|
||||
|
You can provide a custom async function to generate the partition key. The async signature allows you to perform database queries or other I/O operations: |
||||
|
|
||||
|
````csharp |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) |
||||
|
.PartitionBy(ctx => Task.FromResult( |
||||
|
$"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}")); |
||||
|
```` |
||||
|
|
||||
|
## Multi-Tenancy |
||||
|
|
||||
|
By default, rate limit counters are **shared across all tenants**. You can enable tenant isolation for a rule by calling `WithMultiTenancy()`: |
||||
|
|
||||
|
````csharp |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.WithMultiTenancy() |
||||
|
.PartitionByParameter()); |
||||
|
```` |
||||
|
|
||||
|
When multi-tenancy is enabled, the cache key includes the tenant ID, so each tenant has independent counters: |
||||
|
|
||||
|
* **Global key format:** `orl:{PolicyName}:{RuleKey}:{PartitionKey}` |
||||
|
* **Tenant-isolated key format:** `orl:t:{TenantId}:{PolicyName}:{RuleKey}:{PartitionKey}` |
||||
|
|
||||
|
## Checking the Limit |
||||
|
|
||||
|
Inject `IOperationRateLimitingChecker` to interact with rate limits. It provides four methods: |
||||
|
|
||||
|
### CheckAsync |
||||
|
|
||||
|
The primary method. It checks the rate limit and **increments the counter** if allowed. Throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded: |
||||
|
|
||||
|
````csharp |
||||
|
await checker.CheckAsync("SendSmsCode", |
||||
|
new OperationRateLimitingContext { Parameter = phoneNumber }); |
||||
|
```` |
||||
|
|
||||
|
### IsAllowedAsync |
||||
|
|
||||
|
A read-only check that returns `true` or `false` **without incrementing** the counter. Useful for UI pre-checks (e.g., disabling a button before the user clicks): |
||||
|
|
||||
|
````csharp |
||||
|
var isAllowed = await checker.IsAllowedAsync("SendSmsCode", |
||||
|
new OperationRateLimitingContext { Parameter = phoneNumber }); |
||||
|
```` |
||||
|
|
||||
|
### GetStatusAsync |
||||
|
|
||||
|
Returns detailed status information **without incrementing** the counter: |
||||
|
|
||||
|
````csharp |
||||
|
var status = await checker.GetStatusAsync("SendSmsCode", |
||||
|
new OperationRateLimitingContext { Parameter = phoneNumber }); |
||||
|
|
||||
|
// status.IsAllowed - whether the next request would be allowed |
||||
|
// status.RemainingCount - how many requests are left in this window |
||||
|
// status.RetryAfter - time until the window resets |
||||
|
// status.MaxCount - maximum allowed count |
||||
|
// status.CurrentCount - current usage count |
||||
|
```` |
||||
|
|
||||
|
### ResetAsync |
||||
|
|
||||
|
Resets the counter for a specific policy and context. This can be useful for administrative operations: |
||||
|
|
||||
|
````csharp |
||||
|
await checker.ResetAsync("SendSmsCode", |
||||
|
new OperationRateLimitingContext { Parameter = phoneNumber }); |
||||
|
```` |
||||
|
|
||||
|
## The Exception |
||||
|
|
||||
|
When a rate limit is exceeded, `CheckAsync` throws `AbpOperationRateLimitingException`. This exception: |
||||
|
|
||||
|
* Extends `BusinessException` and implements `IHasHttpStatusCode` with status code **429** (Too Many Requests). |
||||
|
* Is automatically handled by ABP's exception handling pipeline and serialized into the HTTP response. |
||||
|
|
||||
|
The exception includes the following data properties: |
||||
|
|
||||
|
| Key | Type | Description | |
||||
|
|-----|------|-------------| |
||||
|
| `PolicyName` | string | Name of the triggered policy | |
||||
|
| `MaxCount` | int | Maximum allowed count | |
||||
|
| `CurrentCount` | int | Current usage count | |
||||
|
| `RemainingCount` | int | Remaining allowed count | |
||||
|
| `RetryAfterSeconds` | int | Seconds until the window resets | |
||||
|
| `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes") | |
||||
|
| `WindowDurationSeconds` | int | Total window duration in seconds | |
||||
|
| `WindowDescription` | string | Localized window description | |
||||
|
| `RuleDetails` | List | Per-rule details (for multi-rule policies) | |
||||
|
|
||||
|
## Configuration |
||||
|
|
||||
|
### AbpOperationRateLimitingOptions |
||||
|
|
||||
|
`AbpOperationRateLimitingOptions` is the main options class for the operation rate limiting system: |
||||
|
|
||||
|
````csharp |
||||
|
Configure<AbpOperationRateLimitingOptions>(options => |
||||
|
{ |
||||
|
options.IsEnabled = true; |
||||
|
options.LockTimeout = TimeSpan.FromSeconds(5); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
* **`IsEnabled`** (`bool`, default: `true`): Global switch to enable or disable rate limiting. When set to `false`, all `CheckAsync` calls pass through without checking. This is useful for disabling rate limiting in development (see [below](#disabling-in-development)). |
||||
|
* **`LockTimeout`** (`TimeSpan`, default: `5 seconds`): Timeout for acquiring the distributed lock during counter increment operations. |
||||
|
|
||||
|
## Advanced Usage |
||||
|
|
||||
|
### Disabling in Development |
||||
|
|
||||
|
You may want to disable rate limiting during development to avoid being blocked while testing: |
||||
|
|
||||
|
````csharp |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
var hostEnvironment = context.Services.GetHostingEnvironment(); |
||||
|
|
||||
|
Configure<AbpOperationRateLimitingOptions>(options => |
||||
|
{ |
||||
|
if (hostEnvironment.IsDevelopment()) |
||||
|
{ |
||||
|
options.IsEnabled = false; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### Ban Policy (maxCount: 0) |
||||
|
|
||||
|
Setting `maxCount` to `0` creates a ban policy that blocks all requests for the specified duration: |
||||
|
|
||||
|
````csharp |
||||
|
options.AddPolicy("BlockedUser", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(24), maxCount: 0) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
### Passing Extra Properties |
||||
|
|
||||
|
Use `ExtraProperties` on `OperationRateLimitingContext` to pass additional context data. These values are available in custom partition resolvers and are included in the exception data when the limit is exceeded: |
||||
|
|
||||
|
````csharp |
||||
|
await checker.CheckAsync("ApiCall", new OperationRateLimitingContext |
||||
|
{ |
||||
|
Parameter = apiEndpoint, |
||||
|
ExtraProperties = |
||||
|
{ |
||||
|
["DeviceId"] = deviceId, |
||||
|
["ClientVersion"] = clientVersion |
||||
|
} |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
### Pre-checking Before Expensive Operations |
||||
|
|
||||
|
Use `IsAllowedAsync` or `GetStatusAsync` to check the limit **before** performing expensive work (e.g., validating input or querying the database): |
||||
|
|
||||
|
````csharp |
||||
|
public async Task<SendCodeResultDto> SendCodeAsync(string phoneNumber) |
||||
|
{ |
||||
|
var context = new OperationRateLimitingContext { Parameter = phoneNumber }; |
||||
|
|
||||
|
// Check limit before doing any work |
||||
|
var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", context); |
||||
|
|
||||
|
if (!status.IsAllowed) |
||||
|
{ |
||||
|
return new SendCodeResultDto |
||||
|
{ |
||||
|
Success = false, |
||||
|
RetryAfterSeconds = (int)(status.RetryAfter?.TotalSeconds ?? 0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// Now do the actual work and increment the counter |
||||
|
await _rateLimitChecker.CheckAsync("SendSmsCode", context); |
||||
|
|
||||
|
await _smsSender.SendAsync(phoneNumber, GenerateCode()); |
||||
|
return new SendCodeResultDto { Success = true }; |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
> `IsAllowedAsync` and `GetStatusAsync` are read-only — they do not increment the counter. Only `CheckAsync` increments. |
||||
|
|
||||
|
### Checking on Behalf of Another User |
||||
|
|
||||
|
`PartitionByCurrentUser()`, `PartitionByCurrentTenant()`, and `PartitionByClientIp()` always resolve from their respective services (`ICurrentUser`, `ICurrentTenant`, `IWebClientInfoProvider`) and do not accept explicit overrides. This design avoids partition key conflicts in [composite policies](#multi-rule-policies) where `Parameter` is shared across all rules. |
||||
|
|
||||
|
If you need to check or enforce rate limits for a **specific user, tenant, or IP**, define the policy with `PartitionByParameter()` and pass the value explicitly: |
||||
|
|
||||
|
````csharp |
||||
|
// Policy definition: use PartitionByParameter for explicit control |
||||
|
options.AddPolicy("UserApiLimit", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
```` |
||||
|
|
||||
|
````csharp |
||||
|
// Check current user's limit |
||||
|
await checker.CheckAsync("UserApiLimit", |
||||
|
new OperationRateLimitingContext { Parameter = CurrentUser.Id.ToString() }); |
||||
|
|
||||
|
// Admin checking another user's limit |
||||
|
await checker.CheckAsync("UserApiLimit", |
||||
|
new OperationRateLimitingContext { Parameter = targetUserId.ToString() }); |
||||
|
|
||||
|
// Check a specific IP in a background job |
||||
|
await checker.CheckAsync("UserApiLimit", |
||||
|
new OperationRateLimitingContext { Parameter = ipAddress }); |
||||
|
```` |
||||
|
|
||||
|
This approach gives you full flexibility while keeping the API simple — `PartitionByCurrentUser()` is a convenience shortcut for "always use the current authenticated user", and `PartitionByParameter()` is for "I want to specify the value explicitly". |
||||
|
|
||||
|
### Combining with ASP.NET Core Rate Limiting |
||||
|
|
||||
|
This module and ASP.NET Core's built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) serve different purposes and can be used together: |
||||
|
|
||||
|
| | ASP.NET Core Rate Limiting | Operation Rate Limiting | |
||||
|
|---|---|---| |
||||
|
| **Level** | HTTP request pipeline | Application/domain code | |
||||
|
| **Scope** | All incoming requests | Specific business operations | |
||||
|
| **Usage** | Middleware (automatic) | Explicit `CheckAsync` calls | |
||||
|
| **Typical use** | API throttling, DDoS protection | Business logic limits (SMS, reports) | |
||||
|
|
||||
|
A common pattern is to use ASP.NET Core middleware for broad API protection and this module for fine-grained business operation limits. |
||||
|
|
||||
|
## Extensibility |
||||
|
|
||||
|
### Custom Store |
||||
|
|
||||
|
The default store uses ABP's `IDistributedCache`. You can replace it by implementing `IOperationRateLimitingStore`: |
||||
|
|
||||
|
````csharp |
||||
|
public class MyCustomStore : IOperationRateLimitingStore, ITransientDependency |
||||
|
{ |
||||
|
public Task<OperationRateLimitingStoreResult> IncrementAsync( |
||||
|
string key, TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
// Your custom implementation (e.g., Redis Lua script for atomicity) |
||||
|
} |
||||
|
|
||||
|
public Task<OperationRateLimitingStoreResult> GetAsync( |
||||
|
string key, TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
// Read-only check |
||||
|
} |
||||
|
|
||||
|
public Task ResetAsync(string key) |
||||
|
{ |
||||
|
// Reset the counter |
||||
|
} |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
ABP's [dependency injection](../../fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one. |
||||
|
|
||||
|
### Custom Rule |
||||
|
|
||||
|
You can implement custom rate limiting algorithms (e.g., sliding window, token bucket) by implementing `IOperationRateLimitingRule` and registering it with `AddRule<TRule>()`: |
||||
|
|
||||
|
````csharp |
||||
|
policy.AddRule<MySlidingWindowRule>(); |
||||
|
```` |
||||
|
|
||||
|
### Custom Formatter |
||||
|
|
||||
|
Replace `IOperationRateLimitingFormatter` to customize how time durations are displayed in error messages (e.g., "5 minutes", "2 hours 30 minutes"). |
||||
|
|
||||
|
### Custom Policy Provider |
||||
|
|
||||
|
Replace `IOperationRateLimitingPolicyProvider` to load policies from a database or external configuration source instead of the in-memory options. |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) |
||||
|
* [Distributed Caching](../../fundamentals/caching.md) |
||||
|
* [Exception Handling](../../fundamentals/exception-handling.md) |
||||
Loading…
Reference in new issue