mirror of https://github.com/abpframework/abp.git
71 changed files with 4725 additions and 1 deletions
@ -0,0 +1,492 @@ |
|||||
|
````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", 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()`. |
||||
|
* Passing `phoneNumber` directly is a shortcut for `new OperationRateLimitingContext { Parameter = phoneNumber }`. Extension methods are provided for all four methods (`CheckAsync`, `IsAllowedAsync`, `GetStatusAsync`, `ResetAsync`) when you only need to pass a `parameter` string. |
||||
|
|
||||
|
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, partition keys do not include tenant information — for partition types like `PartitionByParameter`, `PartitionByCurrentUser`, `PartitionByClientIp`, etc., counters are shared across tenants unless you call `WithMultiTenancy()`. Note that `PartitionByCurrentTenant()` is inherently per-tenant since the partition key is the tenant ID itself, and `PartitionByClientIp()` is typically kept global since the same IP should share a counter regardless of tenant. |
||||
|
|
||||
|
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 uses one of two error codes depending on the policy type: |
||||
|
|
||||
|
| Error Code | Constant | When Used | |
||||
|
|---|---|---| |
||||
|
| `Volo.Abp.OperationRateLimiting:010001` | `AbpOperationRateLimitingErrorCodes.ExceedLimit` | Regular rate limit exceeded (has a retry-after window) | |
||||
|
| `Volo.Abp.OperationRateLimiting:010002` | `AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently` | Ban policy (`maxCount: 0`, permanently denied) | |
||||
|
|
||||
|
You can override the error code per policy using `WithErrorCode()`. When a custom code is set, it is always used regardless of the policy type. |
||||
|
|
||||
|
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 (`0` for ban policies) | |
||||
|
| `RetryAfterMinutes` | int | Minutes until the window resets, rounded down (`0` for ban policies) | |
||||
|
| `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes"); absent for ban policies | |
||||
|
| `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 permanently denies all requests regardless of the window duration. The `RetryAfter` value will be `null` since there is no window to wait for. The exception uses the error code `Volo.Abp.OperationRateLimiting:010002` (`AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently`) with the message "Operation rate limit exceeded. This request is permanently denied.": |
||||
|
|
||||
|
````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) |
||||
@ -0,0 +1,3 @@ |
|||||
|
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"> |
||||
|
<ConfigureAwait ContinueOnCapturedContext="false" /> |
||||
|
</Weavers> |
||||
@ -0,0 +1,32 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<Import Project="..\..\..\configureawait.props" /> |
||||
|
<Import Project="..\..\..\common.props" /> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0</TargetFrameworks> |
||||
|
<Nullable>enable</Nullable> |
||||
|
<WarningsAsErrors>Nullable</WarningsAsErrors> |
||||
|
<AssemblyName>Volo.Abp.OperationRateLimiting</AssemblyName> |
||||
|
<PackageId>Volo.Abp.OperationRateLimiting</PackageId> |
||||
|
<AssetTargetFallback>$(AssetTargetFallback);portable-net45+win8+wp8+wpa81;</AssetTargetFallback> |
||||
|
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute> |
||||
|
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute> |
||||
|
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute> |
||||
|
<RootNamespace /> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<None Remove="Volo\Abp\OperationRateLimiting\Localization\*.json" /> |
||||
|
<EmbeddedResource Include="Volo\Abp\OperationRateLimiting\Localization\*.json" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\Volo.Abp.AspNetCore.Abstractions\Volo.Abp.AspNetCore.Abstractions.csproj" /> |
||||
|
<ProjectReference Include="..\Volo.Abp.Caching\Volo.Abp.Caching.csproj" /> |
||||
|
<ProjectReference Include="..\Volo.Abp.DistributedLocking.Abstractions\Volo.Abp.DistributedLocking.Abstractions.csproj" /> |
||||
|
<ProjectReference Include="..\Volo.Abp.Localization\Volo.Abp.Localization.csproj" /> |
||||
|
<ProjectReference Include="..\Volo.Abp.Security\Volo.Abp.Security.csproj" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
@ -0,0 +1,14 @@ |
|||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public static class AbpOperationRateLimitingErrorCodes |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Default error code for rate limit exceeded (with a retry-after window).
|
||||
|
/// </summary>
|
||||
|
public const string ExceedLimit = "Volo.Abp.OperationRateLimiting:010001"; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Error code for ban policy (maxCount: 0) where requests are permanently denied.
|
||||
|
/// </summary>
|
||||
|
public const string ExceedLimitPermanently = "Volo.Abp.OperationRateLimiting:010002"; |
||||
|
} |
||||
@ -0,0 +1,42 @@ |
|||||
|
using Volo.Abp.AspNetCore; |
||||
|
using Volo.Abp.Caching; |
||||
|
using Volo.Abp.DistributedLocking; |
||||
|
using Volo.Abp.Localization; |
||||
|
using Volo.Abp.Localization.ExceptionHandling; |
||||
|
using Volo.Abp.Modularity; |
||||
|
using Volo.Abp.Security; |
||||
|
using Volo.Abp.VirtualFileSystem; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
[DependsOn( |
||||
|
typeof(AbpCachingModule), |
||||
|
typeof(AbpLocalizationModule), |
||||
|
typeof(AbpSecurityModule), |
||||
|
typeof(AbpAspNetCoreAbstractionsModule), |
||||
|
typeof(AbpDistributedLockingAbstractionsModule) |
||||
|
)] |
||||
|
public class AbpOperationRateLimitingModule : AbpModule |
||||
|
{ |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
Configure<AbpVirtualFileSystemOptions>(options => |
||||
|
{ |
||||
|
options.FileSets.AddEmbedded<AbpOperationRateLimitingModule>(); |
||||
|
}); |
||||
|
|
||||
|
Configure<AbpLocalizationOptions>(options => |
||||
|
{ |
||||
|
options.Resources |
||||
|
.Add<AbpOperationRateLimitingResource>("en") |
||||
|
.AddVirtualJson("/Volo/Abp/OperationRateLimiting/Localization"); |
||||
|
}); |
||||
|
|
||||
|
Configure<AbpExceptionLocalizationOptions>(options => |
||||
|
{ |
||||
|
options.MapCodeNamespace( |
||||
|
"Volo.Abp.OperationRateLimiting", |
||||
|
typeof(AbpOperationRateLimitingResource)); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class AbpOperationRateLimitingOptions |
||||
|
{ |
||||
|
public bool IsEnabled { get; set; } = true; |
||||
|
|
||||
|
public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5); |
||||
|
|
||||
|
public Dictionary<string, OperationRateLimitingPolicy> Policies { get; } = new(); |
||||
|
|
||||
|
public void AddPolicy(string name, Action<OperationRateLimitingPolicyBuilder> configure) |
||||
|
{ |
||||
|
var builder = new OperationRateLimitingPolicyBuilder(name); |
||||
|
configure(builder); |
||||
|
Policies[name] = builder.Build(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
using Volo.Abp.Localization; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
[LocalizationResourceName("AbpOperationRateLimiting")] |
||||
|
public class AbpOperationRateLimitingResource |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public interface IOperationRateLimitingChecker |
||||
|
{ |
||||
|
Task CheckAsync(string policyName, OperationRateLimitingContext? context = null); |
||||
|
|
||||
|
Task<bool> IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null); |
||||
|
|
||||
|
Task<OperationRateLimitingResult> GetStatusAsync(string policyName, OperationRateLimitingContext? context = null); |
||||
|
|
||||
|
Task ResetAsync(string policyName, OperationRateLimitingContext? context = null); |
||||
|
} |
||||
@ -0,0 +1,277 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Volo.Abp.AspNetCore.WebClientInfo; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.MultiTenancy; |
||||
|
using Volo.Abp.Users; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITransientDependency |
||||
|
{ |
||||
|
protected AbpOperationRateLimitingOptions Options { get; } |
||||
|
protected IOperationRateLimitingPolicyProvider PolicyProvider { get; } |
||||
|
protected IServiceProvider ServiceProvider { get; } |
||||
|
protected IOperationRateLimitingStore Store { get; } |
||||
|
protected ICurrentUser CurrentUser { get; } |
||||
|
protected ICurrentTenant CurrentTenant { get; } |
||||
|
protected IWebClientInfoProvider WebClientInfoProvider { get; } |
||||
|
|
||||
|
public OperationRateLimitingChecker( |
||||
|
IOptions<AbpOperationRateLimitingOptions> options, |
||||
|
IOperationRateLimitingPolicyProvider policyProvider, |
||||
|
IServiceProvider serviceProvider, |
||||
|
IOperationRateLimitingStore store, |
||||
|
ICurrentUser currentUser, |
||||
|
ICurrentTenant currentTenant, |
||||
|
IWebClientInfoProvider webClientInfoProvider) |
||||
|
{ |
||||
|
Options = options.Value; |
||||
|
PolicyProvider = policyProvider; |
||||
|
ServiceProvider = serviceProvider; |
||||
|
Store = store; |
||||
|
CurrentUser = currentUser; |
||||
|
CurrentTenant = currentTenant; |
||||
|
WebClientInfoProvider = webClientInfoProvider; |
||||
|
} |
||||
|
|
||||
|
public virtual async Task CheckAsync(string policyName, OperationRateLimitingContext? context = null) |
||||
|
{ |
||||
|
if (!Options.IsEnabled) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
context = EnsureContext(context); |
||||
|
var policy = await PolicyProvider.GetAsync(policyName); |
||||
|
var rules = CreateRules(policy); |
||||
|
|
||||
|
// Phase 1: Check ALL rules without incrementing to get complete status.
|
||||
|
// Do not exit early: a later rule may have a larger RetryAfter that the caller needs to know about.
|
||||
|
var checkResults = new List<OperationRateLimitingRuleResult>(); |
||||
|
foreach (var rule in rules) |
||||
|
{ |
||||
|
checkResults.Add(await rule.CheckAsync(context)); |
||||
|
} |
||||
|
|
||||
|
if (checkResults.Any(r => !r.IsAllowed)) |
||||
|
{ |
||||
|
// Throw without incrementing any counter; RetryAfter is the max across all blocking rules.
|
||||
|
var aggregatedResult = AggregateResults(checkResults, policy); |
||||
|
ThrowRateLimitException(policy, aggregatedResult, context); |
||||
|
} |
||||
|
|
||||
|
// Phase 2: All rules passed in Phase 1 - now increment counters.
|
||||
|
// Guard against concurrent races where another request consumed the last quota
|
||||
|
// between Phase 1 and Phase 2.
|
||||
|
// Once any rule fails during increment, stop incrementing subsequent rules
|
||||
|
// to minimize wasted quota. Remaining rules use read-only check instead.
|
||||
|
var incrementResults = new List<OperationRateLimitingRuleResult>(); |
||||
|
var phase2Failed = false; |
||||
|
foreach (var rule in rules) |
||||
|
{ |
||||
|
if (phase2Failed) |
||||
|
{ |
||||
|
incrementResults.Add(await rule.CheckAsync(context)); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
var result = await rule.AcquireAsync(context); |
||||
|
incrementResults.Add(result); |
||||
|
if (!result.IsAllowed) |
||||
|
{ |
||||
|
phase2Failed = true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (phase2Failed) |
||||
|
{ |
||||
|
var aggregatedResult = AggregateResults(incrementResults, policy); |
||||
|
ThrowRateLimitException(policy, aggregatedResult, context); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<bool> IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null) |
||||
|
{ |
||||
|
if (!Options.IsEnabled) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
context = EnsureContext(context); |
||||
|
var policy = await PolicyProvider.GetAsync(policyName); |
||||
|
var rules = CreateRules(policy); |
||||
|
|
||||
|
foreach (var rule in rules) |
||||
|
{ |
||||
|
var result = await rule.CheckAsync(context); |
||||
|
if (!result.IsAllowed) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<OperationRateLimitingResult> GetStatusAsync(string policyName, OperationRateLimitingContext? context = null) |
||||
|
{ |
||||
|
if (!Options.IsEnabled) |
||||
|
{ |
||||
|
return new OperationRateLimitingResult |
||||
|
{ |
||||
|
IsAllowed = true, |
||||
|
RemainingCount = int.MaxValue, |
||||
|
MaxCount = int.MaxValue, |
||||
|
CurrentCount = 0 |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
context = EnsureContext(context); |
||||
|
var policy = await PolicyProvider.GetAsync(policyName); |
||||
|
var rules = CreateRules(policy); |
||||
|
var ruleResults = new List<OperationRateLimitingRuleResult>(); |
||||
|
|
||||
|
foreach (var rule in rules) |
||||
|
{ |
||||
|
ruleResults.Add(await rule.CheckAsync(context)); |
||||
|
} |
||||
|
|
||||
|
return AggregateResults(ruleResults, policy); |
||||
|
} |
||||
|
|
||||
|
public virtual async Task ResetAsync(string policyName, OperationRateLimitingContext? context = null) |
||||
|
{ |
||||
|
if (!Options.IsEnabled) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
context = EnsureContext(context); |
||||
|
var policy = await PolicyProvider.GetAsync(policyName); |
||||
|
var rules = CreateRules(policy); |
||||
|
|
||||
|
foreach (var rule in rules) |
||||
|
{ |
||||
|
await rule.ResetAsync(context); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected virtual OperationRateLimitingContext EnsureContext(OperationRateLimitingContext? context) |
||||
|
{ |
||||
|
context ??= new OperationRateLimitingContext(); |
||||
|
context.ServiceProvider = ServiceProvider; |
||||
|
return context; |
||||
|
} |
||||
|
|
||||
|
protected virtual List<IOperationRateLimitingRule> CreateRules(OperationRateLimitingPolicy policy) |
||||
|
{ |
||||
|
var rules = new List<IOperationRateLimitingRule>(); |
||||
|
|
||||
|
foreach (var ruleDefinition in policy.Rules) |
||||
|
{ |
||||
|
rules.Add(new FixedWindowOperationRateLimitingRule( |
||||
|
policy.Name, |
||||
|
ruleDefinition, |
||||
|
Store, |
||||
|
CurrentUser, |
||||
|
CurrentTenant, |
||||
|
WebClientInfoProvider)); |
||||
|
} |
||||
|
|
||||
|
foreach (var customRuleType in policy.CustomRuleTypes) |
||||
|
{ |
||||
|
rules.Add((IOperationRateLimitingRule)ServiceProvider.GetRequiredService(customRuleType)); |
||||
|
} |
||||
|
|
||||
|
return rules; |
||||
|
} |
||||
|
|
||||
|
protected virtual OperationRateLimitingResult AggregateResults( |
||||
|
List<OperationRateLimitingRuleResult> ruleResults, |
||||
|
OperationRateLimitingPolicy policy) |
||||
|
{ |
||||
|
var isAllowed = ruleResults.All(r => r.IsAllowed); |
||||
|
var mostRestrictive = ruleResults |
||||
|
.OrderBy(r => r.RemainingCount) |
||||
|
.ThenByDescending(r => r.RetryAfter ?? TimeSpan.Zero) |
||||
|
.First(); |
||||
|
|
||||
|
return new OperationRateLimitingResult |
||||
|
{ |
||||
|
IsAllowed = isAllowed, |
||||
|
RemainingCount = mostRestrictive.RemainingCount, |
||||
|
MaxCount = mostRestrictive.MaxCount, |
||||
|
CurrentCount = mostRestrictive.CurrentCount, |
||||
|
RetryAfter = ruleResults.Any(r => !r.IsAllowed && r.RetryAfter.HasValue) |
||||
|
? ruleResults |
||||
|
.Where(r => !r.IsAllowed && r.RetryAfter.HasValue) |
||||
|
.Select(r => r.RetryAfter!.Value) |
||||
|
.Max() |
||||
|
: null, |
||||
|
WindowDuration = mostRestrictive.WindowDuration, |
||||
|
RuleResults = ruleResults |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
protected virtual void ThrowRateLimitException( |
||||
|
OperationRateLimitingPolicy policy, |
||||
|
OperationRateLimitingResult result, |
||||
|
OperationRateLimitingContext context) |
||||
|
{ |
||||
|
var formatter = context.ServiceProvider.GetRequiredService<IOperationRateLimitingFormatter>(); |
||||
|
|
||||
|
var exception = new AbpOperationRateLimitingException( |
||||
|
policy.Name, |
||||
|
result, |
||||
|
policy.ErrorCode); |
||||
|
|
||||
|
if (result.RetryAfter.HasValue) |
||||
|
{ |
||||
|
exception.SetRetryAfterFormatted(formatter.Format(result.RetryAfter.Value)); |
||||
|
} |
||||
|
|
||||
|
if (result.WindowDuration > TimeSpan.Zero) |
||||
|
{ |
||||
|
exception.SetWindowDescriptionFormatted(formatter.Format(result.WindowDuration)); |
||||
|
} |
||||
|
|
||||
|
if (result.RuleResults != null) |
||||
|
{ |
||||
|
var ruleDetails = new List<Dictionary<string, object>>(); |
||||
|
foreach (var ruleResult in result.RuleResults) |
||||
|
{ |
||||
|
ruleDetails.Add(new Dictionary<string, object> |
||||
|
{ |
||||
|
["RuleName"] = ruleResult.RuleName, |
||||
|
["IsAllowed"] = ruleResult.IsAllowed, |
||||
|
["MaxCount"] = ruleResult.MaxCount, |
||||
|
["RemainingCount"] = ruleResult.RemainingCount, |
||||
|
["CurrentCount"] = ruleResult.CurrentCount, |
||||
|
["WindowDurationSeconds"] = (int)ruleResult.WindowDuration.TotalSeconds, |
||||
|
["WindowDescription"] = ruleResult.WindowDuration > TimeSpan.Zero |
||||
|
? formatter.Format(ruleResult.WindowDuration) |
||||
|
: string.Empty, |
||||
|
["RetryAfterSeconds"] = (int)(ruleResult.RetryAfter?.TotalSeconds ?? 0), |
||||
|
["RetryAfter"] = ruleResult.RetryAfter.HasValue |
||||
|
? formatter.Format(ruleResult.RetryAfter.Value) |
||||
|
: string.Empty |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
exception.WithData("RuleDetails", ruleDetails); |
||||
|
} |
||||
|
|
||||
|
foreach (var kvp in context.ExtraProperties) |
||||
|
{ |
||||
|
exception.WithData(kvp.Key, kvp.Value!); |
||||
|
} |
||||
|
|
||||
|
throw exception; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public static class OperationRateLimitingCheckerExtensions |
||||
|
{ |
||||
|
public static Task CheckAsync( |
||||
|
this IOperationRateLimitingChecker checker, |
||||
|
string policyName, |
||||
|
string parameter) |
||||
|
{ |
||||
|
return checker.CheckAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); |
||||
|
} |
||||
|
|
||||
|
public static Task<bool> IsAllowedAsync( |
||||
|
this IOperationRateLimitingChecker checker, |
||||
|
string policyName, |
||||
|
string parameter) |
||||
|
{ |
||||
|
return checker.IsAllowedAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); |
||||
|
} |
||||
|
|
||||
|
public static Task<OperationRateLimitingResult> GetStatusAsync( |
||||
|
this IOperationRateLimitingChecker checker, |
||||
|
string policyName, |
||||
|
string parameter) |
||||
|
{ |
||||
|
return checker.GetStatusAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); |
||||
|
} |
||||
|
|
||||
|
public static Task ResetAsync( |
||||
|
this IOperationRateLimitingChecker checker, |
||||
|
string policyName, |
||||
|
string parameter) |
||||
|
{ |
||||
|
return checker.ResetAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingContext |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Optional parameter passed by the caller.
|
||||
|
/// Used as the partition key by PartitionByParameter() (required),
|
||||
|
/// and as a fallback by PartitionByEmail() and PartitionByPhoneNumber().
|
||||
|
/// Can be email, phone number, user id, resource id, or any string.
|
||||
|
/// </summary>
|
||||
|
public string? Parameter { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Additional properties that can be read by custom <see cref="IOperationRateLimitingRule"/> implementations
|
||||
|
/// and are forwarded to the exception's Data dictionary when the rate limit is exceeded.
|
||||
|
/// </summary>
|
||||
|
public Dictionary<string, object?> ExtraProperties { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The service provider for resolving services.
|
||||
|
/// Set automatically by the checker.
|
||||
|
/// </summary>
|
||||
|
public IServiceProvider ServiceProvider { get; set; } = default!; |
||||
|
|
||||
|
public T GetRequiredService<T>() where T : notnull |
||||
|
=> ServiceProvider.GetRequiredService<T>(); |
||||
|
|
||||
|
public T? GetService<T>() => ServiceProvider.GetService<T>(); |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingResult |
||||
|
{ |
||||
|
public bool IsAllowed { get; set; } |
||||
|
|
||||
|
public int RemainingCount { get; set; } |
||||
|
|
||||
|
public int MaxCount { get; set; } |
||||
|
|
||||
|
public int CurrentCount { get; set; } |
||||
|
|
||||
|
public TimeSpan? RetryAfter { get; set; } |
||||
|
|
||||
|
public TimeSpan WindowDuration { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Detailed results per rule (for composite policies).
|
||||
|
/// </summary>
|
||||
|
public List<OperationRateLimitingRuleResult>? RuleResults { get; set; } |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingRuleResult |
||||
|
{ |
||||
|
public string RuleName { get; set; } = default!; |
||||
|
|
||||
|
public bool IsAllowed { get; set; } |
||||
|
|
||||
|
public int CurrentCount { get; set; } |
||||
|
|
||||
|
public int RemainingCount { get; set; } |
||||
|
|
||||
|
public int MaxCount { get; set; } |
||||
|
|
||||
|
public TimeSpan? RetryAfter { get; set; } |
||||
|
|
||||
|
public TimeSpan WindowDuration { get; set; } |
||||
|
} |
||||
@ -0,0 +1,48 @@ |
|||||
|
using System; |
||||
|
using Volo.Abp.ExceptionHandling; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class AbpOperationRateLimitingException : BusinessException, IHasHttpStatusCode |
||||
|
{ |
||||
|
public string PolicyName { get; } |
||||
|
|
||||
|
public OperationRateLimitingResult Result { get; } |
||||
|
|
||||
|
public int HttpStatusCode => 429; |
||||
|
|
||||
|
public AbpOperationRateLimitingException( |
||||
|
string policyName, |
||||
|
OperationRateLimitingResult result, |
||||
|
string? errorCode = null) |
||||
|
: base(code: errorCode ?? ResolveDefaultErrorCode(result)) |
||||
|
{ |
||||
|
PolicyName = policyName; |
||||
|
Result = result; |
||||
|
|
||||
|
WithData("PolicyName", policyName); |
||||
|
WithData("MaxCount", result.MaxCount); |
||||
|
WithData("CurrentCount", result.CurrentCount); |
||||
|
WithData("RemainingCount", result.RemainingCount); |
||||
|
WithData("RetryAfterSeconds", (int)(result.RetryAfter?.TotalSeconds ?? 0)); |
||||
|
WithData("RetryAfterMinutes", (int)(result.RetryAfter?.TotalMinutes ?? 0)); |
||||
|
WithData("WindowDurationSeconds", (int)result.WindowDuration.TotalSeconds); |
||||
|
} |
||||
|
|
||||
|
internal void SetRetryAfterFormatted(string formattedRetryAfter) |
||||
|
{ |
||||
|
WithData("RetryAfter", formattedRetryAfter); |
||||
|
} |
||||
|
|
||||
|
internal void SetWindowDescriptionFormatted(string formattedWindowDescription) |
||||
|
{ |
||||
|
WithData("WindowDescription", formattedWindowDescription); |
||||
|
} |
||||
|
|
||||
|
private static string ResolveDefaultErrorCode(OperationRateLimitingResult result) |
||||
|
{ |
||||
|
return result.RetryAfter.HasValue |
||||
|
? AbpOperationRateLimitingErrorCodes.ExceedLimit |
||||
|
: AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
using System; |
||||
|
using Microsoft.Extensions.Localization; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class DefaultOperationRateLimitingFormatter |
||||
|
: IOperationRateLimitingFormatter, ITransientDependency |
||||
|
{ |
||||
|
protected IStringLocalizer<AbpOperationRateLimitingResource> Localizer { get; } |
||||
|
|
||||
|
public DefaultOperationRateLimitingFormatter( |
||||
|
IStringLocalizer<AbpOperationRateLimitingResource> localizer) |
||||
|
{ |
||||
|
Localizer = localizer; |
||||
|
} |
||||
|
|
||||
|
public virtual string Format(TimeSpan duration) |
||||
|
{ |
||||
|
if (duration.TotalDays >= 365) |
||||
|
{ |
||||
|
var years = (int)(duration.TotalDays / 365); |
||||
|
var remainingDays = (int)(duration.TotalDays % 365); |
||||
|
var months = remainingDays / 30; |
||||
|
return months > 0 |
||||
|
? Localizer["RetryAfter:YearsAndMonths", years, months] |
||||
|
: Localizer["RetryAfter:Years", years]; |
||||
|
} |
||||
|
|
||||
|
if (duration.TotalDays >= 30) |
||||
|
{ |
||||
|
var months = (int)(duration.TotalDays / 30); |
||||
|
var remainingDays = (int)(duration.TotalDays % 30); |
||||
|
return remainingDays > 0 |
||||
|
? Localizer["RetryAfter:MonthsAndDays", months, remainingDays] |
||||
|
: Localizer["RetryAfter:Months", months]; |
||||
|
} |
||||
|
|
||||
|
if (duration.TotalDays >= 1) |
||||
|
{ |
||||
|
var days = (int)duration.TotalDays; |
||||
|
var hours = duration.Hours; |
||||
|
return hours > 0 |
||||
|
? Localizer["RetryAfter:DaysAndHours", days, hours] |
||||
|
: Localizer["RetryAfter:Days", days]; |
||||
|
} |
||||
|
|
||||
|
if (duration.TotalHours >= 1) |
||||
|
{ |
||||
|
var hours = (int)duration.TotalHours; |
||||
|
var minutes = duration.Minutes; |
||||
|
return minutes > 0 |
||||
|
? Localizer["RetryAfter:HoursAndMinutes", hours, minutes] |
||||
|
: Localizer["RetryAfter:Hours", hours]; |
||||
|
} |
||||
|
|
||||
|
if (duration.TotalMinutes >= 1) |
||||
|
{ |
||||
|
var minutes = (int)duration.TotalMinutes; |
||||
|
var seconds = duration.Seconds; |
||||
|
return seconds > 0 |
||||
|
? Localizer["RetryAfter:MinutesAndSeconds", minutes, seconds] |
||||
|
: Localizer["RetryAfter:Minutes", minutes]; |
||||
|
} |
||||
|
|
||||
|
return Localizer["RetryAfter:Seconds", (int)duration.TotalSeconds]; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public interface IOperationRateLimitingFormatter |
||||
|
{ |
||||
|
string Format(TimeSpan duration); |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "ar", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "تم تجاوز حد معدل العملية. يمكنك المحاولة مرة أخرى بعد {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} سنة/سنوات", |
||||
|
"RetryAfter:YearsAndMonths": "{0} سنة/سنوات و {1} شهر/أشهر", |
||||
|
"RetryAfter:Months": "{0} شهر/أشهر", |
||||
|
"RetryAfter:MonthsAndDays": "{0} شهر/أشهر و {1} يوم/أيام", |
||||
|
"RetryAfter:Days": "{0} يوم/أيام", |
||||
|
"RetryAfter:DaysAndHours": "{0} يوم/أيام و {1} ساعة/ساعات", |
||||
|
"RetryAfter:Hours": "{0} ساعة/ساعات", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} ساعة/ساعات و {1} دقيقة/دقائق", |
||||
|
"RetryAfter:Minutes": "{0} دقيقة/دقائق", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} دقيقة/دقائق و {1} ثانية/ثوان", |
||||
|
"RetryAfter:Seconds": "{0} ثانية/ثوان", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "تم تجاوز حد معدل العملية. هذا الطلب مرفوض بشكل دائم." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "cs", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Překročen limit rychlosti operace. Můžete to zkusit znovu za {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} rok(y/let)", |
||||
|
"RetryAfter:YearsAndMonths": "{0} rok(y/let) a {1} měsíc(e/ů)", |
||||
|
"RetryAfter:Months": "{0} měsíc(e/ů)", |
||||
|
"RetryAfter:MonthsAndDays": "{0} měsíc(e/ů) a {1} den/dny/dní", |
||||
|
"RetryAfter:Days": "{0} den/dny/dní", |
||||
|
"RetryAfter:DaysAndHours": "{0} den/dny/dní a {1} hodina/hodiny/hodin", |
||||
|
"RetryAfter:Hours": "{0} hodina/hodiny/hodin", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} hodina/hodiny/hodin a {1} minuta/minuty/minut", |
||||
|
"RetryAfter:Minutes": "{0} minuta/minuty/minut", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuta/minuty/minut a {1} sekunda/sekundy/sekund", |
||||
|
"RetryAfter:Seconds": "{0} sekunda/sekundy/sekund", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Byl překročen limit četnosti operace. Tento požadavek je trvale zamítnut." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "de", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Betriebsratenlimit überschritten. Sie können es nach {RetryAfter} erneut versuchen.", |
||||
|
"RetryAfter:Years": "{0} Jahr(e)", |
||||
|
"RetryAfter:YearsAndMonths": "{0} Jahr(e) und {1} Monat(e)", |
||||
|
"RetryAfter:Months": "{0} Monat(e)", |
||||
|
"RetryAfter:MonthsAndDays": "{0} Monat(e) und {1} Tag(e)", |
||||
|
"RetryAfter:Days": "{0} Tag(e)", |
||||
|
"RetryAfter:DaysAndHours": "{0} Tag(e) und {1} Stunde(n)", |
||||
|
"RetryAfter:Hours": "{0} Stunde(n)", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} Stunde(n) und {1} Minute(n)", |
||||
|
"RetryAfter:Minutes": "{0} Minute(n)", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} Minute(n) und {1} Sekunde(n)", |
||||
|
"RetryAfter:Seconds": "{0} Sekunde(n)", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Das Vorgangshäufigkeitslimit wurde überschritten. Diese Anfrage wird dauerhaft abgelehnt." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "el", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Υπέρβαση ορίου ρυθμού λειτουργίας. Μπορείτε να δοκιμάσετε ξανά μετά από {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} έτος/η", |
||||
|
"RetryAfter:YearsAndMonths": "{0} έτος/η και {1} μήνας/ες", |
||||
|
"RetryAfter:Months": "{0} μήνας/ες", |
||||
|
"RetryAfter:MonthsAndDays": "{0} μήνας/ες και {1} ημέρα/ες", |
||||
|
"RetryAfter:Days": "{0} ημέρα/ες", |
||||
|
"RetryAfter:DaysAndHours": "{0} ημέρα/ες και {1} ώρα/ες", |
||||
|
"RetryAfter:Hours": "{0} ώρα/ες", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} ώρα/ες και {1} λεπτό/ά", |
||||
|
"RetryAfter:Minutes": "{0} λεπτό/ά", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} λεπτό/ά και {1} δευτερόλεπτο/α", |
||||
|
"RetryAfter:Seconds": "{0} δευτερόλεπτο/α", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Υπερβλήθηκε το όριο συχνότητας λειτουργίας. Αυτό το αίτημα απορρίπτεται μόνιμα." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "en-GB", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} year(s)", |
||||
|
"RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)", |
||||
|
"RetryAfter:Months": "{0} month(s)", |
||||
|
"RetryAfter:MonthsAndDays": "{0} month(s) and {1} day(s)", |
||||
|
"RetryAfter:Days": "{0} day(s)", |
||||
|
"RetryAfter:DaysAndHours": "{0} day(s) and {1} hour(s)", |
||||
|
"RetryAfter:Hours": "{0} hour(s)", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} hour(s) and {1} minute(s)", |
||||
|
"RetryAfter:Minutes": "{0} minute(s)", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minute(s) and {1} second(s)", |
||||
|
"RetryAfter:Seconds": "{0} second(s)", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "en", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} year(s)", |
||||
|
"RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)", |
||||
|
"RetryAfter:Months": "{0} month(s)", |
||||
|
"RetryAfter:MonthsAndDays": "{0} month(s) and {1} day(s)", |
||||
|
"RetryAfter:Days": "{0} day(s)", |
||||
|
"RetryAfter:DaysAndHours": "{0} day(s) and {1} hour(s)", |
||||
|
"RetryAfter:Hours": "{0} hour(s)", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} hour(s) and {1} minute(s)", |
||||
|
"RetryAfter:Minutes": "{0} minute(s)", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minute(s) and {1} second(s)", |
||||
|
"RetryAfter:Seconds": "{0} second(s)", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "es", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Se ha excedido el límite de tasa de operación. Puede intentarlo de nuevo después de {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} año(s)", |
||||
|
"RetryAfter:YearsAndMonths": "{0} año(s) y {1} mes(es)", |
||||
|
"RetryAfter:Months": "{0} mes(es)", |
||||
|
"RetryAfter:MonthsAndDays": "{0} mes(es) y {1} día(s)", |
||||
|
"RetryAfter:Days": "{0} día(s)", |
||||
|
"RetryAfter:DaysAndHours": "{0} día(s) y {1} hora(s)", |
||||
|
"RetryAfter:Hours": "{0} hora(s)", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} hora(s) y {1} minuto(s)", |
||||
|
"RetryAfter:Minutes": "{0} minuto(s)", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuto(s) y {1} segundo(s)", |
||||
|
"RetryAfter:Seconds": "{0} segundo(s)", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Se superó el límite de frecuencia de operación. Esta solicitud está permanentemente denegada." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "fa", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "محدودیت نرخ عملیات فراتر رفته است. میتوانید بعد از {RetryAfter} دوباره تلاش کنید.", |
||||
|
"RetryAfter:Years": "{0} سال", |
||||
|
"RetryAfter:YearsAndMonths": "{0} سال و {1} ماه", |
||||
|
"RetryAfter:Months": "{0} ماه", |
||||
|
"RetryAfter:MonthsAndDays": "{0} ماه و {1} روز", |
||||
|
"RetryAfter:Days": "{0} روز", |
||||
|
"RetryAfter:DaysAndHours": "{0} روز و {1} ساعت", |
||||
|
"RetryAfter:Hours": "{0} ساعت", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} ساعت و {1} دقیقه", |
||||
|
"RetryAfter:Minutes": "{0} دقیقه", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} دقیقه و {1} ثانیه", |
||||
|
"RetryAfter:Seconds": "{0} ثانیه", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "محدودیت نرخ عملیات از حد مجاز فراتر رفت. این درخواست به طور دائمی رد شده است." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "fi", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Toiminnon nopeusraja ylitetty. Voit yrittää uudelleen {RetryAfter} kuluttua.", |
||||
|
"RetryAfter:Years": "{0} vuosi/vuotta", |
||||
|
"RetryAfter:YearsAndMonths": "{0} vuosi/vuotta ja {1} kuukausi/kuukautta", |
||||
|
"RetryAfter:Months": "{0} kuukausi/kuukautta", |
||||
|
"RetryAfter:MonthsAndDays": "{0} kuukausi/kuukautta ja {1} päivä/päivää", |
||||
|
"RetryAfter:Days": "{0} päivä/päivää", |
||||
|
"RetryAfter:DaysAndHours": "{0} päivä/päivää ja {1} tunti/tuntia", |
||||
|
"RetryAfter:Hours": "{0} tunti/tuntia", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} tunti/tuntia ja {1} minuutti/minuuttia", |
||||
|
"RetryAfter:Minutes": "{0} minuutti/minuuttia", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuutti/minuuttia ja {1} sekunti/sekuntia", |
||||
|
"RetryAfter:Seconds": "{0} sekunti/sekuntia", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Toiminnan nopeusraja ylitettiin. Tämä pyyntö on pysyvästi hylätty." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "fr", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Limite de taux d'opération dépassée. Vous pouvez réessayer après {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} an(s)", |
||||
|
"RetryAfter:YearsAndMonths": "{0} an(s) et {1} mois", |
||||
|
"RetryAfter:Months": "{0} mois", |
||||
|
"RetryAfter:MonthsAndDays": "{0} mois et {1} jour(s)", |
||||
|
"RetryAfter:Days": "{0} jour(s)", |
||||
|
"RetryAfter:DaysAndHours": "{0} jour(s) et {1} heure(s)", |
||||
|
"RetryAfter:Hours": "{0} heure(s)", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} heure(s) et {1} minute(s)", |
||||
|
"RetryAfter:Minutes": "{0} minute(s)", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minute(s) et {1} seconde(s)", |
||||
|
"RetryAfter:Seconds": "{0} seconde(s)", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "La limite de fréquence d'opération a été dépassée. Cette demande est définitivement refusée." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "hi", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "ऑपरेशन दर सीमा पार हो गई। आप {RetryAfter} के बाद पुनः प्रयास कर सकते हैं।", |
||||
|
"RetryAfter:Years": "{0} वर्ष", |
||||
|
"RetryAfter:YearsAndMonths": "{0} वर्ष और {1} महीना/महीने", |
||||
|
"RetryAfter:Months": "{0} महीना/महीने", |
||||
|
"RetryAfter:MonthsAndDays": "{0} महीना/महीने और {1} दिन", |
||||
|
"RetryAfter:Days": "{0} दिन", |
||||
|
"RetryAfter:DaysAndHours": "{0} दिन और {1} घंटा/घंटे", |
||||
|
"RetryAfter:Hours": "{0} घंटा/घंटे", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} घंटा/घंटे और {1} मिनट", |
||||
|
"RetryAfter:Minutes": "{0} मिनट", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} मिनट और {1} सेकंड", |
||||
|
"RetryAfter:Seconds": "{0} सेकंड", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "ऑपरेशन दर सीमा पार हो गई। यह अनुरोध स्थायी रूप से अस्वीकृत है।" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "hr", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Prekoračeno ograničenje brzine operacije. Možete pokušati ponovo nakon {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} godina/e", |
||||
|
"RetryAfter:YearsAndMonths": "{0} godina/e i {1} mjesec/i", |
||||
|
"RetryAfter:Months": "{0} mjesec/i", |
||||
|
"RetryAfter:MonthsAndDays": "{0} mjesec/i i {1} dan/a", |
||||
|
"RetryAfter:Days": "{0} dan/a", |
||||
|
"RetryAfter:DaysAndHours": "{0} dan/a i {1} sat/i", |
||||
|
"RetryAfter:Hours": "{0} sat/i", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} sat/i i {1} minuta/e", |
||||
|
"RetryAfter:Minutes": "{0} minuta/e", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuta/e i {1} sekunda/e", |
||||
|
"RetryAfter:Seconds": "{0} sekunda/e", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Prekoračeno je ograničenje brzine operacije. Ovaj zahtjev je trajno odbijen." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "hu", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "A műveleti sebességkorlát túllépve. Újra próbálkozhat {RetryAfter} múlva.", |
||||
|
"RetryAfter:Years": "{0} év", |
||||
|
"RetryAfter:YearsAndMonths": "{0} év és {1} hónap", |
||||
|
"RetryAfter:Months": "{0} hónap", |
||||
|
"RetryAfter:MonthsAndDays": "{0} hónap és {1} nap", |
||||
|
"RetryAfter:Days": "{0} nap", |
||||
|
"RetryAfter:DaysAndHours": "{0} nap és {1} óra", |
||||
|
"RetryAfter:Hours": "{0} óra", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} óra és {1} perc", |
||||
|
"RetryAfter:Minutes": "{0} perc", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} perc és {1} másodperc", |
||||
|
"RetryAfter:Seconds": "{0} másodperc", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "A műveleti ráta korlátja túllépve. Ez a kérés véglegesen elutasítva." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "is", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Aðgerðarhraðatakmörk náð. Þú getur reynt aftur eftir {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} ár", |
||||
|
"RetryAfter:YearsAndMonths": "{0} ár og {1} mánuð(ir)", |
||||
|
"RetryAfter:Months": "{0} mánuð(ur/ir)", |
||||
|
"RetryAfter:MonthsAndDays": "{0} mánuð(ur/ir) og {1} dag(ur/ar)", |
||||
|
"RetryAfter:Days": "{0} dag(ur/ar)", |
||||
|
"RetryAfter:DaysAndHours": "{0} dag(ur/ar) og {1} klukkustund(ir)", |
||||
|
"RetryAfter:Hours": "{0} klukkustund(ir)", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} klukkustund(ir) og {1} mínúta/úr", |
||||
|
"RetryAfter:Minutes": "{0} mínúta/úr", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} mínúta/úr og {1} sekúnda/úr", |
||||
|
"RetryAfter:Seconds": "{0} sekúnda/úr", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Farið var yfir takmörk á rekstrartíðni. Þessari beiðni er varanlega hafnað." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "it", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Limite di frequenza operazione superato. Puoi riprovare dopo {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} anno/i", |
||||
|
"RetryAfter:YearsAndMonths": "{0} anno/i e {1} mese/i", |
||||
|
"RetryAfter:Months": "{0} mese/i", |
||||
|
"RetryAfter:MonthsAndDays": "{0} mese/i e {1} giorno/i", |
||||
|
"RetryAfter:Days": "{0} giorno/i", |
||||
|
"RetryAfter:DaysAndHours": "{0} giorno/i e {1} ora/e", |
||||
|
"RetryAfter:Hours": "{0} ora/e", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} ora/e e {1} minuto/i", |
||||
|
"RetryAfter:Minutes": "{0} minuto/i", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuto/i e {1} secondo/i", |
||||
|
"RetryAfter:Seconds": "{0} secondo/i", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Limite di frequenza operazione superato. Questa richiesta è permanentemente negata." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "nl", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Bewerkingssnelheidslimiet overschreden. U kunt het opnieuw proberen na {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} jaar", |
||||
|
"RetryAfter:YearsAndMonths": "{0} jaar en {1} maand(en)", |
||||
|
"RetryAfter:Months": "{0} maand(en)", |
||||
|
"RetryAfter:MonthsAndDays": "{0} maand(en) en {1} dag(en)", |
||||
|
"RetryAfter:Days": "{0} dag(en)", |
||||
|
"RetryAfter:DaysAndHours": "{0} dag(en) en {1} uur", |
||||
|
"RetryAfter:Hours": "{0} uur", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} uur en {1} minuut/minuten", |
||||
|
"RetryAfter:Minutes": "{0} minuut/minuten", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuut/minuten en {1} seconde(n)", |
||||
|
"RetryAfter:Seconds": "{0} seconde(n)", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Het bewerkingsfrequentielimiet is overschreden. Dit verzoek wordt permanent geweigerd." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "pl-PL", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Przekroczono limit częstotliwości operacji. Możesz spróbować ponownie po {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} rok/lat", |
||||
|
"RetryAfter:YearsAndMonths": "{0} rok/lat i {1} miesiąc/miesięcy", |
||||
|
"RetryAfter:Months": "{0} miesiąc/miesięcy", |
||||
|
"RetryAfter:MonthsAndDays": "{0} miesiąc/miesięcy i {1} dzień/dni", |
||||
|
"RetryAfter:Days": "{0} dzień/dni", |
||||
|
"RetryAfter:DaysAndHours": "{0} dzień/dni i {1} godzina/godzin", |
||||
|
"RetryAfter:Hours": "{0} godzina/godzin", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} godzina/godzin i {1} minuta/minut", |
||||
|
"RetryAfter:Minutes": "{0} minuta/minut", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuta/minut i {1} sekunda/sekund", |
||||
|
"RetryAfter:Seconds": "{0} sekunda/sekund", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Przekroczono limit częstotliwości operacji. To żądanie jest trwale odrzucone." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "pt-BR", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Limite de taxa de operação excedido. Você pode tentar novamente após {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} ano(s)", |
||||
|
"RetryAfter:YearsAndMonths": "{0} ano(s) e {1} mês/meses", |
||||
|
"RetryAfter:Months": "{0} mês/meses", |
||||
|
"RetryAfter:MonthsAndDays": "{0} mês/meses e {1} dia(s)", |
||||
|
"RetryAfter:Days": "{0} dia(s)", |
||||
|
"RetryAfter:DaysAndHours": "{0} dia(s) e {1} hora(s)", |
||||
|
"RetryAfter:Hours": "{0} hora(s)", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} hora(s) e {1} minuto(s)", |
||||
|
"RetryAfter:Minutes": "{0} minuto(s)", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuto(s) e {1} segundo(s)", |
||||
|
"RetryAfter:Seconds": "{0} segundo(s)", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Limite de taxa de operação excedido. Esta solicitação está permanentemente negada." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "ro-RO", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Limita ratei de operare a fost depășită. Puteți încerca din nou după {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} an/ani", |
||||
|
"RetryAfter:YearsAndMonths": "{0} an/ani și {1} lună/luni", |
||||
|
"RetryAfter:Months": "{0} lună/luni", |
||||
|
"RetryAfter:MonthsAndDays": "{0} lună/luni și {1} zi/zile", |
||||
|
"RetryAfter:Days": "{0} zi/zile", |
||||
|
"RetryAfter:DaysAndHours": "{0} zi/zile și {1} oră/ore", |
||||
|
"RetryAfter:Hours": "{0} oră/ore", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} oră/ore și {1} minut(e)", |
||||
|
"RetryAfter:Minutes": "{0} minut(e)", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minut(e) și {1} secundă/secunde", |
||||
|
"RetryAfter:Seconds": "{0} secundă/secunde", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Limita de rată a operației a fost depășită. Această solicitare este permanent refuzată." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "ru", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Превышен лимит частоты операций. Вы можете повторить попытку через {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} год/лет", |
||||
|
"RetryAfter:YearsAndMonths": "{0} год/лет и {1} месяц/месяцев", |
||||
|
"RetryAfter:Months": "{0} месяц/месяцев", |
||||
|
"RetryAfter:MonthsAndDays": "{0} месяц/месяцев и {1} день/дней", |
||||
|
"RetryAfter:Days": "{0} день/дней", |
||||
|
"RetryAfter:DaysAndHours": "{0} день/дней и {1} час/часов", |
||||
|
"RetryAfter:Hours": "{0} час/часов", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} час/часов и {1} минута/минут", |
||||
|
"RetryAfter:Minutes": "{0} минута/минут", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} минута/минут и {1} секунда/секунд", |
||||
|
"RetryAfter:Seconds": "{0} секунда/секунд", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Превышен лимит частоты операций. Этот запрос постоянно отклонён." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "sk", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Prekročený limit rýchlosti operácie. Môžete to skúsiť znova po {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} rok/rokov", |
||||
|
"RetryAfter:YearsAndMonths": "{0} rok/rokov a {1} mesiac/mesiacov", |
||||
|
"RetryAfter:Months": "{0} mesiac/mesiacov", |
||||
|
"RetryAfter:MonthsAndDays": "{0} mesiac/mesiacov a {1} deň/dní", |
||||
|
"RetryAfter:Days": "{0} deň/dní", |
||||
|
"RetryAfter:DaysAndHours": "{0} deň/dní a {1} hodina/hodín", |
||||
|
"RetryAfter:Hours": "{0} hodina/hodín", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} hodina/hodín a {1} minúta/minút", |
||||
|
"RetryAfter:Minutes": "{0} minúta/minút", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minúta/minút a {1} sekunda/sekúnd", |
||||
|
"RetryAfter:Seconds": "{0} sekunda/sekúnd", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Bol prekročený limit frekvencie operácie. Táto požiadavka je trvalo zamietnutá." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "sl", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Presežena omejitev hitrosti operacije. Poskusite lahko znova čez {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} leto/let", |
||||
|
"RetryAfter:YearsAndMonths": "{0} leto/let in {1} mesec/mesecev", |
||||
|
"RetryAfter:Months": "{0} mesec/mesecev", |
||||
|
"RetryAfter:MonthsAndDays": "{0} mesec/mesecev in {1} dan/dni", |
||||
|
"RetryAfter:Days": "{0} dan/dni", |
||||
|
"RetryAfter:DaysAndHours": "{0} dan/dni in {1} ura/ur", |
||||
|
"RetryAfter:Hours": "{0} ura/ur", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} ura/ur in {1} minuta/minut", |
||||
|
"RetryAfter:Minutes": "{0} minuta/minut", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minuta/minut in {1} sekunda/sekund", |
||||
|
"RetryAfter:Seconds": "{0} sekunda/sekund", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Prekoračena je omejitev hitrosti operacije. Ta zahteva je trajno zavrnjena." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "sv", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Hastighetsgränsen för operationen har överskridits. Du kan försöka igen efter {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} år", |
||||
|
"RetryAfter:YearsAndMonths": "{0} år och {1} månad(er)", |
||||
|
"RetryAfter:Months": "{0} månad(er)", |
||||
|
"RetryAfter:MonthsAndDays": "{0} månad(er) och {1} dag(ar)", |
||||
|
"RetryAfter:Days": "{0} dag(ar)", |
||||
|
"RetryAfter:DaysAndHours": "{0} dag(ar) och {1} timme/timmar", |
||||
|
"RetryAfter:Hours": "{0} timme/timmar", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} timme/timmar och {1} minut(er)", |
||||
|
"RetryAfter:Minutes": "{0} minut(er)", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} minut(er) och {1} sekund(er)", |
||||
|
"RetryAfter:Seconds": "{0} sekund(er)", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Hastighetsgränsen för operationen har överskridits. Denna förfrågan är permanent nekad." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "tr", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "İşlem hız sınırı aşıldı. {RetryAfter} sonra tekrar deneyebilirsiniz.", |
||||
|
"RetryAfter:Years": "{0} yıl", |
||||
|
"RetryAfter:YearsAndMonths": "{0} yıl ve {1} ay", |
||||
|
"RetryAfter:Months": "{0} ay", |
||||
|
"RetryAfter:MonthsAndDays": "{0} ay ve {1} gün", |
||||
|
"RetryAfter:Days": "{0} gün", |
||||
|
"RetryAfter:DaysAndHours": "{0} gün ve {1} saat", |
||||
|
"RetryAfter:Hours": "{0} saat", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} saat ve {1} dakika", |
||||
|
"RetryAfter:Minutes": "{0} dakika", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} dakika ve {1} saniye", |
||||
|
"RetryAfter:Seconds": "{0} saniye", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "İşlem hızı sınırı aşıldı. Bu istek kalıcı olarak reddedildi." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "vi", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "Đã vượt quá giới hạn tốc độ thao tác. Bạn có thể thử lại sau {RetryAfter}.", |
||||
|
"RetryAfter:Years": "{0} năm", |
||||
|
"RetryAfter:YearsAndMonths": "{0} năm và {1} tháng", |
||||
|
"RetryAfter:Months": "{0} tháng", |
||||
|
"RetryAfter:MonthsAndDays": "{0} tháng và {1} ngày", |
||||
|
"RetryAfter:Days": "{0} ngày", |
||||
|
"RetryAfter:DaysAndHours": "{0} ngày và {1} giờ", |
||||
|
"RetryAfter:Hours": "{0} giờ", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} giờ và {1} phút", |
||||
|
"RetryAfter:Minutes": "{0} phút", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} phút và {1} giây", |
||||
|
"RetryAfter:Seconds": "{0} giây", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "Vượt quá giới hạn tần suất thao tác. Yêu cầu này bị từ chối vĩnh viễn." |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
{ |
||||
|
"culture": "zh-Hans", |
||||
|
"texts": { |
||||
|
"Volo.Abp.OperationRateLimiting:010001": "操作频率超出限制。请在 {RetryAfter} 后重试。", |
||||
|
"RetryAfter:Years": "{0} 年", |
||||
|
"RetryAfter:YearsAndMonths": "{0} 年 {1} 个月", |
||||
|
"RetryAfter:Months": "{0} 个月", |
||||
|
"RetryAfter:MonthsAndDays": "{0} 个月 {1} 天", |
||||
|
"RetryAfter:Days": "{0} 天", |
||||
|
"RetryAfter:DaysAndHours": "{0} 天 {1} 小时", |
||||
|
"RetryAfter:Hours": "{0} 小时", |
||||
|
"RetryAfter:HoursAndMinutes": "{0} 小时 {1} 分钟", |
||||
|
"RetryAfter:Minutes": "{0} 分钟", |
||||
|
"RetryAfter:MinutesAndSeconds": "{0} 分钟 {1} 秒", |
||||
|
"RetryAfter:Seconds": "{0} 秒", |
||||
|
"Volo.Abp.OperationRateLimiting:010002": "操作频率超出限制。此请求已被永久拒绝。" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class DefaultOperationRateLimitingPolicyProvider : IOperationRateLimitingPolicyProvider, ITransientDependency |
||||
|
{ |
||||
|
protected AbpOperationRateLimitingOptions Options { get; } |
||||
|
|
||||
|
public DefaultOperationRateLimitingPolicyProvider(IOptions<AbpOperationRateLimitingOptions> options) |
||||
|
{ |
||||
|
Options = options.Value; |
||||
|
} |
||||
|
|
||||
|
public virtual Task<OperationRateLimitingPolicy> GetAsync(string policyName) |
||||
|
{ |
||||
|
if (!Options.Policies.TryGetValue(policyName, out var policy)) |
||||
|
{ |
||||
|
throw new AbpException( |
||||
|
$"Operation rate limit policy '{policyName}' was not found. " + |
||||
|
$"Make sure to configure it using AbpOperationRateLimitingOptions.AddPolicy()."); |
||||
|
} |
||||
|
|
||||
|
return Task.FromResult(policy); |
||||
|
} |
||||
|
|
||||
|
public virtual Task<List<OperationRateLimitingPolicy>> GetListAsync() |
||||
|
{ |
||||
|
return Task.FromResult(Options.Policies.Values.ToList()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public interface IOperationRateLimitingPolicyProvider |
||||
|
{ |
||||
|
Task<OperationRateLimitingPolicy> GetAsync(string policyName); |
||||
|
|
||||
|
Task<List<OperationRateLimitingPolicy>> GetListAsync(); |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public enum OperationRateLimitingPartitionType |
||||
|
{ |
||||
|
Parameter, |
||||
|
CurrentUser, |
||||
|
CurrentTenant, |
||||
|
ClientIp, |
||||
|
Email, |
||||
|
PhoneNumber, |
||||
|
Custom |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingPolicy |
||||
|
{ |
||||
|
public string Name { get; set; } = default!; |
||||
|
|
||||
|
public string? ErrorCode { get; set; } |
||||
|
|
||||
|
public List<OperationRateLimitingRuleDefinition> Rules { get; set; } = new(); |
||||
|
|
||||
|
public List<Type> CustomRuleTypes { get; set; } = new(); |
||||
|
} |
||||
@ -0,0 +1,102 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingPolicyBuilder |
||||
|
{ |
||||
|
private readonly string _name; |
||||
|
private string? _errorCode; |
||||
|
private readonly List<OperationRateLimitingRuleDefinition> _rules = new(); |
||||
|
private readonly List<Type> _customRuleTypes = new(); |
||||
|
|
||||
|
public OperationRateLimitingPolicyBuilder(string name) |
||||
|
{ |
||||
|
_name = Check.NotNullOrWhiteSpace(name, nameof(name)); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Add a built-in rule. Multiple rules are AND-combined.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder AddRule( |
||||
|
Action<OperationRateLimitingRuleBuilder> configure) |
||||
|
{ |
||||
|
var builder = new OperationRateLimitingRuleBuilder(this); |
||||
|
configure(builder); |
||||
|
if (!builder.IsCommitted) |
||||
|
{ |
||||
|
_rules.Add(builder.Build()); |
||||
|
} |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Add a custom rule type (resolved from DI).
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder AddRule<TRule>() |
||||
|
where TRule : class, IOperationRateLimitingRule |
||||
|
{ |
||||
|
_customRuleTypes.Add(typeof(TRule)); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Shortcut: single-rule policy with fixed window.
|
||||
|
/// Returns the rule builder for partition configuration.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingRuleBuilder WithFixedWindow( |
||||
|
TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
var builder = new OperationRateLimitingRuleBuilder(this); |
||||
|
builder.WithFixedWindow(duration, maxCount); |
||||
|
return builder; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Set a custom ErrorCode for this policy's exception.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder WithErrorCode(string errorCode) |
||||
|
{ |
||||
|
_errorCode = Check.NotNullOrWhiteSpace(errorCode, nameof(errorCode)); |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
internal void AddRuleDefinition(OperationRateLimitingRuleDefinition definition) |
||||
|
{ |
||||
|
_rules.Add(definition); |
||||
|
} |
||||
|
|
||||
|
internal OperationRateLimitingPolicy Build() |
||||
|
{ |
||||
|
if (_rules.Count == 0 && _customRuleTypes.Count == 0) |
||||
|
{ |
||||
|
throw new AbpException( |
||||
|
$"Operation rate limit policy '{_name}' has no rules. " + |
||||
|
"Call AddRule() or WithFixedWindow(...).PartitionBy*() to add at least one rule."); |
||||
|
} |
||||
|
|
||||
|
var duplicate = _rules |
||||
|
.Where(r => r.PartitionType != OperationRateLimitingPartitionType.Custom) |
||||
|
.GroupBy(r => (r.Duration, r.MaxCount, r.PartitionType, r.IsMultiTenant)) |
||||
|
.FirstOrDefault(g => g.Count() > 1); |
||||
|
|
||||
|
if (duplicate != null) |
||||
|
{ |
||||
|
var (duration, maxCount, partitionType, isMultiTenant) = duplicate.Key; |
||||
|
throw new AbpException( |
||||
|
$"Operation rate limit policy '{_name}' has duplicate rules with the same " + |
||||
|
$"Duration ({duration}), MaxCount ({maxCount}), PartitionType ({partitionType}), " + |
||||
|
$"and IsMultiTenant ({isMultiTenant}). " + |
||||
|
"Each rule in a policy must have a unique combination of these properties."); |
||||
|
} |
||||
|
|
||||
|
return new OperationRateLimitingPolicy |
||||
|
{ |
||||
|
Name = _name, |
||||
|
ErrorCode = _errorCode, |
||||
|
Rules = new List<OperationRateLimitingRuleDefinition>(_rules), |
||||
|
CustomRuleTypes = new List<Type>(_customRuleTypes) |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,157 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingRuleBuilder |
||||
|
{ |
||||
|
private readonly OperationRateLimitingPolicyBuilder _policyBuilder; |
||||
|
private TimeSpan _duration; |
||||
|
private int _maxCount; |
||||
|
private OperationRateLimitingPartitionType? _partitionType; |
||||
|
private Func<OperationRateLimitingContext, Task<string>>? _customPartitionKeyResolver; |
||||
|
private bool _isMultiTenant; |
||||
|
|
||||
|
internal bool IsCommitted { get; private set; } |
||||
|
|
||||
|
internal OperationRateLimitingRuleBuilder(OperationRateLimitingPolicyBuilder policyBuilder) |
||||
|
{ |
||||
|
_policyBuilder = policyBuilder; |
||||
|
} |
||||
|
|
||||
|
public OperationRateLimitingRuleBuilder WithFixedWindow( |
||||
|
TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
_duration = duration; |
||||
|
_maxCount = maxCount; |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public OperationRateLimitingRuleBuilder WithMultiTenancy() |
||||
|
{ |
||||
|
_isMultiTenant = true; |
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Use context.Parameter as partition key.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder PartitionByParameter() |
||||
|
{ |
||||
|
_partitionType = OperationRateLimitingPartitionType.Parameter; |
||||
|
CommitToPolicyBuilder(); |
||||
|
return _policyBuilder; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Partition by the current authenticated user (ICurrentUser.Id).
|
||||
|
/// Use PartitionByParameter() if you need to specify the user ID explicitly.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder PartitionByCurrentUser() |
||||
|
{ |
||||
|
_partitionType = OperationRateLimitingPartitionType.CurrentUser; |
||||
|
CommitToPolicyBuilder(); |
||||
|
return _policyBuilder; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Partition by the current tenant (ICurrentTenant.Id). Uses "host" when no tenant is active.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder PartitionByCurrentTenant() |
||||
|
{ |
||||
|
_partitionType = OperationRateLimitingPartitionType.CurrentTenant; |
||||
|
CommitToPolicyBuilder(); |
||||
|
return _policyBuilder; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Partition by the client IP address (IWebClientInfoProvider.ClientIpAddress).
|
||||
|
/// Use PartitionByParameter() if you need to specify the IP explicitly.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder PartitionByClientIp() |
||||
|
{ |
||||
|
_partitionType = OperationRateLimitingPartitionType.ClientIp; |
||||
|
CommitToPolicyBuilder(); |
||||
|
return _policyBuilder; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Partition by email address.
|
||||
|
/// Resolves from context.Parameter, falls back to ICurrentUser.Email.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder PartitionByEmail() |
||||
|
{ |
||||
|
_partitionType = OperationRateLimitingPartitionType.Email; |
||||
|
CommitToPolicyBuilder(); |
||||
|
return _policyBuilder; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Partition by phone number.
|
||||
|
/// Resolves from context.Parameter, falls back to ICurrentUser.PhoneNumber.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder PartitionByPhoneNumber() |
||||
|
{ |
||||
|
_partitionType = OperationRateLimitingPartitionType.PhoneNumber; |
||||
|
CommitToPolicyBuilder(); |
||||
|
return _policyBuilder; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Custom async partition key resolver from context.
|
||||
|
/// </summary>
|
||||
|
public OperationRateLimitingPolicyBuilder PartitionBy( |
||||
|
Func<OperationRateLimitingContext, Task<string>> keyResolver) |
||||
|
{ |
||||
|
_partitionType = OperationRateLimitingPartitionType.Custom; |
||||
|
_customPartitionKeyResolver = Check.NotNull(keyResolver, nameof(keyResolver)); |
||||
|
CommitToPolicyBuilder(); |
||||
|
return _policyBuilder; |
||||
|
} |
||||
|
|
||||
|
protected virtual void CommitToPolicyBuilder() |
||||
|
{ |
||||
|
_policyBuilder.AddRuleDefinition(Build()); |
||||
|
IsCommitted = true; |
||||
|
} |
||||
|
|
||||
|
internal OperationRateLimitingRuleDefinition Build() |
||||
|
{ |
||||
|
if (_duration <= TimeSpan.Zero) |
||||
|
{ |
||||
|
throw new AbpException( |
||||
|
"Operation rate limit rule requires a positive duration. " + |
||||
|
"Call WithFixedWindow(duration, maxCount) before building the rule."); |
||||
|
} |
||||
|
|
||||
|
if (_maxCount < 0) |
||||
|
{ |
||||
|
throw new AbpException( |
||||
|
"Operation rate limit rule requires maxCount >= 0. " + |
||||
|
"Use maxCount: 0 to completely deny all requests (ban policy)."); |
||||
|
} |
||||
|
|
||||
|
if (!_partitionType.HasValue) |
||||
|
{ |
||||
|
throw new AbpException( |
||||
|
"Operation rate limit rule requires a partition type. " + |
||||
|
"Call PartitionByParameter(), PartitionByCurrentUser(), PartitionByClientIp(), or another PartitionBy*() method."); |
||||
|
} |
||||
|
|
||||
|
if (_partitionType == OperationRateLimitingPartitionType.Custom && _customPartitionKeyResolver == null) |
||||
|
{ |
||||
|
throw new AbpException( |
||||
|
"Custom partition type requires a key resolver. " + |
||||
|
"Call PartitionBy(keyResolver) instead of setting partition type directly."); |
||||
|
} |
||||
|
|
||||
|
return new OperationRateLimitingRuleDefinition |
||||
|
{ |
||||
|
Duration = _duration, |
||||
|
MaxCount = _maxCount, |
||||
|
PartitionType = _partitionType.Value, |
||||
|
CustomPartitionKeyResolver = _customPartitionKeyResolver, |
||||
|
IsMultiTenant = _isMultiTenant |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingRuleDefinition |
||||
|
{ |
||||
|
public TimeSpan Duration { get; set; } |
||||
|
|
||||
|
public int MaxCount { get; set; } |
||||
|
|
||||
|
public OperationRateLimitingPartitionType PartitionType { get; set; } |
||||
|
|
||||
|
public Func<OperationRateLimitingContext, Task<string>>? CustomPartitionKeyResolver { get; set; } |
||||
|
|
||||
|
public bool IsMultiTenant { get; set; } |
||||
|
} |
||||
@ -0,0 +1,147 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.AspNetCore.WebClientInfo; |
||||
|
using Volo.Abp.MultiTenancy; |
||||
|
using Volo.Abp.Users; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule |
||||
|
{ |
||||
|
private const string HostTenantKey = "host"; |
||||
|
|
||||
|
protected string PolicyName { get; } |
||||
|
protected OperationRateLimitingRuleDefinition Definition { get; } |
||||
|
protected IOperationRateLimitingStore Store { get; } |
||||
|
protected ICurrentUser CurrentUser { get; } |
||||
|
protected ICurrentTenant CurrentTenant { get; } |
||||
|
protected IWebClientInfoProvider WebClientInfoProvider { get; } |
||||
|
|
||||
|
public FixedWindowOperationRateLimitingRule( |
||||
|
string policyName, |
||||
|
OperationRateLimitingRuleDefinition definition, |
||||
|
IOperationRateLimitingStore store, |
||||
|
ICurrentUser currentUser, |
||||
|
ICurrentTenant currentTenant, |
||||
|
IWebClientInfoProvider webClientInfoProvider) |
||||
|
{ |
||||
|
PolicyName = policyName; |
||||
|
Definition = definition; |
||||
|
Store = store; |
||||
|
CurrentUser = currentUser; |
||||
|
CurrentTenant = currentTenant; |
||||
|
WebClientInfoProvider = webClientInfoProvider; |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<OperationRateLimitingRuleResult> AcquireAsync( |
||||
|
OperationRateLimitingContext context) |
||||
|
{ |
||||
|
var partitionKey = await ResolvePartitionKeyAsync(context); |
||||
|
var storeKey = BuildStoreKey(partitionKey); |
||||
|
var storeResult = await Store.IncrementAsync(storeKey, Definition.Duration, Definition.MaxCount); |
||||
|
|
||||
|
return ToRuleResult(storeResult); |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<OperationRateLimitingRuleResult> CheckAsync( |
||||
|
OperationRateLimitingContext context) |
||||
|
{ |
||||
|
var partitionKey = await ResolvePartitionKeyAsync(context); |
||||
|
var storeKey = BuildStoreKey(partitionKey); |
||||
|
var storeResult = await Store.GetAsync(storeKey, Definition.Duration, Definition.MaxCount); |
||||
|
|
||||
|
return ToRuleResult(storeResult); |
||||
|
} |
||||
|
|
||||
|
public virtual async Task ResetAsync(OperationRateLimitingContext context) |
||||
|
{ |
||||
|
var partitionKey = await ResolvePartitionKeyAsync(context); |
||||
|
var storeKey = BuildStoreKey(partitionKey); |
||||
|
await Store.ResetAsync(storeKey); |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task<string> ResolvePartitionKeyAsync(OperationRateLimitingContext context) |
||||
|
{ |
||||
|
return Definition.PartitionType switch |
||||
|
{ |
||||
|
OperationRateLimitingPartitionType.Parameter => |
||||
|
context.Parameter ?? throw new AbpException( |
||||
|
$"OperationRateLimitingContext.Parameter is required for policy '{PolicyName}' (PartitionByParameter)."), |
||||
|
|
||||
|
OperationRateLimitingPartitionType.CurrentUser => |
||||
|
CurrentUser.Id?.ToString() |
||||
|
?? throw new AbpException( |
||||
|
$"Current user is not authenticated. Policy '{PolicyName}' requires PartitionByCurrentUser. " + |
||||
|
"Use PartitionByParameter() if you need to specify the user ID explicitly."), |
||||
|
|
||||
|
OperationRateLimitingPartitionType.CurrentTenant => |
||||
|
CurrentTenant.Id?.ToString() |
||||
|
?? HostTenantKey, |
||||
|
|
||||
|
OperationRateLimitingPartitionType.ClientIp => |
||||
|
WebClientInfoProvider.ClientIpAddress |
||||
|
?? throw new AbpException( |
||||
|
$"Client IP address could not be determined. Policy '{PolicyName}' requires PartitionByClientIp. " + |
||||
|
"Ensure IWebClientInfoProvider is properly configured or use PartitionByParameter() to pass the IP explicitly."), |
||||
|
|
||||
|
OperationRateLimitingPartitionType.Email => |
||||
|
context.Parameter |
||||
|
?? CurrentUser.Email |
||||
|
?? throw new AbpException( |
||||
|
$"Email is required for policy '{PolicyName}' (PartitionByEmail). Provide it via context.Parameter or ensure the user has an email."), |
||||
|
|
||||
|
OperationRateLimitingPartitionType.PhoneNumber => |
||||
|
context.Parameter |
||||
|
?? CurrentUser.PhoneNumber |
||||
|
?? throw new AbpException( |
||||
|
$"Phone number is required for policy '{PolicyName}' (PartitionByPhoneNumber). Provide it via context.Parameter or ensure the user has a phone number."), |
||||
|
|
||||
|
OperationRateLimitingPartitionType.Custom => |
||||
|
await ResolveCustomPartitionKeyAsync(context), |
||||
|
|
||||
|
_ => throw new AbpException($"Unknown partition type: {Definition.PartitionType}") |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
protected virtual async Task<string> ResolveCustomPartitionKeyAsync(OperationRateLimitingContext context) |
||||
|
{ |
||||
|
var key = await Definition.CustomPartitionKeyResolver!(context); |
||||
|
if (string.IsNullOrEmpty(key)) |
||||
|
{ |
||||
|
throw new AbpException( |
||||
|
$"Custom partition key resolver returned null or empty for policy '{PolicyName}'. " + |
||||
|
"The resolver must return a non-empty string."); |
||||
|
} |
||||
|
return key; |
||||
|
} |
||||
|
|
||||
|
protected virtual string BuildStoreKey(string partitionKey) |
||||
|
{ |
||||
|
// Stable rule descriptor based on content so reordering rules does not change the key.
|
||||
|
// Changing Duration or MaxCount intentionally resets counters for that rule.
|
||||
|
var ruleKey = $"{(long)Definition.Duration.TotalSeconds}_{Definition.MaxCount}_{(int)Definition.PartitionType}"; |
||||
|
|
||||
|
// Tenant isolation is opt-in via WithMultiTenancy() on the rule builder.
|
||||
|
// When not set, the key is global (shared across all tenants).
|
||||
|
if (!Definition.IsMultiTenant) |
||||
|
{ |
||||
|
return $"orl:{PolicyName}:{ruleKey}:{partitionKey}"; |
||||
|
} |
||||
|
|
||||
|
var tenantId = CurrentTenant.Id.HasValue ? CurrentTenant.Id.Value.ToString() : HostTenantKey; |
||||
|
return $"orl:t:{tenantId}:{PolicyName}:{ruleKey}:{partitionKey}"; |
||||
|
} |
||||
|
|
||||
|
protected virtual OperationRateLimitingRuleResult ToRuleResult(OperationRateLimitingStoreResult storeResult) |
||||
|
{ |
||||
|
return new OperationRateLimitingRuleResult |
||||
|
{ |
||||
|
RuleName = $"{PolicyName}:Rule[{(long)Definition.Duration.TotalSeconds}s,{Definition.MaxCount},{Definition.PartitionType}]", |
||||
|
IsAllowed = storeResult.IsAllowed, |
||||
|
CurrentCount = storeResult.CurrentCount, |
||||
|
RemainingCount = storeResult.MaxCount - storeResult.CurrentCount, |
||||
|
MaxCount = storeResult.MaxCount, |
||||
|
RetryAfter = storeResult.RetryAfter, |
||||
|
WindowDuration = Definition.Duration |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public interface IOperationRateLimitingRule |
||||
|
{ |
||||
|
Task<OperationRateLimitingRuleResult> AcquireAsync(OperationRateLimitingContext context); |
||||
|
|
||||
|
Task<OperationRateLimitingRuleResult> CheckAsync(OperationRateLimitingContext context); |
||||
|
|
||||
|
Task ResetAsync(OperationRateLimitingContext context); |
||||
|
} |
||||
@ -0,0 +1,155 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.Caching.Distributed; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Volo.Abp.Caching; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
using Volo.Abp.DistributedLocking; |
||||
|
using Volo.Abp.Timing; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class DistributedCacheOperationRateLimitingStore : IOperationRateLimitingStore, ITransientDependency |
||||
|
{ |
||||
|
protected IDistributedCache<OperationRateLimitingCacheItem> Cache { get; } |
||||
|
protected IClock Clock { get; } |
||||
|
protected IAbpDistributedLock DistributedLock { get; } |
||||
|
protected AbpOperationRateLimitingOptions Options { get; } |
||||
|
|
||||
|
public DistributedCacheOperationRateLimitingStore( |
||||
|
IDistributedCache<OperationRateLimitingCacheItem> cache, |
||||
|
IClock clock, |
||||
|
IAbpDistributedLock distributedLock, |
||||
|
IOptions<AbpOperationRateLimitingOptions> options) |
||||
|
{ |
||||
|
Cache = cache; |
||||
|
Clock = clock; |
||||
|
DistributedLock = distributedLock; |
||||
|
Options = options.Value; |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<OperationRateLimitingStoreResult> IncrementAsync( |
||||
|
string key, TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
if (maxCount <= 0) |
||||
|
{ |
||||
|
return new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
CurrentCount = 0, |
||||
|
MaxCount = maxCount, |
||||
|
RetryAfter = null |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
await using (var handle = await DistributedLock.TryAcquireAsync( |
||||
|
$"OperationRateLimiting:{key}", Options.LockTimeout)) |
||||
|
{ |
||||
|
if (handle == null) |
||||
|
{ |
||||
|
throw new AbpException( |
||||
|
"Could not acquire distributed lock for operation rate limit. " + |
||||
|
"This is an infrastructure issue, not a rate limit violation."); |
||||
|
} |
||||
|
|
||||
|
var cacheItem = await Cache.GetAsync(key); |
||||
|
var now = new DateTimeOffset(Clock.Now.ToUniversalTime()); |
||||
|
|
||||
|
if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration)) |
||||
|
{ |
||||
|
cacheItem = new OperationRateLimitingCacheItem { Count = 1, WindowStart = now }; |
||||
|
await Cache.SetAsync(key, cacheItem, |
||||
|
new DistributedCacheEntryOptions |
||||
|
{ |
||||
|
AbsoluteExpirationRelativeToNow = duration |
||||
|
}); |
||||
|
|
||||
|
return new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = true, |
||||
|
CurrentCount = 1, |
||||
|
MaxCount = maxCount |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
if (cacheItem.Count >= maxCount) |
||||
|
{ |
||||
|
var retryAfter = cacheItem.WindowStart.Add(duration) - now; |
||||
|
return new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
CurrentCount = cacheItem.Count, |
||||
|
MaxCount = maxCount, |
||||
|
RetryAfter = retryAfter |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
cacheItem.Count++; |
||||
|
var expiration = cacheItem.WindowStart.Add(duration) - now; |
||||
|
await Cache.SetAsync(key, cacheItem, |
||||
|
new DistributedCacheEntryOptions |
||||
|
{ |
||||
|
AbsoluteExpirationRelativeToNow = expiration > TimeSpan.Zero ? expiration : duration |
||||
|
}); |
||||
|
|
||||
|
return new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = true, |
||||
|
CurrentCount = cacheItem.Count, |
||||
|
MaxCount = maxCount |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public virtual async Task<OperationRateLimitingStoreResult> GetAsync( |
||||
|
string key, TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
if (maxCount <= 0) |
||||
|
{ |
||||
|
return new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
CurrentCount = 0, |
||||
|
MaxCount = maxCount, |
||||
|
RetryAfter = null |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
var cacheItem = await Cache.GetAsync(key); |
||||
|
var now = new DateTimeOffset(Clock.Now.ToUniversalTime()); |
||||
|
|
||||
|
if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration)) |
||||
|
{ |
||||
|
return new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = true, |
||||
|
CurrentCount = 0, |
||||
|
MaxCount = maxCount |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
if (cacheItem.Count >= maxCount) |
||||
|
{ |
||||
|
var retryAfter = cacheItem.WindowStart.Add(duration) - now; |
||||
|
return new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
CurrentCount = cacheItem.Count, |
||||
|
MaxCount = maxCount, |
||||
|
RetryAfter = retryAfter |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = true, |
||||
|
CurrentCount = cacheItem.Count, |
||||
|
MaxCount = maxCount |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public virtual async Task ResetAsync(string key) |
||||
|
{ |
||||
|
await Cache.RemoveAsync(key); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public interface IOperationRateLimitingStore |
||||
|
{ |
||||
|
Task<OperationRateLimitingStoreResult> IncrementAsync(string key, TimeSpan duration, int maxCount); |
||||
|
|
||||
|
Task<OperationRateLimitingStoreResult> GetAsync(string key, TimeSpan duration, int maxCount); |
||||
|
|
||||
|
Task ResetAsync(string key); |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
using System; |
||||
|
using Volo.Abp.Caching; |
||||
|
using Volo.Abp.MultiTenancy; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
[CacheName("OperationRateLimiting")] |
||||
|
[IgnoreMultiTenancy] |
||||
|
public class OperationRateLimitingCacheItem |
||||
|
{ |
||||
|
public int Count { get; set; } |
||||
|
|
||||
|
public DateTimeOffset WindowStart { get; set; } |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
public bool IsAllowed { get; set; } |
||||
|
|
||||
|
public int CurrentCount { get; set; } |
||||
|
|
||||
|
public int MaxCount { get; set; } |
||||
|
|
||||
|
public TimeSpan? RetryAfter { get; set; } |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<Import Project="..\..\..\common.test.props" /> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFramework>net10.0</TargetFramework> |
||||
|
<RootNamespace /> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\..\src\Volo.Abp.Autofac\Volo.Abp.Autofac.csproj" /> |
||||
|
<ProjectReference Include="..\..\src\Volo.Abp.ExceptionHandling\Volo.Abp.ExceptionHandling.csproj" /> |
||||
|
<ProjectReference Include="..\AbpTestBase\AbpTestBase.csproj" /> |
||||
|
<ProjectReference Include="..\..\src\Volo.Abp.OperationRateLimiting\Volo.Abp.OperationRateLimiting.csproj" /> |
||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
@ -0,0 +1,117 @@ |
|||||
|
using System; |
||||
|
using Shouldly; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class AbpOperationRateLimitingException_Tests |
||||
|
{ |
||||
|
[Fact] |
||||
|
public void Should_Set_HttpStatusCode_To_429() |
||||
|
{ |
||||
|
var result = new OperationRateLimitingResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
MaxCount = 3, |
||||
|
CurrentCount = 3, |
||||
|
RemainingCount = 0, |
||||
|
RetryAfter = TimeSpan.FromMinutes(15) |
||||
|
}; |
||||
|
|
||||
|
var exception = new AbpOperationRateLimitingException("TestPolicy", result); |
||||
|
|
||||
|
exception.HttpStatusCode.ShouldBe(429); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Use_ExceedLimit_Code_When_RetryAfter_Is_Set() |
||||
|
{ |
||||
|
var result = new OperationRateLimitingResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
MaxCount = 3, |
||||
|
CurrentCount = 3, |
||||
|
RemainingCount = 0, |
||||
|
RetryAfter = TimeSpan.FromMinutes(5) |
||||
|
}; |
||||
|
|
||||
|
var exception = new AbpOperationRateLimitingException("TestPolicy", result); |
||||
|
|
||||
|
exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Use_ExceedLimitPermanently_Code_When_RetryAfter_Is_Null() |
||||
|
{ |
||||
|
var result = new OperationRateLimitingResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
MaxCount = 0, |
||||
|
CurrentCount = 0, |
||||
|
RemainingCount = 0, |
||||
|
RetryAfter = null |
||||
|
}; |
||||
|
|
||||
|
var exception = new AbpOperationRateLimitingException("TestPolicy", result); |
||||
|
|
||||
|
exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Set_Custom_ErrorCode() |
||||
|
{ |
||||
|
var result = new OperationRateLimitingResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
MaxCount = 3, |
||||
|
CurrentCount = 3, |
||||
|
RemainingCount = 0 |
||||
|
}; |
||||
|
|
||||
|
var exception = new AbpOperationRateLimitingException("TestPolicy", result, "App:Custom:Error"); |
||||
|
|
||||
|
exception.Code.ShouldBe("App:Custom:Error"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Include_Data_Properties() |
||||
|
{ |
||||
|
var result = new OperationRateLimitingResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
MaxCount = 3, |
||||
|
CurrentCount = 3, |
||||
|
RemainingCount = 0, |
||||
|
RetryAfter = TimeSpan.FromMinutes(15), |
||||
|
WindowDuration = TimeSpan.FromHours(1) |
||||
|
}; |
||||
|
|
||||
|
var exception = new AbpOperationRateLimitingException("TestPolicy", result); |
||||
|
|
||||
|
exception.Data["PolicyName"].ShouldBe("TestPolicy"); |
||||
|
exception.Data["MaxCount"].ShouldBe(3); |
||||
|
exception.Data["CurrentCount"].ShouldBe(3); |
||||
|
exception.Data["RemainingCount"].ShouldBe(0); |
||||
|
exception.Data["RetryAfterSeconds"].ShouldBe(900); |
||||
|
exception.Data["RetryAfterMinutes"].ShouldBe(15); |
||||
|
exception.Data["WindowDurationSeconds"].ShouldBe(3600); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Store_PolicyName_And_Result() |
||||
|
{ |
||||
|
var result = new OperationRateLimitingResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
MaxCount = 5, |
||||
|
CurrentCount = 5, |
||||
|
RemainingCount = 0, |
||||
|
RetryAfter = TimeSpan.FromHours(1) |
||||
|
}; |
||||
|
|
||||
|
var exception = new AbpOperationRateLimitingException("MyPolicy", result); |
||||
|
|
||||
|
exception.PolicyName.ShouldBe("MyPolicy"); |
||||
|
exception.Result.ShouldBeSameAs(result); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,102 @@ |
|||||
|
using System; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Microsoft.Extensions.DependencyInjection.Extensions; |
||||
|
using Volo.Abp.Autofac; |
||||
|
using Volo.Abp.ExceptionHandling; |
||||
|
using Volo.Abp.Modularity; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// A mock store that simulates a multi-rule Phase 2 race condition:
|
||||
|
/// - GetAsync always reports quota available (Phase 1 passes for all rules).
|
||||
|
/// - IncrementAsync succeeds for the first call, fails on the second call
|
||||
|
/// (simulating a concurrent race on Rule2), and tracks total increment calls
|
||||
|
/// so tests can verify that Rule3 was never incremented (early break).
|
||||
|
/// </summary>
|
||||
|
internal class MultiRuleRaceConditionSimulatorStore : IOperationRateLimitingStore |
||||
|
{ |
||||
|
private int _incrementCallCount; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Total number of IncrementAsync calls made.
|
||||
|
/// </summary>
|
||||
|
public int IncrementCallCount => _incrementCallCount; |
||||
|
|
||||
|
public Task<OperationRateLimitingStoreResult> GetAsync(string key, TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
return Task.FromResult(new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = true, |
||||
|
CurrentCount = 0, |
||||
|
MaxCount = maxCount |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public Task<OperationRateLimitingStoreResult> IncrementAsync(string key, TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
var callIndex = Interlocked.Increment(ref _incrementCallCount); |
||||
|
|
||||
|
if (callIndex == 2) |
||||
|
{ |
||||
|
// Second rule: simulate concurrent race - another request consumed the last slot.
|
||||
|
return Task.FromResult(new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
CurrentCount = maxCount, |
||||
|
MaxCount = maxCount, |
||||
|
RetryAfter = duration |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// First rule (and any others if early break fails): succeed.
|
||||
|
return Task.FromResult(new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = true, |
||||
|
CurrentCount = 1, |
||||
|
MaxCount = maxCount |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public Task ResetAsync(string key) |
||||
|
{ |
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[DependsOn( |
||||
|
typeof(AbpOperationRateLimitingModule), |
||||
|
typeof(AbpExceptionHandlingModule), |
||||
|
typeof(AbpTestBaseModule), |
||||
|
typeof(AbpAutofacModule) |
||||
|
)] |
||||
|
public class AbpOperationRateLimitingPhase2EarlyBreakTestModule : AbpModule |
||||
|
{ |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
context.Services.Replace( |
||||
|
ServiceDescriptor.Singleton<IOperationRateLimitingStore, MultiRuleRaceConditionSimulatorStore>()); |
||||
|
|
||||
|
Configure<AbpOperationRateLimitingOptions>(options => |
||||
|
{ |
||||
|
// 3-rule composite policy: all PartitionByParameter with different durations
|
||||
|
// so they generate unique cache keys and don't trigger duplicate rule validation.
|
||||
|
options.AddPolicy("TestMultiRuleRacePolicy", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(2), maxCount: 5) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(3), maxCount: 5) |
||||
|
.PartitionByParameter()); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Microsoft.Extensions.DependencyInjection.Extensions; |
||||
|
using Volo.Abp.Autofac; |
||||
|
using Volo.Abp.ExceptionHandling; |
||||
|
using Volo.Abp.Modularity; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// A mock store that simulates a concurrent race condition:
|
||||
|
/// - GetAsync always says the quota is available (Phase 1 checks pass).
|
||||
|
/// - IncrementAsync always says the quota is exhausted (Phase 2 finds another request consumed it).
|
||||
|
/// </summary>
|
||||
|
internal class RaceConditionSimulatorStore : IOperationRateLimitingStore |
||||
|
{ |
||||
|
public Task<OperationRateLimitingStoreResult> GetAsync(string key, TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
return Task.FromResult(new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = true, |
||||
|
CurrentCount = 0, |
||||
|
MaxCount = maxCount |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public Task<OperationRateLimitingStoreResult> IncrementAsync(string key, TimeSpan duration, int maxCount) |
||||
|
{ |
||||
|
// Simulate: between Phase 1 and Phase 2 another concurrent request consumed the last slot.
|
||||
|
return Task.FromResult(new OperationRateLimitingStoreResult |
||||
|
{ |
||||
|
IsAllowed = false, |
||||
|
CurrentCount = maxCount, |
||||
|
MaxCount = maxCount, |
||||
|
RetryAfter = duration |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public Task ResetAsync(string key) |
||||
|
{ |
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[DependsOn( |
||||
|
typeof(AbpOperationRateLimitingModule), |
||||
|
typeof(AbpExceptionHandlingModule), |
||||
|
typeof(AbpTestBaseModule), |
||||
|
typeof(AbpAutofacModule) |
||||
|
)] |
||||
|
public class AbpOperationRateLimitingPhase2RaceTestModule : AbpModule |
||||
|
{ |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
context.Services.Replace( |
||||
|
ServiceDescriptor.Transient<IOperationRateLimitingStore, RaceConditionSimulatorStore>()); |
||||
|
|
||||
|
Configure<AbpOperationRateLimitingOptions>(options => |
||||
|
{ |
||||
|
options.AddPolicy("TestRacePolicy", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,187 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using NSubstitute; |
||||
|
using Volo.Abp.AspNetCore.WebClientInfo; |
||||
|
using Volo.Abp.Autofac; |
||||
|
using Volo.Abp.ExceptionHandling; |
||||
|
using Volo.Abp.Modularity; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
[DependsOn( |
||||
|
typeof(AbpOperationRateLimitingModule), |
||||
|
typeof(AbpExceptionHandlingModule), |
||||
|
typeof(AbpTestBaseModule), |
||||
|
typeof(AbpAutofacModule) |
||||
|
)] |
||||
|
public class AbpOperationRateLimitingTestModule : AbpModule |
||||
|
{ |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
var mockWebClientInfoProvider = Substitute.For<IWebClientInfoProvider>(); |
||||
|
mockWebClientInfoProvider.ClientIpAddress.Returns("127.0.0.1"); |
||||
|
context.Services.AddSingleton<IWebClientInfoProvider>(mockWebClientInfoProvider); |
||||
|
|
||||
|
Configure<AbpOperationRateLimitingOptions>(options => |
||||
|
{ |
||||
|
options.AddPolicy("TestSimple", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
|
||||
|
options.AddPolicy("TestUserBased", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromMinutes(30), maxCount: 5) |
||||
|
.PartitionByCurrentUser(); |
||||
|
}); |
||||
|
|
||||
|
options.AddPolicy("TestComposite", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromDays(1), maxCount: 10) |
||||
|
.PartitionByCurrentUser()); |
||||
|
}); |
||||
|
|
||||
|
options.AddPolicy("TestCustomErrorCode", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) |
||||
|
.PartitionByParameter() |
||||
|
.WithErrorCode("Test:CustomError"); |
||||
|
}); |
||||
|
|
||||
|
options.AddPolicy("TestTenantBased", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByCurrentTenant(); |
||||
|
}); |
||||
|
|
||||
|
options.AddPolicy("TestClientIp", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromMinutes(15), maxCount: 10) |
||||
|
.PartitionByClientIp(); |
||||
|
}); |
||||
|
|
||||
|
options.AddPolicy("TestEmailBased", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByEmail(); |
||||
|
}); |
||||
|
|
||||
|
options.AddPolicy("TestPhoneNumberBased", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByPhoneNumber(); |
||||
|
}); |
||||
|
|
||||
|
// Composite where Rule2 triggers before Rule1 (to test no-wasted-increment)
|
||||
|
options.AddPolicy("TestCompositeRule2First", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) |
||||
|
.PartitionByCurrentUser()); |
||||
|
}); |
||||
|
|
||||
|
// Composite: ByParameter + ByClientIp (different partition types, no auth)
|
||||
|
options.AddPolicy("TestCompositeParamIp", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByClientIp()); |
||||
|
}); |
||||
|
|
||||
|
// Composite: Triple - ByParameter + ByCurrentUser + ByClientIp
|
||||
|
options.AddPolicy("TestCompositeTriple", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 4) |
||||
|
.PartitionByCurrentUser()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByClientIp()); |
||||
|
}); |
||||
|
|
||||
|
// Fix #6: policy where both rules block simultaneously with different RetryAfter durations.
|
||||
|
// Used to verify that Phase 1 checks ALL rules and reports the maximum RetryAfter.
|
||||
|
// Rule0: 5-minute window → RetryAfter ~5 min when full
|
||||
|
// Rule1: 2-hour window → RetryAfter ~2 hr when full
|
||||
|
options.AddPolicy("TestCompositeMaxRetryAfter", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 1) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(2), maxCount: 1) |
||||
|
.PartitionByParameter()); |
||||
|
}); |
||||
|
|
||||
|
// Fix #6: policy where only Rule0 blocks but Rule1 is still within limit.
|
||||
|
// Used to verify that RuleResults contains all rules, not just the blocking one.
|
||||
|
options.AddPolicy("TestCompositePartialBlock", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 1) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) |
||||
|
.PartitionByParameter()); |
||||
|
}); |
||||
|
|
||||
|
// Ban policy: maxCount=0 should always deny
|
||||
|
options.AddPolicy("TestBanPolicy", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 0) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
|
||||
|
// Custom resolver: combines Parameter + a static prefix to simulate multi-value key
|
||||
|
options.AddPolicy("TestCustomResolver", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) |
||||
|
.PartitionBy(ctx => Task.FromResult($"action:{ctx.Parameter}")); |
||||
|
}); |
||||
|
|
||||
|
// Custom resolver returning null - should throw
|
||||
|
options.AddPolicy("TestCustomResolverNull", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) |
||||
|
.PartitionBy(ctx => Task.FromResult<string>(null!)); |
||||
|
}); |
||||
|
|
||||
|
// Multi-tenant: ByParameter with tenant isolation - same param, different tenants = different counters
|
||||
|
options.AddPolicy("TestMultiTenantByParameter", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) |
||||
|
.WithMultiTenancy() |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
|
||||
|
// Multi-tenant: ByClientIp (global) - same IP, different tenants = same counter
|
||||
|
options.AddPolicy("TestMultiTenantByClientIp", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) |
||||
|
.PartitionByClientIp(); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,135 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Shouldly; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class DistributedCacheOperationRateLimitingStore_Tests : OperationRateLimitingTestBase |
||||
|
{ |
||||
|
private readonly IOperationRateLimitingStore _store; |
||||
|
|
||||
|
public DistributedCacheOperationRateLimitingStore_Tests() |
||||
|
{ |
||||
|
_store = GetRequiredService<IOperationRateLimitingStore>(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Create_New_Window_On_First_Request() |
||||
|
{ |
||||
|
var key = $"store-new-{Guid.NewGuid()}"; |
||||
|
var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); |
||||
|
|
||||
|
result.IsAllowed.ShouldBeTrue(); |
||||
|
result.CurrentCount.ShouldBe(1); |
||||
|
result.MaxCount.ShouldBe(5); |
||||
|
result.RetryAfter.ShouldBeNull(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Increment_Within_Window() |
||||
|
{ |
||||
|
var key = $"store-incr-{Guid.NewGuid()}"; |
||||
|
|
||||
|
await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); |
||||
|
var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); |
||||
|
|
||||
|
result.IsAllowed.ShouldBeTrue(); |
||||
|
result.CurrentCount.ShouldBe(2); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Reject_When_MaxCount_Reached() |
||||
|
{ |
||||
|
var key = $"store-max-{Guid.NewGuid()}"; |
||||
|
|
||||
|
await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
|
||||
|
result.IsAllowed.ShouldBeFalse(); |
||||
|
result.CurrentCount.ShouldBe(2); |
||||
|
result.RetryAfter.ShouldNotBeNull(); |
||||
|
result.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Reset_Counter() |
||||
|
{ |
||||
|
var key = $"store-reset-{Guid.NewGuid()}"; |
||||
|
|
||||
|
await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
|
||||
|
// At max now
|
||||
|
var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
result.IsAllowed.ShouldBeFalse(); |
||||
|
|
||||
|
// Reset
|
||||
|
await _store.ResetAsync(key); |
||||
|
|
||||
|
// Should be allowed again
|
||||
|
result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
result.IsAllowed.ShouldBeTrue(); |
||||
|
result.CurrentCount.ShouldBe(1); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Get_Status_Without_Incrementing() |
||||
|
{ |
||||
|
var key = $"store-get-{Guid.NewGuid()}"; |
||||
|
|
||||
|
await _store.IncrementAsync(key, TimeSpan.FromHours(1), 5); |
||||
|
|
||||
|
var result = await _store.GetAsync(key, TimeSpan.FromHours(1), 5); |
||||
|
result.IsAllowed.ShouldBeTrue(); |
||||
|
result.CurrentCount.ShouldBe(1); |
||||
|
|
||||
|
// Get again should still be 1 (no increment)
|
||||
|
result = await _store.GetAsync(key, TimeSpan.FromHours(1), 5); |
||||
|
result.CurrentCount.ShouldBe(1); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Not_Isolate_By_Tenant_At_Store_Level() |
||||
|
{ |
||||
|
// Tenant isolation is now handled at the rule level (BuildStoreKey),
|
||||
|
// not at the store level. The store treats keys as opaque strings.
|
||||
|
var key = $"store-tenant-{Guid.NewGuid()}"; |
||||
|
|
||||
|
await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
|
||||
|
var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
result.IsAllowed.ShouldBeFalse(); |
||||
|
|
||||
|
// Same key, same counter regardless of tenant context
|
||||
|
result = await _store.GetAsync(key, TimeSpan.FromHours(1), 2); |
||||
|
result.IsAllowed.ShouldBeFalse(); |
||||
|
result.CurrentCount.ShouldBe(2); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Deny_Immediately_When_MaxCount_Is_Zero_Increment() |
||||
|
{ |
||||
|
var key = $"store-zero-incr-{Guid.NewGuid()}"; |
||||
|
var result = await _store.IncrementAsync(key, TimeSpan.FromHours(1), 0); |
||||
|
|
||||
|
result.IsAllowed.ShouldBeFalse(); |
||||
|
result.CurrentCount.ShouldBe(0); |
||||
|
result.MaxCount.ShouldBe(0); |
||||
|
result.RetryAfter.ShouldBeNull(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Deny_Immediately_When_MaxCount_Is_Zero_Get() |
||||
|
{ |
||||
|
var key = $"store-zero-get-{Guid.NewGuid()}"; |
||||
|
var result = await _store.GetAsync(key, TimeSpan.FromHours(1), 0); |
||||
|
|
||||
|
result.IsAllowed.ShouldBeFalse(); |
||||
|
result.CurrentCount.ShouldBe(0); |
||||
|
result.MaxCount.ShouldBe(0); |
||||
|
result.RetryAfter.ShouldBeNull(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,197 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Shouldly; |
||||
|
using Volo.Abp.Testing; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Tests for Fix #6: Phase 1 in CheckAsync now checks ALL rules before throwing,
|
||||
|
/// so RetryAfter is the maximum across all blocking rules and RuleResults is complete.
|
||||
|
/// </summary>
|
||||
|
public class OperationRateLimitingCheckerPhase1_Tests : OperationRateLimitingTestBase |
||||
|
{ |
||||
|
private readonly IOperationRateLimitingChecker _checker; |
||||
|
|
||||
|
public OperationRateLimitingCheckerPhase1_Tests() |
||||
|
{ |
||||
|
_checker = GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Report_Max_RetryAfter_When_Multiple_Rules_Block() |
||||
|
{ |
||||
|
// TestCompositeMaxRetryAfter: Rule0 (5-min window, max=1), Rule1 (2-hr window, max=1)
|
||||
|
// Both rules use PartitionByParameter with the same key, so one request exhausts both.
|
||||
|
var param = $"max-retry-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// First request: both rules go from 0 to 1 (exhausted, since maxCount=1)
|
||||
|
await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); |
||||
|
|
||||
|
// Second request: both Rule0 and Rule1 are blocking.
|
||||
|
// Phase 1 checks all rules → RetryAfter must be the larger one (~2 hours).
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); |
||||
|
}); |
||||
|
|
||||
|
// RetryAfter should be at least 1 hour (i.e., from Rule1's 2-hour window, not Rule0's 5-min window)
|
||||
|
exception.Result.RetryAfter.ShouldNotBeNull(); |
||||
|
exception.Result.RetryAfter!.Value.ShouldBeGreaterThan(TimeSpan.FromHours(1)); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Include_All_Rules_In_RuleResults_When_Multiple_Rules_Block() |
||||
|
{ |
||||
|
var param = $"all-rules-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Exhaust both rules
|
||||
|
await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); |
||||
|
}); |
||||
|
|
||||
|
// Both rules must appear in RuleResults (not just the first blocking one)
|
||||
|
exception.Result.RuleResults.ShouldNotBeNull(); |
||||
|
exception.Result.RuleResults!.Count.ShouldBe(2); |
||||
|
exception.Result.RuleResults[0].IsAllowed.ShouldBeFalse(); |
||||
|
exception.Result.RuleResults[1].IsAllowed.ShouldBeFalse(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Include_Non_Blocking_Rules_In_RuleResults() |
||||
|
{ |
||||
|
// TestCompositePartialBlock: Rule0 (max=1) blocks, Rule1 (max=100) is still within limit.
|
||||
|
// RuleResults must contain BOTH rules so callers get the full picture.
|
||||
|
var param = $"partial-block-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Exhaust only Rule0 (max=1)
|
||||
|
await _checker.CheckAsync("TestCompositePartialBlock", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCompositePartialBlock", context); |
||||
|
}); |
||||
|
|
||||
|
exception.Result.RuleResults.ShouldNotBeNull(); |
||||
|
exception.Result.RuleResults!.Count.ShouldBe(2); |
||||
|
|
||||
|
// Rule0 is blocking
|
||||
|
exception.Result.RuleResults[0].IsAllowed.ShouldBeFalse(); |
||||
|
exception.Result.RuleResults[0].MaxCount.ShouldBe(1); |
||||
|
|
||||
|
// Rule1 is still allowed (only 1/100 used), but is still present in results
|
||||
|
exception.Result.RuleResults[1].IsAllowed.ShouldBeTrue(); |
||||
|
exception.Result.RuleResults[1].MaxCount.ShouldBe(100); |
||||
|
exception.Result.RuleResults[1].RemainingCount.ShouldBe(99); |
||||
|
|
||||
|
// The overall RetryAfter comes only from the blocking Rule0
|
||||
|
exception.Result.RetryAfter.ShouldNotBeNull(); |
||||
|
exception.Result.RetryAfter!.Value.TotalMinutes.ShouldBeLessThan(61); // ~1 hour from Rule0
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Tests for Phase 2 early break: when a multi-rule policy encounters a race condition
|
||||
|
/// in Phase 2 (Rule2 fails), Rule3 should NOT be incremented.
|
||||
|
/// Uses a mock store where IncrementAsync fails on the 2nd call.
|
||||
|
/// </summary>
|
||||
|
public class OperationRateLimitingCheckerPhase2EarlyBreak_Tests |
||||
|
: AbpIntegratedTest<AbpOperationRateLimitingPhase2EarlyBreakTestModule> |
||||
|
{ |
||||
|
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) |
||||
|
{ |
||||
|
options.UseAutofac(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Not_Increment_Remaining_Rules_After_Phase2_Failure() |
||||
|
{ |
||||
|
// 3-rule policy. Mock store: Rule1 increment succeeds, Rule2 increment fails (race),
|
||||
|
// Rule3 should NOT be incremented due to early break.
|
||||
|
var checker = GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var store = (MultiRuleRaceConditionSimulatorStore)GetRequiredService<IOperationRateLimitingStore>(); |
||||
|
var context = new OperationRateLimitingContext { Parameter = "early-break-test" }; |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestMultiRuleRacePolicy", context); |
||||
|
}); |
||||
|
|
||||
|
exception.PolicyName.ShouldBe("TestMultiRuleRacePolicy"); |
||||
|
exception.Result.IsAllowed.ShouldBeFalse(); |
||||
|
|
||||
|
// Key assertion: only 2 IncrementAsync calls were made (Rule1 + Rule2).
|
||||
|
// Rule3 was skipped (used CheckAsync instead) due to early break.
|
||||
|
store.IncrementCallCount.ShouldBe(2); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Include_All_Rule_Results_Despite_Early_Break() |
||||
|
{ |
||||
|
// Even with early break, the aggregated result should contain all 3 rules
|
||||
|
// (Rule3 via CheckAsync instead of AcquireAsync).
|
||||
|
var checker = GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var context = new OperationRateLimitingContext { Parameter = $"all-results-{Guid.NewGuid()}" }; |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestMultiRuleRacePolicy", context); |
||||
|
}); |
||||
|
|
||||
|
exception.Result.RuleResults.ShouldNotBeNull(); |
||||
|
exception.Result.RuleResults!.Count.ShouldBe(3); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Tests for Fix #1: Phase 2 in CheckAsync now checks the result of AcquireAsync.
|
||||
|
/// Uses a mock store that simulates a concurrent race condition:
|
||||
|
/// GetAsync (Phase 1) always reports quota available, but IncrementAsync (Phase 2) returns denied.
|
||||
|
/// </summary>
|
||||
|
public class OperationRateLimitingCheckerPhase2Race_Tests |
||||
|
: AbpIntegratedTest<AbpOperationRateLimitingPhase2RaceTestModule> |
||||
|
{ |
||||
|
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) |
||||
|
{ |
||||
|
options.UseAutofac(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Throw_When_Phase2_Increment_Returns_Denied_Due_To_Race() |
||||
|
{ |
||||
|
// The mock store always returns IsAllowed=true in GetAsync (Phase 1 passes)
|
||||
|
// but always returns IsAllowed=false in IncrementAsync (simulates concurrent exhaustion).
|
||||
|
// Before Fix #1, CheckAsync would silently succeed. After the fix it must throw.
|
||||
|
var checker = GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var context = new OperationRateLimitingContext { Parameter = "race-test" }; |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestRacePolicy", context); |
||||
|
}); |
||||
|
|
||||
|
exception.PolicyName.ShouldBe("TestRacePolicy"); |
||||
|
exception.Result.IsAllowed.ShouldBeFalse(); |
||||
|
exception.HttpStatusCode.ShouldBe(429); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task IsAllowedAsync_Should_Not_Be_Affected_By_Phase2_Fix() |
||||
|
{ |
||||
|
// IsAllowedAsync is read-only and does not call IncrementAsync,
|
||||
|
// so it should not be affected by the mock store's deny-on-increment behavior.
|
||||
|
var checker = GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var context = new OperationRateLimitingContext { Parameter = "is-allowed-race" }; |
||||
|
|
||||
|
// Should return true because GetAsync always returns allowed in the mock store
|
||||
|
var allowed = await checker.IsAllowedAsync("TestRacePolicy", context); |
||||
|
allowed.ShouldBeTrue(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,798 @@ |
|||||
|
using System; |
||||
|
using System.Security.Claims; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Shouldly; |
||||
|
using Volo.Abp.Security.Claims; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase |
||||
|
{ |
||||
|
private readonly IOperationRateLimitingChecker _checker; |
||||
|
|
||||
|
public OperationRateLimitingChecker_Tests() |
||||
|
{ |
||||
|
_checker = GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Allow_Within_Limit() |
||||
|
{ |
||||
|
var context = new OperationRateLimitingContext { Parameter = "test@example.com" }; |
||||
|
|
||||
|
// Should not throw for 3 requests (max is 3)
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Reject_When_Exceeded() |
||||
|
{ |
||||
|
var param = $"exceed-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
}); |
||||
|
|
||||
|
exception.PolicyName.ShouldBe("TestSimple"); |
||||
|
exception.Result.IsAllowed.ShouldBeFalse(); |
||||
|
exception.HttpStatusCode.ShouldBe(429); |
||||
|
exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Return_Correct_RemainingCount() |
||||
|
{ |
||||
|
var param = $"remaining-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
var status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.IsAllowed.ShouldBeTrue(); |
||||
|
status.RemainingCount.ShouldBe(3); |
||||
|
status.CurrentCount.ShouldBe(0); |
||||
|
|
||||
|
// Increment once
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.IsAllowed.ShouldBeTrue(); |
||||
|
status.RemainingCount.ShouldBe(2); |
||||
|
status.CurrentCount.ShouldBe(1); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Return_Correct_RetryAfter() |
||||
|
{ |
||||
|
var param = $"retry-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
}); |
||||
|
|
||||
|
exception.Result.RetryAfter.ShouldNotBeNull(); |
||||
|
exception.Result.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Handle_Composite_Policy_All_Pass() |
||||
|
{ |
||||
|
var userId = Guid.NewGuid(); |
||||
|
|
||||
|
using (var scope = ServiceProvider.CreateScope()) |
||||
|
{ |
||||
|
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>(); |
||||
|
var claimsPrincipal = CreateClaimsPrincipal(userId); |
||||
|
|
||||
|
using (principalAccessor.Change(claimsPrincipal)) |
||||
|
{ |
||||
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var context = new OperationRateLimitingContext { Parameter = $"composite-{Guid.NewGuid()}" }; |
||||
|
|
||||
|
// Should pass: both rules within limits
|
||||
|
await checker.CheckAsync("TestComposite", context); |
||||
|
await checker.CheckAsync("TestComposite", context); |
||||
|
await checker.CheckAsync("TestComposite", context); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Reject_Composite_Policy_When_Any_Rule_Exceeds() |
||||
|
{ |
||||
|
var userId = Guid.NewGuid(); |
||||
|
|
||||
|
using (var scope = ServiceProvider.CreateScope()) |
||||
|
{ |
||||
|
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>(); |
||||
|
var claimsPrincipal = CreateClaimsPrincipal(userId); |
||||
|
|
||||
|
using (principalAccessor.Change(claimsPrincipal)) |
||||
|
{ |
||||
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var param = $"composite-reject-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await checker.CheckAsync("TestComposite", context); |
||||
|
await checker.CheckAsync("TestComposite", context); |
||||
|
await checker.CheckAsync("TestComposite", context); |
||||
|
|
||||
|
// 4th request: Rule1 (max 3 per hour by parameter) should fail
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestComposite", context); |
||||
|
}); |
||||
|
|
||||
|
exception.PolicyName.ShouldBe("TestComposite"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Reset_Counter() |
||||
|
{ |
||||
|
var param = $"reset-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
// Should be at limit
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
}); |
||||
|
|
||||
|
// Reset
|
||||
|
await _checker.ResetAsync("TestSimple", context); |
||||
|
|
||||
|
// Should be allowed again
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Use_Custom_ErrorCode() |
||||
|
{ |
||||
|
var param = $"custom-error-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestCustomErrorCode", context); |
||||
|
await _checker.CheckAsync("TestCustomErrorCode", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCustomErrorCode", context); |
||||
|
}); |
||||
|
|
||||
|
exception.Code.ShouldBe("Test:CustomError"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Throw_For_Unknown_Policy() |
||||
|
{ |
||||
|
await Assert.ThrowsAsync<AbpException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("NonExistentPolicy"); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Skip_When_Disabled() |
||||
|
{ |
||||
|
var options = GetRequiredService<Microsoft.Extensions.Options.IOptions<AbpOperationRateLimitingOptions>>(); |
||||
|
var originalValue = options.Value.IsEnabled; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
options.Value.IsEnabled = false; |
||||
|
|
||||
|
var param = $"disabled-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Should pass unlimited times
|
||||
|
for (var i = 0; i < 100; i++) |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
} |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
options.Value.IsEnabled = originalValue; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Work_With_IsAllowedAsync() |
||||
|
{ |
||||
|
var param = $"is-allowed-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// IsAllowedAsync does not consume quota
|
||||
|
(await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); |
||||
|
(await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); |
||||
|
|
||||
|
// Status should still show 0 consumed
|
||||
|
var status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.CurrentCount.ShouldBe(0); |
||||
|
status.RemainingCount.ShouldBe(3); |
||||
|
|
||||
|
// Now consume all
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
(await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Partition_By_Different_Parameters() |
||||
|
{ |
||||
|
var param1 = $"param1-{Guid.NewGuid()}"; |
||||
|
var param2 = $"param2-{Guid.NewGuid()}"; |
||||
|
|
||||
|
var context1 = new OperationRateLimitingContext { Parameter = param1 }; |
||||
|
var context2 = new OperationRateLimitingContext { Parameter = param2 }; |
||||
|
|
||||
|
// Consume all for param1
|
||||
|
await _checker.CheckAsync("TestSimple", context1); |
||||
|
await _checker.CheckAsync("TestSimple", context1); |
||||
|
await _checker.CheckAsync("TestSimple", context1); |
||||
|
|
||||
|
// param2 should still be allowed
|
||||
|
await _checker.CheckAsync("TestSimple", context2); |
||||
|
(await _checker.IsAllowedAsync("TestSimple", context2)).ShouldBeTrue(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Support_ExtraProperties_In_Exception_Data() |
||||
|
{ |
||||
|
var param = $"extra-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext |
||||
|
{ |
||||
|
Parameter = param, |
||||
|
ExtraProperties = |
||||
|
{ |
||||
|
["Email"] = "test@example.com", |
||||
|
["UserId"] = "user-123" |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
}); |
||||
|
|
||||
|
exception.Data["Email"].ShouldBe("test@example.com"); |
||||
|
exception.Data["UserId"].ShouldBe("user-123"); |
||||
|
exception.Data["PolicyName"].ShouldBe("TestSimple"); |
||||
|
exception.Data["MaxCount"].ShouldBe(3); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Partition_By_Email_Via_Parameter() |
||||
|
{ |
||||
|
var email = $"email-param-{Guid.NewGuid()}@example.com"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = email }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestEmailBased", context); |
||||
|
await _checker.CheckAsync("TestEmailBased", context); |
||||
|
await _checker.CheckAsync("TestEmailBased", context); |
||||
|
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestEmailBased", context); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Partition_By_Email_Via_CurrentUser_Fallback() |
||||
|
{ |
||||
|
var userId = Guid.NewGuid(); |
||||
|
|
||||
|
using (var scope = ServiceProvider.CreateScope()) |
||||
|
{ |
||||
|
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>(); |
||||
|
var claimsPrincipal = CreateClaimsPrincipal(userId); |
||||
|
|
||||
|
using (principalAccessor.Change(claimsPrincipal)) |
||||
|
{ |
||||
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
|
||||
|
// No Parameter set, should fall back to ICurrentUser.Email
|
||||
|
var context = new OperationRateLimitingContext(); |
||||
|
|
||||
|
await checker.CheckAsync("TestEmailBased", context); |
||||
|
await checker.CheckAsync("TestEmailBased", context); |
||||
|
await checker.CheckAsync("TestEmailBased", context); |
||||
|
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestEmailBased", context); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Partition_By_PhoneNumber_Via_Parameter() |
||||
|
{ |
||||
|
var phone = $"phone-param-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = phone }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
await _checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
await _checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Partition_By_PhoneNumber_Via_CurrentUser_Fallback() |
||||
|
{ |
||||
|
var userId = Guid.NewGuid(); |
||||
|
|
||||
|
using (var scope = ServiceProvider.CreateScope()) |
||||
|
{ |
||||
|
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>(); |
||||
|
var claimsPrincipal = CreateClaimsPrincipal(userId); |
||||
|
|
||||
|
using (principalAccessor.Change(claimsPrincipal)) |
||||
|
{ |
||||
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
|
||||
|
// No Parameter set, should fall back to ICurrentUser.PhoneNumber
|
||||
|
var context = new OperationRateLimitingContext(); |
||||
|
|
||||
|
await checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
await checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
await checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Throw_When_Email_Not_Available() |
||||
|
{ |
||||
|
// No Parameter and no authenticated user
|
||||
|
var context = new OperationRateLimitingContext(); |
||||
|
|
||||
|
await Assert.ThrowsAsync<AbpException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestEmailBased", context); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Not_Waste_Rule1_Count_When_Rule2_Blocks() |
||||
|
{ |
||||
|
// TestCompositeRule2First: Rule1 (Parameter, 5/hour), Rule2 (CurrentUser, 2/hour)
|
||||
|
// Rule2 triggers at 2. Rule1 should NOT be incremented for blocked requests.
|
||||
|
var userId = Guid.NewGuid(); |
||||
|
|
||||
|
using (var scope = ServiceProvider.CreateScope()) |
||||
|
{ |
||||
|
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>(); |
||||
|
var claimsPrincipal = CreateClaimsPrincipal(userId); |
||||
|
|
||||
|
using (principalAccessor.Change(claimsPrincipal)) |
||||
|
{ |
||||
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var param = $"no-waste-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// 2 successful requests (Rule1: 2/5, Rule2: 2/2)
|
||||
|
await checker.CheckAsync("TestCompositeRule2First", context); |
||||
|
await checker.CheckAsync("TestCompositeRule2First", context); |
||||
|
|
||||
|
// 3rd request: Rule2 blocks (2/2 at max)
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestCompositeRule2First", context); |
||||
|
}); |
||||
|
|
||||
|
// Verify Rule1 was NOT incremented for the blocked request
|
||||
|
// Rule1 should still be at 2/5, not 3/5
|
||||
|
var status = await checker.GetStatusAsync("TestCompositeRule2First", context); |
||||
|
// GetStatusAsync returns the most restrictive rule (Rule2 at 2/2)
|
||||
|
// But we can verify Rule1 by checking RuleResults
|
||||
|
status.RuleResults.ShouldNotBeNull(); |
||||
|
status.RuleResults!.Count.ShouldBe(2); |
||||
|
|
||||
|
// Rule1 (index 0): should be 2/5, remaining 3
|
||||
|
status.RuleResults[0].RemainingCount.ShouldBe(3); |
||||
|
status.RuleResults[0].MaxCount.ShouldBe(5); |
||||
|
|
||||
|
// Rule2 (index 1): should be 2/2, remaining 0
|
||||
|
status.RuleResults[1].RemainingCount.ShouldBe(0); |
||||
|
status.RuleResults[1].MaxCount.ShouldBe(2); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Composite_ParamIp_Ip_Triggers_First() |
||||
|
{ |
||||
|
// TestCompositeParamIp: Rule1 (Parameter, 5/hour), Rule2 (ClientIp, 3/hour)
|
||||
|
// IP limit (3) is lower, should trigger first
|
||||
|
var param = $"param-ip-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// 3 successful requests
|
||||
|
await _checker.CheckAsync("TestCompositeParamIp", context); |
||||
|
await _checker.CheckAsync("TestCompositeParamIp", context); |
||||
|
await _checker.CheckAsync("TestCompositeParamIp", context); |
||||
|
|
||||
|
// 4th: IP rule blocks (3/3)
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCompositeParamIp", context); |
||||
|
}); |
||||
|
|
||||
|
exception.PolicyName.ShouldBe("TestCompositeParamIp"); |
||||
|
|
||||
|
// Verify counts: Rule1 should be 3/5, Rule2 should be 3/3
|
||||
|
var status = await _checker.GetStatusAsync("TestCompositeParamIp", context); |
||||
|
status.RuleResults.ShouldNotBeNull(); |
||||
|
status.RuleResults!.Count.ShouldBe(2); |
||||
|
|
||||
|
status.RuleResults[0].RemainingCount.ShouldBe(2); // Parameter: 3/5, remaining 2
|
||||
|
status.RuleResults[0].MaxCount.ShouldBe(5); |
||||
|
status.RuleResults[1].RemainingCount.ShouldBe(0); // IP: 3/3, remaining 0
|
||||
|
status.RuleResults[1].MaxCount.ShouldBe(3); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Composite_ParamIp_Different_Params_Share_Ip() |
||||
|
{ |
||||
|
// Different parameters should have independent Rule1 counters
|
||||
|
// but share the same Rule2 (IP) counter
|
||||
|
var param1 = $"share-ip-1-{Guid.NewGuid()}"; |
||||
|
var param2 = $"share-ip-2-{Guid.NewGuid()}"; |
||||
|
var context1 = new OperationRateLimitingContext { Parameter = param1 }; |
||||
|
var context2 = new OperationRateLimitingContext { Parameter = param2 }; |
||||
|
|
||||
|
// 2 requests with param1
|
||||
|
await _checker.CheckAsync("TestCompositeParamIp", context1); |
||||
|
await _checker.CheckAsync("TestCompositeParamIp", context1); |
||||
|
|
||||
|
// 1 request with param2 (IP counter is now at 3/3)
|
||||
|
await _checker.CheckAsync("TestCompositeParamIp", context2); |
||||
|
|
||||
|
// 4th request with param2: IP rule blocks (3/3 from combined)
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCompositeParamIp", context2); |
||||
|
}); |
||||
|
|
||||
|
// param1 Rule1 should be at 2/5
|
||||
|
var status1 = await _checker.GetStatusAsync("TestCompositeParamIp", context1); |
||||
|
status1.RuleResults![0].RemainingCount.ShouldBe(3); // Parameter: 2/5
|
||||
|
status1.RuleResults[0].MaxCount.ShouldBe(5); |
||||
|
|
||||
|
// param2 Rule1 should be at 1/5
|
||||
|
var status2 = await _checker.GetStatusAsync("TestCompositeParamIp", context2); |
||||
|
status2.RuleResults![0].RemainingCount.ShouldBe(4); // Parameter: 1/5
|
||||
|
status2.RuleResults[0].MaxCount.ShouldBe(5); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Composite_Triple_Lowest_Limit_Triggers_First() |
||||
|
{ |
||||
|
// TestCompositeTriple: Rule1 (Parameter, 5/hour), Rule2 (User, 4/hour), Rule3 (IP, 3/hour)
|
||||
|
// IP limit (3) is lowest, should trigger first
|
||||
|
var userId = Guid.NewGuid(); |
||||
|
|
||||
|
using (var scope = ServiceProvider.CreateScope()) |
||||
|
{ |
||||
|
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>(); |
||||
|
var claimsPrincipal = CreateClaimsPrincipal(userId); |
||||
|
|
||||
|
using (principalAccessor.Change(claimsPrincipal)) |
||||
|
{ |
||||
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var param = $"triple-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// 3 successful requests
|
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
|
||||
|
// 4th: IP rule blocks (3/3)
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
}); |
||||
|
|
||||
|
// Verify all three rules
|
||||
|
var status = await checker.GetStatusAsync("TestCompositeTriple", context); |
||||
|
status.RuleResults.ShouldNotBeNull(); |
||||
|
status.RuleResults!.Count.ShouldBe(3); |
||||
|
|
||||
|
status.RuleResults[0].RemainingCount.ShouldBe(2); // Parameter: 3/5
|
||||
|
status.RuleResults[0].MaxCount.ShouldBe(5); |
||||
|
status.RuleResults[1].RemainingCount.ShouldBe(1); // User: 3/4
|
||||
|
status.RuleResults[1].MaxCount.ShouldBe(4); |
||||
|
status.RuleResults[2].RemainingCount.ShouldBe(0); // IP: 3/3
|
||||
|
status.RuleResults[2].MaxCount.ShouldBe(3); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Composite_Triple_No_Wasted_Increment_On_Block() |
||||
|
{ |
||||
|
// When IP (Rule3) blocks, Rule1 and Rule2 should NOT be incremented
|
||||
|
var userId = Guid.NewGuid(); |
||||
|
|
||||
|
using (var scope = ServiceProvider.CreateScope()) |
||||
|
{ |
||||
|
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>(); |
||||
|
var claimsPrincipal = CreateClaimsPrincipal(userId); |
||||
|
|
||||
|
using (principalAccessor.Change(claimsPrincipal)) |
||||
|
{ |
||||
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var param = $"triple-nowaste-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// 3 successful requests (all rules increment to 3)
|
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
|
||||
|
// Attempt 3 more blocked requests
|
||||
|
for (var i = 0; i < 3; i++) |
||||
|
{ |
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Verify Rule1 and Rule2 were NOT incremented beyond 3
|
||||
|
var status = await checker.GetStatusAsync("TestCompositeTriple", context); |
||||
|
status.RuleResults![0].RemainingCount.ShouldBe(2); // Parameter: still 3/5
|
||||
|
status.RuleResults[1].RemainingCount.ShouldBe(1); // User: still 3/4
|
||||
|
status.RuleResults[2].RemainingCount.ShouldBe(0); // IP: still 3/3
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Composite_Reset_All_Rules() |
||||
|
{ |
||||
|
// Verify reset clears all rules in a composite policy
|
||||
|
var userId = Guid.NewGuid(); |
||||
|
|
||||
|
using (var scope = ServiceProvider.CreateScope()) |
||||
|
{ |
||||
|
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>(); |
||||
|
var claimsPrincipal = CreateClaimsPrincipal(userId); |
||||
|
|
||||
|
using (principalAccessor.Change(claimsPrincipal)) |
||||
|
{ |
||||
|
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
var param = $"triple-reset-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Exhaust IP limit
|
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
}); |
||||
|
|
||||
|
// Reset
|
||||
|
await checker.ResetAsync("TestCompositeTriple", context); |
||||
|
|
||||
|
// All rules should be cleared
|
||||
|
var status = await checker.GetStatusAsync("TestCompositeTriple", context); |
||||
|
status.IsAllowed.ShouldBeTrue(); |
||||
|
status.RuleResults![0].RemainingCount.ShouldBe(5); // Parameter: 0/5
|
||||
|
status.RuleResults[1].RemainingCount.ShouldBe(4); // User: 0/4
|
||||
|
status.RuleResults[2].RemainingCount.ShouldBe(3); // IP: 0/3
|
||||
|
|
||||
|
// Should be able to use again
|
||||
|
await checker.CheckAsync("TestCompositeTriple", context); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Throw_When_PhoneNumber_Not_Available() |
||||
|
{ |
||||
|
// No Parameter and no authenticated user
|
||||
|
var context = new OperationRateLimitingContext(); |
||||
|
|
||||
|
await Assert.ThrowsAsync<AbpException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestPhoneNumberBased", context); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Deny_First_Request_When_MaxCount_Is_Zero() |
||||
|
{ |
||||
|
var context = new OperationRateLimitingContext { Parameter = $"ban-{Guid.NewGuid()}" }; |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestBanPolicy", context); |
||||
|
}); |
||||
|
|
||||
|
exception.Result.IsAllowed.ShouldBeFalse(); |
||||
|
exception.Result.MaxCount.ShouldBe(0); |
||||
|
exception.Result.RetryAfter.ShouldBeNull(); |
||||
|
exception.HttpStatusCode.ShouldBe(429); |
||||
|
exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_IsAllowed_Return_False_When_MaxCount_Is_Zero() |
||||
|
{ |
||||
|
var context = new OperationRateLimitingContext { Parameter = $"ban-allowed-{Guid.NewGuid()}" }; |
||||
|
|
||||
|
var allowed = await _checker.IsAllowedAsync("TestBanPolicy", context); |
||||
|
allowed.ShouldBeFalse(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_GetStatus_Show_Not_Allowed_When_MaxCount_Is_Zero() |
||||
|
{ |
||||
|
var context = new OperationRateLimitingContext { Parameter = $"ban-status-{Guid.NewGuid()}" }; |
||||
|
|
||||
|
var status = await _checker.GetStatusAsync("TestBanPolicy", context); |
||||
|
status.IsAllowed.ShouldBeFalse(); |
||||
|
status.MaxCount.ShouldBe(0); |
||||
|
status.RemainingCount.ShouldBe(0); |
||||
|
status.RetryAfter.ShouldBeNull(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Partition_By_Custom_Resolver() |
||||
|
{ |
||||
|
// TestCustomResolver uses PartitionBy(ctx => $"action:{ctx.Parameter}")
|
||||
|
// Two different parameters => independent counters
|
||||
|
var param1 = $"op1-{Guid.NewGuid()}"; |
||||
|
var param2 = $"op2-{Guid.NewGuid()}"; |
||||
|
|
||||
|
var ctx1 = new OperationRateLimitingContext { Parameter = param1 }; |
||||
|
var ctx2 = new OperationRateLimitingContext { Parameter = param2 }; |
||||
|
|
||||
|
// Exhaust param1's quota (max=2)
|
||||
|
await _checker.CheckAsync("TestCustomResolver", ctx1); |
||||
|
await _checker.CheckAsync("TestCustomResolver", ctx1); |
||||
|
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCustomResolver", ctx1); |
||||
|
}); |
||||
|
|
||||
|
// param2 should still be allowed
|
||||
|
await _checker.CheckAsync("TestCustomResolver", ctx2); |
||||
|
(await _checker.IsAllowedAsync("TestCustomResolver", ctx2)).ShouldBeTrue(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Throw_When_Custom_Resolver_Returns_Null() |
||||
|
{ |
||||
|
var context = new OperationRateLimitingContext { Parameter = "test" }; |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCustomResolverNull", context); |
||||
|
}); |
||||
|
|
||||
|
exception.Message.ShouldContain("Custom partition key resolver returned null or empty"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Throw_When_Policy_Has_Duplicate_Rules() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
Assert.Throws<AbpException>(() => |
||||
|
{ |
||||
|
options.AddPolicy("DuplicateRulePolicy", policy => |
||||
|
{ |
||||
|
policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter()); |
||||
|
policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter()); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Return_Correct_CurrentCount_In_RuleResults() |
||||
|
{ |
||||
|
var param = $"current-count-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
var status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.RuleResults.ShouldNotBeNull(); |
||||
|
status.RuleResults!.Count.ShouldBe(1); |
||||
|
status.RuleResults[0].CurrentCount.ShouldBe(2); |
||||
|
status.RuleResults[0].RemainingCount.ShouldBe(1); |
||||
|
status.RuleResults[0].MaxCount.ShouldBe(3); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task ResetAsync_Should_Skip_When_Disabled() |
||||
|
{ |
||||
|
var options = GetRequiredService<Microsoft.Extensions.Options.IOptions<AbpOperationRateLimitingOptions>>(); |
||||
|
var originalValue = options.Value.IsEnabled; |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var param = $"reset-disabled-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Exhaust the quota
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
// Disable and call ResetAsync — should be a no-op (counter not actually reset)
|
||||
|
options.Value.IsEnabled = false; |
||||
|
await _checker.ResetAsync("TestSimple", context); |
||||
|
|
||||
|
// Re-enable: quota should still be exhausted because reset was skipped
|
||||
|
options.Value.IsEnabled = true; |
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
}); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
options.Value.IsEnabled = originalValue; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static ClaimsPrincipal CreateClaimsPrincipal(Guid userId) |
||||
|
{ |
||||
|
return new ClaimsPrincipal( |
||||
|
new ClaimsIdentity( |
||||
|
new[] |
||||
|
{ |
||||
|
new Claim(AbpClaimTypes.UserId, userId.ToString()), |
||||
|
new Claim(AbpClaimTypes.Email, "test@example.com"), |
||||
|
new Claim(AbpClaimTypes.PhoneNumber, "1234567890") |
||||
|
}, |
||||
|
"TestAuth")); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,408 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Shouldly; |
||||
|
using Volo.Abp.AspNetCore.ExceptionHandling; |
||||
|
using Volo.Abp.Localization; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingFrontendIntegration_Tests : OperationRateLimitingTestBase |
||||
|
{ |
||||
|
private readonly IOperationRateLimitingChecker _checker; |
||||
|
private readonly IExceptionToErrorInfoConverter _errorInfoConverter; |
||||
|
private readonly IOperationRateLimitingFormatter _formatter; |
||||
|
|
||||
|
public OperationRateLimitingFrontendIntegration_Tests() |
||||
|
{ |
||||
|
_checker = GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
_errorInfoConverter = GetRequiredService<IExceptionToErrorInfoConverter>(); |
||||
|
_formatter = GetRequiredService<IOperationRateLimitingFormatter>(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task ErrorInfo_Should_Contain_Localized_Message_En() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
var param = $"frontend-en-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
}); |
||||
|
|
||||
|
var errorInfo = _errorInfoConverter.Convert(exception); |
||||
|
|
||||
|
// The localized message should contain "Operation rate limit exceeded"
|
||||
|
errorInfo.Message.ShouldContain("Operation rate limit exceeded"); |
||||
|
errorInfo.Message.ShouldContain("minute(s)"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task ErrorInfo_Should_Contain_Localized_Message_ZhHans() |
||||
|
{ |
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
var param = $"frontend-zh-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
}); |
||||
|
|
||||
|
var errorInfo = _errorInfoConverter.Convert(exception); |
||||
|
|
||||
|
// The localized message should be in Chinese
|
||||
|
errorInfo.Message.ShouldContain("操作频率超出限制"); |
||||
|
errorInfo.Message.ShouldContain("分钟"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task ErrorInfo_Should_Include_Structured_Data_For_Frontend() |
||||
|
{ |
||||
|
var param = $"frontend-data-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext |
||||
|
{ |
||||
|
Parameter = param, |
||||
|
ExtraProperties = |
||||
|
{ |
||||
|
["Email"] = "user@example.com" |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
}); |
||||
|
|
||||
|
var errorInfo = _errorInfoConverter.Convert(exception); |
||||
|
|
||||
|
// Frontend receives error.code
|
||||
|
errorInfo.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); |
||||
|
|
||||
|
// Frontend receives error.data for countdown timer and UI display
|
||||
|
exception.Data["PolicyName"].ShouldBe("TestSimple"); |
||||
|
exception.Data["MaxCount"].ShouldBe(3); |
||||
|
exception.Data["CurrentCount"].ShouldBe(3); |
||||
|
exception.Data["RemainingCount"].ShouldBe(0); |
||||
|
|
||||
|
// RetryAfterSeconds: frontend uses this for countdown
|
||||
|
var retryAfterSeconds = (int)exception.Data["RetryAfterSeconds"]!; |
||||
|
retryAfterSeconds.ShouldBeGreaterThan(0); |
||||
|
retryAfterSeconds.ShouldBeLessThanOrEqualTo(3600); // max 1 hour window
|
||||
|
|
||||
|
// RetryAfterMinutes: frontend uses this for display
|
||||
|
var retryAfterMinutes = (int)exception.Data["RetryAfterMinutes"]!; |
||||
|
retryAfterMinutes.ShouldBeGreaterThan(0); |
||||
|
|
||||
|
// RetryAfter: localized human-readable string
|
||||
|
exception.Data["RetryAfter"].ShouldNotBeNull(); |
||||
|
exception.Data["RetryAfter"].ShouldBeOfType<string>(); |
||||
|
|
||||
|
// WindowDurationSeconds: the configured window duration
|
||||
|
var windowDurationSeconds = (int)exception.Data["WindowDurationSeconds"]!; |
||||
|
windowDurationSeconds.ShouldBe(3600); // 1 hour window
|
||||
|
|
||||
|
// WindowDescription: localized human-readable window description (e.g. "1 hour(s)")
|
||||
|
exception.Data["WindowDescription"].ShouldNotBeNull(); |
||||
|
exception.Data["WindowDescription"].ShouldBeOfType<string>(); |
||||
|
|
||||
|
// RuleDetails: complete rule information for frontend
|
||||
|
var ruleDetails = exception.Data["RuleDetails"].ShouldBeOfType<List<Dictionary<string, object>>>(); |
||||
|
ruleDetails.Count.ShouldBe(1); |
||||
|
ruleDetails[0]["RuleName"].ShouldBe("TestSimple:Rule[3600s,3,Parameter]"); |
||||
|
ruleDetails[0]["MaxCount"].ShouldBe(3); |
||||
|
ruleDetails[0]["IsAllowed"].ShouldBe(false); |
||||
|
ruleDetails[0]["WindowDurationSeconds"].ShouldBe(3600); |
||||
|
((string)ruleDetails[0]["WindowDescription"]).ShouldNotBeNullOrEmpty(); |
||||
|
((int)ruleDetails[0]["RetryAfterSeconds"]).ShouldBeGreaterThan(0); |
||||
|
((string)ruleDetails[0]["RetryAfter"]).ShouldNotBeNullOrEmpty(); |
||||
|
|
||||
|
// ExtraProperties passed through
|
||||
|
exception.Data["Email"].ShouldBe("user@example.com"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task GetStatusAsync_Should_Provide_Countdown_Data_For_Frontend() |
||||
|
{ |
||||
|
var param = $"frontend-status-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Before any requests: frontend can show "3 remaining"
|
||||
|
var status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.IsAllowed.ShouldBeTrue(); |
||||
|
status.RemainingCount.ShouldBe(3); |
||||
|
status.MaxCount.ShouldBe(3); |
||||
|
status.CurrentCount.ShouldBe(0); |
||||
|
status.RetryAfter.ShouldBeNull(); |
||||
|
status.WindowDuration.ShouldBe(TimeSpan.FromHours(1)); |
||||
|
|
||||
|
// After 2 requests: frontend shows "1 remaining"
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.IsAllowed.ShouldBeTrue(); |
||||
|
status.RemainingCount.ShouldBe(1); |
||||
|
status.MaxCount.ShouldBe(3); |
||||
|
status.CurrentCount.ShouldBe(2); |
||||
|
|
||||
|
// After exhausting limit: frontend shows countdown
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.IsAllowed.ShouldBeFalse(); |
||||
|
status.RemainingCount.ShouldBe(0); |
||||
|
status.CurrentCount.ShouldBe(3); |
||||
|
status.RetryAfter.ShouldNotBeNull(); |
||||
|
status.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Custom_ErrorCode_Should_Appear_In_ErrorInfo() |
||||
|
{ |
||||
|
var param = $"frontend-custom-code-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
await _checker.CheckAsync("TestCustomErrorCode", context); |
||||
|
await _checker.CheckAsync("TestCustomErrorCode", context); |
||||
|
|
||||
|
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestCustomErrorCode", context); |
||||
|
}); |
||||
|
|
||||
|
var errorInfo = _errorInfoConverter.Convert(exception); |
||||
|
|
||||
|
// Frontend can use error.code to decide which UI to show
|
||||
|
errorInfo.Code.ShouldBe("Test:CustomError"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_Seconds() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromSeconds(30)).ShouldBe("30 second(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromSeconds(30)).ShouldBe("30 秒"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_Minutes() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromMinutes(15)).ShouldBe("15 minute(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromMinutes(15)).ShouldBe("15 分钟"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_MinutesAndSeconds() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
// 70 seconds = 1 minute and 10 seconds
|
||||
|
_formatter.Format(TimeSpan.FromSeconds(70)).ShouldBe("1 minute(s) and 10 second(s)"); |
||||
|
_formatter.Format(TimeSpan.FromSeconds(90)).ShouldBe("1 minute(s) and 30 second(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromSeconds(70)).ShouldBe("1 分钟 10 秒"); |
||||
|
_formatter.Format(TimeSpan.FromSeconds(90)).ShouldBe("1 分钟 30 秒"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_Hours() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromHours(2)).ShouldBe("2 hour(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromHours(2)).ShouldBe("2 小时"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_HoursAndMinutes() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromMinutes(90)).ShouldBe("1 hour(s) and 30 minute(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromMinutes(90)).ShouldBe("1 小时 30 分钟"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_Days() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(1)).ShouldBe("1 day(s)"); |
||||
|
_formatter.Format(TimeSpan.FromDays(3)).ShouldBe("3 day(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(1)).ShouldBe("1 天"); |
||||
|
_formatter.Format(TimeSpan.FromDays(3)).ShouldBe("3 天"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_DaysAndHours() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromHours(30)).ShouldBe("1 day(s) and 6 hour(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromHours(30)).ShouldBe("1 天 6 小时"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_Months() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(30)).ShouldBe("1 month(s)"); |
||||
|
_formatter.Format(TimeSpan.FromDays(90)).ShouldBe("3 month(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(30)).ShouldBe("1 个月"); |
||||
|
_formatter.Format(TimeSpan.FromDays(90)).ShouldBe("3 个月"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_MonthsAndDays() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(45)).ShouldBe("1 month(s) and 15 day(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(45)).ShouldBe("1 个月 15 天"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_Years() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(365)).ShouldBe("1 year(s)"); |
||||
|
_formatter.Format(TimeSpan.FromDays(730)).ShouldBe("2 year(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(365)).ShouldBe("1 年"); |
||||
|
_formatter.Format(TimeSpan.FromDays(730)).ShouldBe("2 年"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void RetryAfterFormatter_Should_Format_YearsAndMonths() |
||||
|
{ |
||||
|
using (CultureHelper.Use("en")) |
||||
|
{ |
||||
|
// 1 year + 60 days = 1 year and 2 months
|
||||
|
_formatter.Format(TimeSpan.FromDays(425)).ShouldBe("1 year(s) and 2 month(s)"); |
||||
|
} |
||||
|
|
||||
|
using (CultureHelper.Use("zh-Hans")) |
||||
|
{ |
||||
|
_formatter.Format(TimeSpan.FromDays(425)).ShouldBe("1 年 2 个月"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Reset_Should_Allow_Frontend_To_Resume() |
||||
|
{ |
||||
|
var param = $"frontend-reset-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Exhaust limit
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
// Frontend shows "limit reached"
|
||||
|
var status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.IsAllowed.ShouldBeFalse(); |
||||
|
|
||||
|
// After reset (e.g. CAPTCHA verified), frontend can resume
|
||||
|
await _checker.ResetAsync("TestSimple", context); |
||||
|
|
||||
|
status = await _checker.GetStatusAsync("TestSimple", context); |
||||
|
status.IsAllowed.ShouldBeTrue(); |
||||
|
status.RemainingCount.ShouldBe(3); |
||||
|
status.CurrentCount.ShouldBe(0); |
||||
|
status.RetryAfter.ShouldBeNull(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task IsAllowedAsync_Can_Be_Used_For_Frontend_PreCheck() |
||||
|
{ |
||||
|
var param = $"frontend-precheck-{Guid.NewGuid()}"; |
||||
|
var context = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Frontend precheck: button should be enabled
|
||||
|
(await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); |
||||
|
|
||||
|
// Consume all
|
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
await _checker.CheckAsync("TestSimple", context); |
||||
|
|
||||
|
// Frontend precheck: button should be disabled
|
||||
|
(await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); |
||||
|
|
||||
|
// IsAllowedAsync does NOT consume — calling again still returns false, not error
|
||||
|
(await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeFalse(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Shouldly; |
||||
|
using Volo.Abp.MultiTenancy; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Verifies per-tenant isolation for tenant-scoped partition types and
|
||||
|
/// global (cross-tenant) sharing for ClientIp partition type.
|
||||
|
/// </summary>
|
||||
|
public class OperationRateLimitingMultiTenant_Tests : OperationRateLimitingTestBase |
||||
|
{ |
||||
|
private readonly ICurrentTenant _currentTenant; |
||||
|
private readonly IOperationRateLimitingChecker _checker; |
||||
|
|
||||
|
private static readonly Guid TenantA = Guid.NewGuid(); |
||||
|
private static readonly Guid TenantB = Guid.NewGuid(); |
||||
|
|
||||
|
public OperationRateLimitingMultiTenant_Tests() |
||||
|
{ |
||||
|
_currentTenant = GetRequiredService<ICurrentTenant>(); |
||||
|
_checker = GetRequiredService<IOperationRateLimitingChecker>(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Isolate_ByParameter_Between_Tenants() |
||||
|
{ |
||||
|
// Same parameter value in different tenants should have independent counters.
|
||||
|
var param = $"shared-param-{Guid.NewGuid()}"; |
||||
|
|
||||
|
using (_currentTenant.Change(TenantA)) |
||||
|
{ |
||||
|
var ctx = new OperationRateLimitingContext { Parameter = param }; |
||||
|
await _checker.CheckAsync("TestMultiTenantByParameter", ctx); |
||||
|
await _checker.CheckAsync("TestMultiTenantByParameter", ctx); |
||||
|
|
||||
|
// Tenant A exhausted (max=2)
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestMultiTenantByParameter", ctx); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
using (_currentTenant.Change(TenantB)) |
||||
|
{ |
||||
|
var ctx = new OperationRateLimitingContext { Parameter = param }; |
||||
|
|
||||
|
// Tenant B has its own counter and should still be allowed
|
||||
|
await _checker.CheckAsync("TestMultiTenantByParameter", ctx); |
||||
|
(await _checker.IsAllowedAsync("TestMultiTenantByParameter", ctx)).ShouldBeTrue(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Share_ByClientIp_Across_Tenants() |
||||
|
{ |
||||
|
// ClientIp counters are global: requests from the same IP are counted together
|
||||
|
// regardless of which tenant context is active.
|
||||
|
// The mock IWebClientInfoProvider returns "127.0.0.1" for all requests.
|
||||
|
|
||||
|
using (_currentTenant.Change(TenantA)) |
||||
|
{ |
||||
|
var ctx = new OperationRateLimitingContext(); |
||||
|
await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); |
||||
|
await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); |
||||
|
} |
||||
|
|
||||
|
using (_currentTenant.Change(TenantB)) |
||||
|
{ |
||||
|
var ctx = new OperationRateLimitingContext(); |
||||
|
|
||||
|
// Tenant B shares the same IP counter; should be at limit now
|
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Isolate_ByParameter_Host_Tenant_From_Named_Tenant() |
||||
|
{ |
||||
|
// Host context (no tenant) and a specific tenant should have separate counters.
|
||||
|
var param = $"host-vs-tenant-{Guid.NewGuid()}"; |
||||
|
|
||||
|
// Host context: exhaust quota
|
||||
|
var hostCtx = new OperationRateLimitingContext { Parameter = param }; |
||||
|
await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); |
||||
|
await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); |
||||
|
await Assert.ThrowsAsync<AbpOperationRateLimitingException>(async () => |
||||
|
{ |
||||
|
await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); |
||||
|
}); |
||||
|
|
||||
|
// Tenant A should have its own counter, unaffected by host
|
||||
|
using (_currentTenant.Change(TenantA)) |
||||
|
{ |
||||
|
var tenantCtx = new OperationRateLimitingContext { Parameter = param }; |
||||
|
await _checker.CheckAsync("TestMultiTenantByParameter", tenantCtx); |
||||
|
(await _checker.IsAllowedAsync("TestMultiTenantByParameter", tenantCtx)).ShouldBeTrue(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,258 @@ |
|||||
|
using System; |
||||
|
using System.Threading.Tasks; |
||||
|
using Shouldly; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingPolicyBuilder_Tests |
||||
|
{ |
||||
|
[Fact] |
||||
|
public void Should_Build_Simple_Policy() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
options.AddPolicy("TestPolicy", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
|
||||
|
var policy = options.Policies["TestPolicy"]; |
||||
|
|
||||
|
policy.Name.ShouldBe("TestPolicy"); |
||||
|
policy.Rules.Count.ShouldBe(1); |
||||
|
policy.Rules[0].Duration.ShouldBe(TimeSpan.FromHours(1)); |
||||
|
policy.Rules[0].MaxCount.ShouldBe(5); |
||||
|
policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); |
||||
|
policy.ErrorCode.ShouldBeNull(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Build_Composite_Policy() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
options.AddPolicy("CompositePolicy", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromDays(1), maxCount: 10) |
||||
|
.PartitionByCurrentUser()); |
||||
|
}); |
||||
|
|
||||
|
var policy = options.Policies["CompositePolicy"]; |
||||
|
|
||||
|
policy.Name.ShouldBe("CompositePolicy"); |
||||
|
policy.Rules.Count.ShouldBe(2); |
||||
|
policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); |
||||
|
policy.Rules[0].MaxCount.ShouldBe(3); |
||||
|
policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); |
||||
|
policy.Rules[1].MaxCount.ShouldBe(10); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Set_ErrorCode() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
options.AddPolicy("ErrorPolicy", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) |
||||
|
.PartitionByParameter() |
||||
|
.WithErrorCode("App:Custom:Error"); |
||||
|
}); |
||||
|
|
||||
|
var policy = options.Policies["ErrorPolicy"]; |
||||
|
policy.ErrorCode.ShouldBe("App:Custom:Error"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Build_Custom_Partition() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
options.AddPolicy("CustomPolicy", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromMinutes(30), maxCount: 5) |
||||
|
.PartitionBy(ctx => Task.FromResult($"custom:{ctx.Parameter}"))); |
||||
|
}); |
||||
|
|
||||
|
var policy = options.Policies["CustomPolicy"]; |
||||
|
|
||||
|
policy.Rules.Count.ShouldBe(1); |
||||
|
policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); |
||||
|
policy.Rules[0].CustomPartitionKeyResolver.ShouldNotBeNull(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Support_All_Partition_Types() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
options.AddPolicy("P1", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByParameter()); |
||||
|
options.AddPolicy("P2", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByCurrentUser()); |
||||
|
options.AddPolicy("P3", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByCurrentTenant()); |
||||
|
options.AddPolicy("P4", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByClientIp()); |
||||
|
options.AddPolicy("P5", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByEmail()); |
||||
|
options.AddPolicy("P6", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByPhoneNumber()); |
||||
|
|
||||
|
options.Policies["P1"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); |
||||
|
options.Policies["P2"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); |
||||
|
options.Policies["P3"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentTenant); |
||||
|
options.Policies["P4"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.ClientIp); |
||||
|
options.Policies["P5"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Email); |
||||
|
options.Policies["P6"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.PhoneNumber); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Throw_When_Policy_Has_No_Rules() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
var exception = Assert.Throws<AbpException>(() => |
||||
|
{ |
||||
|
options.AddPolicy("EmptyPolicy", policy => |
||||
|
{ |
||||
|
// Intentionally not adding any rules
|
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
exception.Message.ShouldContain("no rules"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Throw_When_WithFixedWindow_Without_PartitionBy() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
var exception = Assert.Throws<AbpException>(() => |
||||
|
{ |
||||
|
options.AddPolicy("IncompletePolicy", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5); |
||||
|
// Missing PartitionBy*() call - rule never committed
|
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
exception.Message.ShouldContain("no rules"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Throw_When_AddRule_Without_WithFixedWindow() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
var exception = Assert.Throws<AbpException>(() => |
||||
|
{ |
||||
|
options.AddPolicy("NoWindowPolicy", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => |
||||
|
{ |
||||
|
// Missing WithFixedWindow call - duration is zero
|
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
exception.Message.ShouldContain("positive duration"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Allow_MaxCount_Zero_For_Ban_Policy() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
// maxCount=0 is a valid "ban" policy - always deny
|
||||
|
options.AddPolicy("BanPolicy", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 0) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
|
||||
|
var policy = options.Policies["BanPolicy"]; |
||||
|
policy.Rules[0].MaxCount.ShouldBe(0); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Throw_When_AddRule_Without_PartitionBy() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
var exception = Assert.Throws<AbpException>(() => |
||||
|
{ |
||||
|
options.AddPolicy("NoPartitionPolicy", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5)); |
||||
|
// Missing PartitionBy*() call
|
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
exception.Message.ShouldContain("partition type"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Throw_When_MaxCount_Is_Negative() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
var exception = Assert.Throws<AbpException>(() => |
||||
|
{ |
||||
|
options.AddPolicy("NegativePolicy", policy => |
||||
|
{ |
||||
|
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: -1) |
||||
|
.PartitionByParameter(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
exception.Message.ShouldContain("maxCount >= 0"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Allow_Same_Rule_With_Different_MultiTenancy() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
// Same Duration/MaxCount/PartitionType but different IsMultiTenant should be allowed
|
||||
|
options.AddPolicy("MultiTenancyPolicy", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.PartitionByParameter()); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.WithMultiTenancy() |
||||
|
.PartitionByParameter()); |
||||
|
}); |
||||
|
|
||||
|
var policy = options.Policies["MultiTenancyPolicy"]; |
||||
|
policy.Rules.Count.ShouldBe(2); |
||||
|
policy.Rules[0].IsMultiTenant.ShouldBeFalse(); |
||||
|
policy.Rules[1].IsMultiTenant.ShouldBeTrue(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public void Should_Allow_Multiple_Custom_Partition_Rules() |
||||
|
{ |
||||
|
var options = new AbpOperationRateLimitingOptions(); |
||||
|
|
||||
|
// Multiple custom partition rules with same Duration/MaxCount should be allowed
|
||||
|
// because they may use different key resolvers
|
||||
|
options.AddPolicy("MultiCustomPolicy", policy => |
||||
|
{ |
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.PartitionBy(ctx => Task.FromResult($"by-ip:{ctx.Parameter}"))); |
||||
|
|
||||
|
policy.AddRule(rule => rule |
||||
|
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) |
||||
|
.PartitionBy(ctx => Task.FromResult($"by-device:{ctx.Parameter}"))); |
||||
|
}); |
||||
|
|
||||
|
var policy = options.Policies["MultiCustomPolicy"]; |
||||
|
policy.Rules.Count.ShouldBe(2); |
||||
|
policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); |
||||
|
policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
using Volo.Abp.Testing; |
||||
|
|
||||
|
namespace Volo.Abp.OperationRateLimiting; |
||||
|
|
||||
|
public class OperationRateLimitingTestBase : AbpIntegratedTest<AbpOperationRateLimitingTestModule> |
||||
|
{ |
||||
|
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) |
||||
|
{ |
||||
|
options.UseAutofac(); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue