From 9664b686be98da9d3df02084e5d976d4882b2e7b Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 13 Mar 2026 13:38:52 +0800 Subject: [PATCH] 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 --- .../POST.md | 64 +++++++ .../infrastructure/operation-rate-limiting.md | 72 +++++++ .../AbpOperationRateLimitingOptions.cs | 30 ++- .../OperationRateLimitingPolicyBuilder.cs | 23 +++ ...perationRateLimitingPolicyBuilder_Tests.cs | 178 ++++++++++++++++++ 5 files changed, 366 insertions(+), 1 deletion(-) diff --git a/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md b/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md index 91a9e26aff..e53f633ae9 100644 --- a/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md +++ b/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(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(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(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(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. diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md index 5208c2c959..cd693828e1 100644 --- a/docs/en/framework/infrastructure/operation-rate-limiting.md +++ b/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(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(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: diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs index 711f2b17d0..9de7a97412 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs @@ -11,10 +11,38 @@ public class AbpOperationRateLimitingOptions public Dictionary Policies { get; } = new(); - public void AddPolicy(string name, Action configure) + public AbpOperationRateLimitingOptions AddPolicy(string name, Action configure) { + Check.NotNullOrWhiteSpace(name, nameof(name)); + Check.NotNull(configure, nameof(configure)); + var builder = new OperationRateLimitingPolicyBuilder(name); configure(builder); Policies[name] = builder.Build(); + return this; + } + + /// + /// 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 if the policy is not found. + /// + public AbpOperationRateLimitingOptions ConfigurePolicy(string name, Action 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; } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs index 72cb247614..3a3db933b5 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs @@ -62,6 +62,29 @@ public class OperationRateLimitingPolicyBuilder return this; } + /// + /// Clears all rules and custom rule types from this policy builder, + /// allowing a full replacement of the inherited rules. + /// + /// The current builder instance for method chaining. + 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); diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs index 6a503a6191..ffba340e29 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs +++ b/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(() => + { + 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); + } }