Browse Source

Add ConfigurePolicy and ClearRules to OperationRateLimiting

- Add ConfigurePolicy method to AbpOperationRateLimitingOptions for
  partial modification of existing policies without full replacement
- Add ClearRules method to OperationRateLimitingPolicyBuilder for
  clearing inherited rules before defining new ones
- Add FromPolicy factory method to reconstruct a builder from an
  existing policy
- Change AddPolicy return type to AbpOperationRateLimitingOptions
  for chaining consistency with ConfigurePolicy
- Add parameter validation to AddPolicy and FromPolicy
- Add documentation for overriding existing policies
- Add tests for ConfigurePolicy, ClearRules, chaining, and error cases
pull/25082/head
maliming 3 weeks ago
parent
commit
9664b686be
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 64
      docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md
  2. 72
      docs/en/framework/infrastructure/operation-rate-limiting.md
  3. 30
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs
  4. 23
      framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs
  5. 178
      framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs

64
docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md

@ -114,6 +114,70 @@ The two counters are completely independent. If `alice` fails 5 times, her accou
When multiple rules are present, the module uses a two-phase approach: it checks all rules first, and only increments counters if every rule passes. This prevents a rule from consuming quota on a request that would have been rejected by another rule anyway.
## Customizing Policies from Reusable Modules
ABP modules (including your own) can ship with built-in rate limiting policies. For example, an Account module might define a `"Account.SendPasswordResetCode"` policy with conservative defaults that make sense for most applications. When you need different rules in your specific application, you have two options.
**Complete replacement with `AddPolicy`:** call `AddPolicy` with the same name and the second registration wins, replacing all rules from the module:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("Account.SendPasswordResetCode", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail());
});
});
```
**Partial modification with `ConfigurePolicy`:** when you only want to tweak part of a policy — change the error code, add a secondary rule, or tighten the window — use `ConfigurePolicy`. The builder starts pre-populated with the module's existing rules, so you only express what changes.
For example, keep the module's default rules but assign your own localized error code:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.WithErrorCode("MyApp:PasswordResetLimit");
});
});
```
Or add a secondary IP-based rule on top of what the module already defined, without touching it:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
});
```
If you want a clean slate, call `ClearRules()` first and then define entirely new rules — this gives you the same result as `AddPolicy` but makes the intent explicit:
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.ClearRules()
.WithFixedWindow(TimeSpan.FromMinutes(10), maxCount: 5)
.PartitionByEmail();
});
});
```
`ConfigurePolicy` throws if the policy name doesn't exist — which catches typos at startup rather than silently doing nothing.
The general rule: use `AddPolicy` for full replacements, `ConfigurePolicy` for surgical modifications.
## Beyond Just Checking
Not every scenario calls for throwing an exception. `IOperationRateLimitingChecker` provides three additional methods for more nuanced control.

72
docs/en/framework/infrastructure/operation-rate-limiting.md

@ -115,6 +115,78 @@ options.AddPolicy("Login", policy =>
> 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.
### Overriding an Existing Policy
If a reusable module (e.g., ABP's Account module) defines a policy with default rules, you have two ways to customize it in your own module's `ConfigureServices`.
**Option 1 — Full replacement with `AddPolicy`:**
Call `AddPolicy` with the same name. The last registration wins and completely replaces all rules:
````csharp
// In your application module — runs after the Account module
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.AddPolicy("Account.SendPasswordResetCode", policy =>
{
// Replaces all rules defined by the Account module for this policy
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail());
});
});
````
> `AddPolicy` stores policies in a dictionary keyed by name, so calling it again with the same name fully replaces the previous policy and all its rules.
**Option 2 — Partial modification with `ConfigurePolicy`:**
Use `ConfigurePolicy` to modify an existing policy without replacing it entirely. The builder is pre-populated with the existing rules, so you only need to express what changes:
````csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
// Only override the error code, keeping the module's original rules
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.WithErrorCode("MyApp:SmsCodeLimit");
});
});
````
You can also add a rule on top of the existing ones:
````csharp
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
// Keep the module's per-email rule and add a per-IP rule on top
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
````
Or clear all inherited rules first and define entirely new ones using `ClearRules()`:
````csharp
options.ConfigurePolicy("Account.SendPasswordResetCode", policy =>
{
policy.ClearRules()
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail();
});
````
`ConfigurePolicy` returns `AbpOperationRateLimitingOptions`, so you can chain multiple calls:
````csharp
options
.ConfigurePolicy("Account.SendPasswordResetCode", p => p.WithErrorCode("MyApp:SmsLimit"))
.ConfigurePolicy("Account.Login", p => p.WithErrorCode("MyApp:LoginLimit"));
````
> `ConfigurePolicy` throws `AbpException` if the policy name is not found. Use `AddPolicy` first (in the module that owns the policy), then `ConfigurePolicy` in downstream modules to customize it.
### Custom Error Code
By default, the exception uses the error code `Volo.Abp.OperationRateLimiting:010001`. You can override it per policy:

30
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs

@ -11,10 +11,38 @@ public class AbpOperationRateLimitingOptions
public Dictionary<string, OperationRateLimitingPolicy> Policies { get; } = new();
public void AddPolicy(string name, Action<OperationRateLimitingPolicyBuilder> configure)
public AbpOperationRateLimitingOptions AddPolicy(string name, Action<OperationRateLimitingPolicyBuilder> configure)
{
Check.NotNullOrWhiteSpace(name, nameof(name));
Check.NotNull(configure, nameof(configure));
var builder = new OperationRateLimitingPolicyBuilder(name);
configure(builder);
Policies[name] = builder.Build();
return this;
}
/// <summary>
/// Configures an existing rate limiting policy by name.
/// The builder is pre-populated with the existing policy's rules and error code,
/// so you can add, clear, or replace rules while keeping what you don't change.
/// Throws <see cref="AbpException"/> if the policy is not found.
/// </summary>
public AbpOperationRateLimitingOptions ConfigurePolicy(string name, Action<OperationRateLimitingPolicyBuilder> configure)
{
Check.NotNullOrWhiteSpace(name, nameof(name));
Check.NotNull(configure, nameof(configure));
if (!Policies.TryGetValue(name, out var existingPolicy))
{
throw new AbpException(
$"Could not find operation rate limiting policy: '{name}'. " +
"Make sure the policy is defined with AddPolicy() before calling ConfigurePolicy().");
}
var builder = OperationRateLimitingPolicyBuilder.FromPolicy(existingPolicy);
configure(builder);
Policies[name] = builder.Build();
return this;
}
}

23
framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs

@ -62,6 +62,29 @@ public class OperationRateLimitingPolicyBuilder
return this;
}
/// <summary>
/// Clears all rules and custom rule types from this policy builder,
/// allowing a full replacement of the inherited rules.
/// </summary>
/// <returns>The current builder instance for method chaining.</returns>
public OperationRateLimitingPolicyBuilder ClearRules()
{
_rules.Clear();
_customRuleTypes.Clear();
return this;
}
internal static OperationRateLimitingPolicyBuilder FromPolicy(OperationRateLimitingPolicy policy)
{
Check.NotNull(policy, nameof(policy));
var builder = new OperationRateLimitingPolicyBuilder(policy.Name);
builder._errorCode = policy.ErrorCode;
builder._rules.AddRange(policy.Rules);
builder._customRuleTypes.AddRange(policy.CustomRuleTypes);
return builder;
}
internal void AddRuleDefinition(OperationRateLimitingRuleDefinition definition)
{
_rules.Add(definition);

178
framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs

@ -255,4 +255,182 @@ public class OperationRateLimitingPolicyBuilder_Tests
policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom);
policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom);
}
[Fact]
public void AddPolicy_With_Same_Name_Should_Replace_Existing_Policy()
{
var options = new AbpOperationRateLimitingOptions();
options.AddPolicy("MyPolicy", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5)
.PartitionByParameter();
});
// Second AddPolicy with the same name replaces the first one entirely
options.AddPolicy("MyPolicy", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(10), maxCount: 2)
.PartitionByCurrentUser();
});
options.Policies.Count.ShouldBe(1);
var policy = options.Policies["MyPolicy"];
policy.Rules.Count.ShouldBe(1);
policy.Rules[0].Duration.ShouldBe(TimeSpan.FromMinutes(10));
policy.Rules[0].MaxCount.ShouldBe(2);
policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser);
}
[Fact]
public void ConfigurePolicy_Should_Override_ErrorCode_While_Keeping_Rules()
{
var options = new AbpOperationRateLimitingOptions();
options.AddPolicy("BasePolicy", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5)
.PartitionByParameter();
});
options.ConfigurePolicy("BasePolicy", policy =>
{
policy.WithErrorCode("App:Custom:Override");
});
var result = options.Policies["BasePolicy"];
result.ErrorCode.ShouldBe("App:Custom:Override");
result.Rules.Count.ShouldBe(1);
result.Rules[0].MaxCount.ShouldBe(5);
result.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter);
}
[Fact]
public void ConfigurePolicy_Should_Add_Additional_Rule_To_Existing_Policy()
{
var options = new AbpOperationRateLimitingOptions();
options.AddPolicy("BasePolicy", policy =>
{
policy.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByParameter();
});
options.ConfigurePolicy("BasePolicy", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20)
.PartitionByClientIp());
});
var result = options.Policies["BasePolicy"];
result.Rules.Count.ShouldBe(2);
result.Rules[0].Duration.ShouldBe(TimeSpan.FromMinutes(5));
result.Rules[0].MaxCount.ShouldBe(3);
result.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter);
result.Rules[1].Duration.ShouldBe(TimeSpan.FromHours(1));
result.Rules[1].MaxCount.ShouldBe(20);
result.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.ClientIp);
}
[Fact]
public void ConfigurePolicy_ClearRules_Should_Replace_All_Rules()
{
var options = new AbpOperationRateLimitingOptions();
options.AddPolicy("BasePolicy", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10)
.PartitionByParameter());
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromDays(1), maxCount: 50)
.PartitionByCurrentUser());
});
options.ConfigurePolicy("BasePolicy", policy =>
{
policy.ClearRules()
.WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 3)
.PartitionByEmail();
});
var result = options.Policies["BasePolicy"];
result.Rules.Count.ShouldBe(1);
result.Rules[0].Duration.ShouldBe(TimeSpan.FromMinutes(5));
result.Rules[0].MaxCount.ShouldBe(3);
result.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Email);
}
[Fact]
public void ConfigurePolicy_Should_Support_Chaining()
{
var options = new AbpOperationRateLimitingOptions();
options.AddPolicy("PolicyA", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5)
.PartitionByParameter();
});
options.AddPolicy("PolicyB", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10)
.PartitionByCurrentUser();
});
// ConfigurePolicy returns AbpOperationRateLimitingOptions for chaining
options
.ConfigurePolicy("PolicyA", policy => policy.WithErrorCode("App:LimitA"))
.ConfigurePolicy("PolicyB", policy => policy.WithErrorCode("App:LimitB"));
options.Policies["PolicyA"].ErrorCode.ShouldBe("App:LimitA");
options.Policies["PolicyB"].ErrorCode.ShouldBe("App:LimitB");
}
[Fact]
public void ConfigurePolicy_Should_Throw_When_Policy_Not_Found()
{
var options = new AbpOperationRateLimitingOptions();
var exception = Assert.Throws<AbpException>(() =>
{
options.ConfigurePolicy("NonExistentPolicy", policy =>
{
policy.WithErrorCode("App:SomeCode");
});
});
exception.Message.ShouldContain("NonExistentPolicy");
}
[Fact]
public void ConfigurePolicy_Should_Preserve_Existing_ErrorCode_When_Not_Overridden()
{
var options = new AbpOperationRateLimitingOptions();
options.AddPolicy("BasePolicy", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5)
.PartitionByParameter()
.WithErrorCode("Original:ErrorCode");
});
options.ConfigurePolicy("BasePolicy", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromMinutes(10), maxCount: 3)
.PartitionByClientIp());
});
var result = options.Policies["BasePolicy"];
result.ErrorCode.ShouldBe("Original:ErrorCode");
result.Rules.Count.ShouldBe(2);
result.Rules[0].Duration.ShouldBe(TimeSpan.FromHours(1));
result.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter);
result.Rules[1].Duration.ShouldBe(TimeSpan.FromMinutes(10));
result.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.ClientIp);
}
}

Loading…
Cancel
Save