diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx
index 1302600c09..b5d1f87166 100644
--- a/framework/Volo.Abp.slnx
+++ b/framework/Volo.Abp.slnx
@@ -169,6 +169,7 @@
+
@@ -256,5 +257,6 @@
+
diff --git a/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs b/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs
index 6a15c5550f..2c27ea864a 100644
--- a/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
+using Volo.Abp.AspNetCore.ClientIpAddress;
using Volo.Abp.AspNetCore.VirtualFileSystem;
using Volo.Abp.AspNetCore.WebClientInfo;
using Volo.Abp.Modularity;
@@ -10,6 +11,7 @@ public class AbpAspNetCoreAbstractionsModule : AbpModule
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddSingleton();
- context.Services.AddSingleton();;
+ context.Services.AddSingleton();
+ context.Services.AddTransient();
}
}
diff --git a/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/IClientIpAddressProvider.cs b/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/IClientIpAddressProvider.cs
new file mode 100644
index 0000000000..6318ec0989
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/IClientIpAddressProvider.cs
@@ -0,0 +1,6 @@
+namespace Volo.Abp.AspNetCore.ClientIpAddress;
+
+public interface IClientIpAddressProvider
+{
+ string? ClientIpAddress { get; }
+}
diff --git a/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/NullClientIpAddressProvider.cs b/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/NullClientIpAddressProvider.cs
new file mode 100644
index 0000000000..f1dbcc903e
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/NullClientIpAddressProvider.cs
@@ -0,0 +1,6 @@
+namespace Volo.Abp.AspNetCore.ClientIpAddress;
+
+public class NullClientIpAddressProvider : IClientIpAddressProvider
+{
+ public string? ClientIpAddress => null;
+}
diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/ClientIpAddress/HttpContextClientIpAddressProvider.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/ClientIpAddress/HttpContextClientIpAddressProvider.cs
new file mode 100644
index 0000000000..fa0a252e3c
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/ClientIpAddress/HttpContextClientIpAddressProvider.cs
@@ -0,0 +1,36 @@
+using System;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.AspNetCore.ClientIpAddress;
+
+[Dependency(ReplaceServices = true)]
+public class HttpContextClientIpAddressProvider : IClientIpAddressProvider, ITransientDependency
+{
+ protected ILogger Logger { get; }
+ protected IHttpContextAccessor HttpContextAccessor { get; }
+
+ public HttpContextClientIpAddressProvider(
+ ILogger logger,
+ IHttpContextAccessor httpContextAccessor)
+ {
+ Logger = logger;
+ HttpContextAccessor = httpContextAccessor;
+ }
+
+ public string? ClientIpAddress => GetClientIpAddress();
+
+ protected virtual string? GetClientIpAddress()
+ {
+ try
+ {
+ return HttpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString();
+ }
+ catch (Exception ex)
+ {
+ Logger.LogException(ex, LogLevel.Warning);
+ return null;
+ }
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/FodyWeavers.xml b/framework/src/Volo.Abp.OperationRateLimit/FodyWeavers.xml
new file mode 100644
index 0000000000..7e9f94ead6
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/FodyWeavers.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj b/framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj
new file mode 100644
index 0000000000..f550b7c7cf
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0
+ enable
+ Nullable
+ Volo.Abp.OperationRateLimit
+ Volo.Abp.OperationRateLimit
+ $(AssetTargetFallback);portable-net45+win8+wp8+wpa81;
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs
new file mode 100644
index 0000000000..783b52e3f2
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs
@@ -0,0 +1,9 @@
+namespace Volo.Abp.OperationRateLimit;
+
+public static class AbpOperationRateLimitErrorCodes
+{
+ ///
+ /// Default error code for rate limit exceeded.
+ ///
+ public const string ExceedLimit = "Volo.Abp.OperationRateLimit:010001";
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs
new file mode 100644
index 0000000000..852b506e46
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs
@@ -0,0 +1,41 @@
+using System;
+using Volo.Abp.ExceptionHandling;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class AbpOperationRateLimitException : BusinessException, IHasHttpStatusCode
+{
+ public string PolicyName { get; }
+
+ public OperationRateLimitResult Result { get; }
+
+ public int HttpStatusCode => 429;
+
+ public AbpOperationRateLimitException(
+ string policyName,
+ OperationRateLimitResult result,
+ string? errorCode = null)
+ : base(code: errorCode ?? AbpOperationRateLimitErrorCodes.ExceedLimit)
+ {
+ 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);
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs
new file mode 100644
index 0000000000..ac74e4c80c
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs
@@ -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.OperationRateLimit;
+
+[DependsOn(
+ typeof(AbpCachingModule),
+ typeof(AbpLocalizationModule),
+ typeof(AbpSecurityModule),
+ typeof(AbpAspNetCoreAbstractionsModule),
+ typeof(AbpDistributedLockingAbstractionsModule)
+)]
+public class AbpOperationRateLimitModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ Configure(options =>
+ {
+ options.FileSets.AddEmbedded();
+ });
+
+ Configure(options =>
+ {
+ options.Resources
+ .Add("en")
+ .AddVirtualJson("/Volo/Abp/OperationRateLimit/Localization");
+ });
+
+ Configure(options =>
+ {
+ options.MapCodeNamespace(
+ "Volo.Abp.OperationRateLimit",
+ typeof(AbpOperationRateLimitResource));
+ });
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs
new file mode 100644
index 0000000000..5ed35d4de7
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs
@@ -0,0 +1,20 @@
+using System;
+using System.Collections.Generic;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class AbpOperationRateLimitOptions
+{
+ public bool IsEnabled { get; set; } = true;
+
+ public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5);
+
+ public Dictionary Policies { get; } = new();
+
+ public void AddPolicy(string name, Action configure)
+ {
+ var builder = new OperationRateLimitPolicyBuilder(name);
+ configure(builder);
+ Policies[name] = builder.Build();
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs
new file mode 100644
index 0000000000..d180b89838
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs
@@ -0,0 +1,8 @@
+using Volo.Abp.Localization;
+
+namespace Volo.Abp.OperationRateLimit;
+
+[LocalizationResourceName("AbpOperationRateLimit")]
+public class AbpOperationRateLimitResource
+{
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs
new file mode 100644
index 0000000000..7a506e5d5e
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs
@@ -0,0 +1,68 @@
+using System;
+using Microsoft.Extensions.Localization;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class DefaultOperationRateLimitFormatter
+ : IOperationRateLimitFormatter, ITransientDependency
+{
+ protected IStringLocalizer Localizer { get; }
+
+ public DefaultOperationRateLimitFormatter(
+ IStringLocalizer 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];
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs
new file mode 100644
index 0000000000..86cec343eb
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs
@@ -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.OperationRateLimit;
+
+public class DefaultOperationRateLimitPolicyProvider : IOperationRateLimitPolicyProvider, ITransientDependency
+{
+ protected AbpOperationRateLimitOptions Options { get; }
+
+ public DefaultOperationRateLimitPolicyProvider(IOptions options)
+ {
+ Options = options.Value;
+ }
+
+ public virtual Task 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 AbpOperationRateLimitOptions.AddPolicy().");
+ }
+
+ return Task.FromResult(policy);
+ }
+
+ public virtual Task> GetListAsync()
+ {
+ return Task.FromResult(Options.Policies.Values.ToList());
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs
new file mode 100644
index 0000000000..0e86fc31a1
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs
@@ -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.OperationRateLimit;
+
+public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, ITransientDependency
+{
+ protected IDistributedCache Cache { get; }
+ protected IClock Clock { get; }
+ protected IAbpDistributedLock DistributedLock { get; }
+ protected AbpOperationRateLimitOptions Options { get; }
+
+ public DistributedCacheOperationRateLimitStore(
+ IDistributedCache cache,
+ IClock clock,
+ IAbpDistributedLock distributedLock,
+ IOptions options)
+ {
+ Cache = cache;
+ Clock = clock;
+ DistributedLock = distributedLock;
+ Options = options.Value;
+ }
+
+ public virtual async Task IncrementAsync(
+ string key, TimeSpan duration, int maxCount)
+ {
+ if (maxCount <= 0)
+ {
+ return new OperationRateLimitStoreResult
+ {
+ IsAllowed = false,
+ CurrentCount = 0,
+ MaxCount = maxCount,
+ RetryAfter = duration
+ };
+ }
+
+ await using (var handle = await DistributedLock.TryAcquireAsync(
+ $"OperationRateLimit:{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 OperationRateLimitCacheItem { Count = 1, WindowStart = now };
+ await Cache.SetAsync(key, cacheItem,
+ new DistributedCacheEntryOptions
+ {
+ AbsoluteExpirationRelativeToNow = duration
+ });
+
+ return new OperationRateLimitStoreResult
+ {
+ IsAllowed = true,
+ CurrentCount = 1,
+ MaxCount = maxCount
+ };
+ }
+
+ if (cacheItem.Count >= maxCount)
+ {
+ var retryAfter = cacheItem.WindowStart.Add(duration) - now;
+ return new OperationRateLimitStoreResult
+ {
+ 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 OperationRateLimitStoreResult
+ {
+ IsAllowed = true,
+ CurrentCount = cacheItem.Count,
+ MaxCount = maxCount
+ };
+ }
+ }
+
+ public virtual async Task GetAsync(
+ string key, TimeSpan duration, int maxCount)
+ {
+ if (maxCount <= 0)
+ {
+ return new OperationRateLimitStoreResult
+ {
+ IsAllowed = false,
+ CurrentCount = 0,
+ MaxCount = maxCount,
+ RetryAfter = duration
+ };
+ }
+
+ var cacheItem = await Cache.GetAsync(key);
+ var now = new DateTimeOffset(Clock.Now.ToUniversalTime());
+
+ if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration))
+ {
+ return new OperationRateLimitStoreResult
+ {
+ IsAllowed = true,
+ CurrentCount = 0,
+ MaxCount = maxCount
+ };
+ }
+
+ if (cacheItem.Count >= maxCount)
+ {
+ var retryAfter = cacheItem.WindowStart.Add(duration) - now;
+ return new OperationRateLimitStoreResult
+ {
+ IsAllowed = false,
+ CurrentCount = cacheItem.Count,
+ MaxCount = maxCount,
+ RetryAfter = retryAfter
+ };
+ }
+
+ return new OperationRateLimitStoreResult
+ {
+ IsAllowed = true,
+ CurrentCount = cacheItem.Count,
+ MaxCount = maxCount
+ };
+ }
+
+ public virtual async Task ResetAsync(string key)
+ {
+ await Cache.RemoveAsync(key);
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs
new file mode 100644
index 0000000000..737e957788
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs
@@ -0,0 +1,134 @@
+using System.Threading.Tasks;
+using Volo.Abp.AspNetCore.ClientIpAddress;
+using Volo.Abp.MultiTenancy;
+using Volo.Abp.Users;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule
+{
+ private const string HostTenantKey = "host";
+
+ protected string PolicyName { get; }
+ protected int RuleIndex { get; }
+ protected OperationRateLimitRuleDefinition Definition { get; }
+ protected IOperationRateLimitStore Store { get; }
+ protected ICurrentUser CurrentUser { get; }
+ protected ICurrentTenant CurrentTenant { get; }
+ protected IClientIpAddressProvider ClientIpAddressProvider { get; }
+
+ public FixedWindowOperationRateLimitRule(
+ string policyName,
+ int ruleIndex,
+ OperationRateLimitRuleDefinition definition,
+ IOperationRateLimitStore store,
+ ICurrentUser currentUser,
+ ICurrentTenant currentTenant,
+ IClientIpAddressProvider clientInfoProvider)
+ {
+ PolicyName = policyName;
+ RuleIndex = ruleIndex;
+ Definition = definition;
+ Store = store;
+ CurrentUser = currentUser;
+ CurrentTenant = currentTenant;
+ ClientIpAddressProvider = clientInfoProvider;
+ }
+
+ public virtual async Task AcquireAsync(
+ OperationRateLimitContext context)
+ {
+ var partitionKey = ResolvePartitionKey(context);
+ var storeKey = BuildStoreKey(partitionKey);
+ var storeResult = await Store.IncrementAsync(storeKey, Definition.Duration, Definition.MaxCount);
+
+ return ToRuleResult(storeResult);
+ }
+
+ public virtual async Task CheckAsync(
+ OperationRateLimitContext context)
+ {
+ var partitionKey = ResolvePartitionKey(context);
+ var storeKey = BuildStoreKey(partitionKey);
+ var storeResult = await Store.GetAsync(storeKey, Definition.Duration, Definition.MaxCount);
+
+ return ToRuleResult(storeResult);
+ }
+
+ public virtual async Task ResetAsync(OperationRateLimitContext context)
+ {
+ var partitionKey = ResolvePartitionKey(context);
+ var storeKey = BuildStoreKey(partitionKey);
+ await Store.ResetAsync(storeKey);
+ }
+
+ protected virtual string ResolvePartitionKey(OperationRateLimitContext context)
+ {
+ return Definition.PartitionType switch
+ {
+ OperationRateLimitPartitionType.Parameter =>
+ context.Parameter ?? throw new AbpException(
+ $"OperationRateLimitContext.Parameter is required for policy '{PolicyName}' (PartitionByParameter)."),
+
+ OperationRateLimitPartitionType.CurrentUser =>
+ CurrentUser.Id?.ToString() ?? throw new AbpException(
+ $"Current user is not authenticated. Policy '{PolicyName}' requires PartitionByCurrentUser."),
+
+ OperationRateLimitPartitionType.CurrentTenant =>
+ CurrentTenant.Id?.ToString() ?? HostTenantKey,
+
+ OperationRateLimitPartitionType.ClientIp =>
+ ClientIpAddressProvider.ClientIpAddress
+ ?? throw new AbpException(
+ $"Client IP address could not be determined. Policy '{PolicyName}' requires PartitionByClientIp. " +
+ "Ensure IClientIpAddressProvider is properly configured."),
+
+ OperationRateLimitPartitionType.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."),
+
+ OperationRateLimitPartitionType.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."),
+
+ OperationRateLimitPartitionType.Custom =>
+ Definition.CustomPartitionKeyResolver!(context),
+
+ _ => throw new AbpException($"Unknown partition type: {Definition.PartitionType}")
+ };
+ }
+
+ 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 OperationRateLimitRuleResult ToRuleResult(OperationRateLimitStoreResult storeResult)
+ {
+ return new OperationRateLimitRuleResult
+ {
+ RuleName = $"{PolicyName}:Rule[{(long)Definition.Duration.TotalSeconds}s,{Definition.MaxCount},{Definition.PartitionType}]",
+ IsAllowed = storeResult.IsAllowed,
+ RemainingCount = storeResult.MaxCount - storeResult.CurrentCount,
+ MaxCount = storeResult.MaxCount,
+ RetryAfter = storeResult.RetryAfter,
+ WindowDuration = Definition.Duration
+ };
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs
new file mode 100644
index 0000000000..8cccb0d51f
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs
@@ -0,0 +1,14 @@
+using System.Threading.Tasks;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public interface IOperationRateLimitChecker
+{
+ Task CheckAsync(string policyName, OperationRateLimitContext? context = null);
+
+ Task IsAllowedAsync(string policyName, OperationRateLimitContext? context = null);
+
+ Task GetStatusAsync(string policyName, OperationRateLimitContext? context = null);
+
+ Task ResetAsync(string policyName, OperationRateLimitContext? context = null);
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitFormatter.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitFormatter.cs
new file mode 100644
index 0000000000..8fd61d3925
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitFormatter.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public interface IOperationRateLimitFormatter
+{
+ string Format(TimeSpan duration);
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitPolicyProvider.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitPolicyProvider.cs
new file mode 100644
index 0000000000..504b8da745
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitPolicyProvider.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public interface IOperationRateLimitPolicyProvider
+{
+ Task GetAsync(string policyName);
+
+ Task> GetListAsync();
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitRule.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitRule.cs
new file mode 100644
index 0000000000..b7c83265f2
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitRule.cs
@@ -0,0 +1,12 @@
+using System.Threading.Tasks;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public interface IOperationRateLimitRule
+{
+ Task AcquireAsync(OperationRateLimitContext context);
+
+ Task CheckAsync(OperationRateLimitContext context);
+
+ Task ResetAsync(OperationRateLimitContext context);
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitStore.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitStore.cs
new file mode 100644
index 0000000000..c6c736b45c
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitStore.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public interface IOperationRateLimitStore
+{
+ Task IncrementAsync(string key, TimeSpan duration, int maxCount);
+
+ Task GetAsync(string key, TimeSpan duration, int maxCount);
+
+ Task ResetAsync(string key);
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ar.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ar.json
new file mode 100644
index 0000000000..8e2cf120cd
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ar.json
@@ -0,0 +1,17 @@
+{
+ "culture": "ar",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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} ثانية/ثوان"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/cs.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/cs.json
new file mode 100644
index 0000000000..d1db9eb671
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/cs.json
@@ -0,0 +1,17 @@
+{
+ "culture": "cs",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/de.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/de.json
new file mode 100644
index 0000000000..5fcca27604
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/de.json
@@ -0,0 +1,17 @@
+{
+ "culture": "de",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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)"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/el.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/el.json
new file mode 100644
index 0000000000..f5d5ba20b7
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/el.json
@@ -0,0 +1,17 @@
+{
+ "culture": "el",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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} δευτερόλεπτο/α"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en-GB.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en-GB.json
new file mode 100644
index 0000000000..4dad40dd1a
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en-GB.json
@@ -0,0 +1,17 @@
+{
+ "culture": "en-GB",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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)"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en.json
new file mode 100644
index 0000000000..a962e3d9c9
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en.json
@@ -0,0 +1,17 @@
+{
+ "culture": "en",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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)"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/es.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/es.json
new file mode 100644
index 0000000000..fa5ce16176
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/es.json
@@ -0,0 +1,17 @@
+{
+ "culture": "es",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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)"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fa.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fa.json
new file mode 100644
index 0000000000..9bd5fa51c5
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fa.json
@@ -0,0 +1,17 @@
+{
+ "culture": "fa",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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} ثانیه"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fi.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fi.json
new file mode 100644
index 0000000000..91d5a799e2
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fi.json
@@ -0,0 +1,17 @@
+{
+ "culture": "fi",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fr.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fr.json
new file mode 100644
index 0000000000..ce1b2a5da5
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fr.json
@@ -0,0 +1,17 @@
+{
+ "culture": "fr",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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)"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hi.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hi.json
new file mode 100644
index 0000000000..c23d01b4e1
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hi.json
@@ -0,0 +1,17 @@
+{
+ "culture": "hi",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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} सेकंड"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hr.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hr.json
new file mode 100644
index 0000000000..77a253b33e
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hr.json
@@ -0,0 +1,17 @@
+{
+ "culture": "hr",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hu.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hu.json
new file mode 100644
index 0000000000..30ca0a59a0
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hu.json
@@ -0,0 +1,17 @@
+{
+ "culture": "hu",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/is.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/is.json
new file mode 100644
index 0000000000..1331cc4bef
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/is.json
@@ -0,0 +1,17 @@
+{
+ "culture": "is",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/it.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/it.json
new file mode 100644
index 0000000000..fb550655f2
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/it.json
@@ -0,0 +1,17 @@
+{
+ "culture": "it",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/nl.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/nl.json
new file mode 100644
index 0000000000..68646ea677
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/nl.json
@@ -0,0 +1,17 @@
+{
+ "culture": "nl",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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)"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pl-PL.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pl-PL.json
new file mode 100644
index 0000000000..085a20af9d
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pl-PL.json
@@ -0,0 +1,17 @@
+{
+ "culture": "pl-PL",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pt-BR.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pt-BR.json
new file mode 100644
index 0000000000..f1d7cd1dfe
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pt-BR.json
@@ -0,0 +1,17 @@
+{
+ "culture": "pt-BR",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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)"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ro-RO.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ro-RO.json
new file mode 100644
index 0000000000..51a7446b4f
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ro-RO.json
@@ -0,0 +1,17 @@
+{
+ "culture": "ro-RO",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ru.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ru.json
new file mode 100644
index 0000000000..fbee7ea360
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ru.json
@@ -0,0 +1,17 @@
+{
+ "culture": "ru",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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} секунда/секунд"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sk.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sk.json
new file mode 100644
index 0000000000..16e1a32403
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sk.json
@@ -0,0 +1,17 @@
+{
+ "culture": "sk",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sl.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sl.json
new file mode 100644
index 0000000000..22bbbf58c2
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sl.json
@@ -0,0 +1,17 @@
+{
+ "culture": "sl",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sv.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sv.json
new file mode 100644
index 0000000000..1aa6d1f6ed
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sv.json
@@ -0,0 +1,17 @@
+{
+ "culture": "sv",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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)"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/tr.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/tr.json
new file mode 100644
index 0000000000..9dfc82dc7b
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/tr.json
@@ -0,0 +1,17 @@
+{
+ "culture": "tr",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/vi.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/vi.json
new file mode 100644
index 0000000000..4744a6c5ce
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/vi.json
@@ -0,0 +1,17 @@
+{
+ "culture": "vi",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/zh-Hans.json b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/zh-Hans.json
new file mode 100644
index 0000000000..1db03def8c
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/zh-Hans.json
@@ -0,0 +1,17 @@
+{
+ "culture": "zh-Hans",
+ "texts": {
+ "Volo.Abp.OperationRateLimit: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} 秒"
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitCacheItem.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitCacheItem.cs
new file mode 100644
index 0000000000..f2ed13b7b1
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitCacheItem.cs
@@ -0,0 +1,14 @@
+using System;
+using Volo.Abp.Caching;
+using Volo.Abp.MultiTenancy;
+
+namespace Volo.Abp.OperationRateLimit;
+
+[CacheName("OperationRateLimit")]
+[IgnoreMultiTenancy]
+public class OperationRateLimitCacheItem
+{
+ public int Count { get; set; }
+
+ public DateTimeOffset WindowStart { get; set; }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs
new file mode 100644
index 0000000000..98965c445f
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs
@@ -0,0 +1,258 @@
+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.ClientIpAddress;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.MultiTenancy;
+using Volo.Abp.Users;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientDependency
+{
+ protected AbpOperationRateLimitOptions Options { get; }
+ protected IOperationRateLimitPolicyProvider PolicyProvider { get; }
+ protected IServiceProvider ServiceProvider { get; }
+ protected IOperationRateLimitStore Store { get; }
+ protected ICurrentUser CurrentUser { get; }
+ protected ICurrentTenant CurrentTenant { get; }
+ protected IClientIpAddressProvider ClientIpAddressProvider { get; }
+
+ public OperationRateLimitChecker(
+ IOptions options,
+ IOperationRateLimitPolicyProvider policyProvider,
+ IServiceProvider serviceProvider,
+ IOperationRateLimitStore store,
+ ICurrentUser currentUser,
+ ICurrentTenant currentTenant,
+ IClientIpAddressProvider clientIpAddressProvider)
+ {
+ Options = options.Value;
+ PolicyProvider = policyProvider;
+ ServiceProvider = serviceProvider;
+ Store = store;
+ CurrentUser = currentUser;
+ CurrentTenant = currentTenant;
+ ClientIpAddressProvider = clientIpAddressProvider;
+ }
+
+ public virtual async Task CheckAsync(string policyName, OperationRateLimitContext? 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();
+ 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 pass - now increment all counters.
+ // Also guard against a concurrent race where another request consumed the last quota
+ // between Phase 1 and Phase 2.
+ var incrementResults = new List();
+ foreach (var rule in rules)
+ {
+ incrementResults.Add(await rule.AcquireAsync(context));
+ }
+
+ if (incrementResults.Any(r => !r.IsAllowed))
+ {
+ var aggregatedResult = AggregateResults(incrementResults, policy);
+ ThrowRateLimitException(policy, aggregatedResult, context);
+ }
+ }
+
+ public virtual async Task IsAllowedAsync(string policyName, OperationRateLimitContext? 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 GetStatusAsync(string policyName, OperationRateLimitContext? context = null)
+ {
+ if (!Options.IsEnabled)
+ {
+ return new OperationRateLimitResult
+ {
+ 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();
+
+ foreach (var rule in rules)
+ {
+ ruleResults.Add(await rule.CheckAsync(context));
+ }
+
+ return AggregateResults(ruleResults, policy);
+ }
+
+ public virtual async Task ResetAsync(string policyName, OperationRateLimitContext? context = null)
+ {
+ context = EnsureContext(context);
+ var policy = await PolicyProvider.GetAsync(policyName);
+ var rules = CreateRules(policy);
+
+ foreach (var rule in rules)
+ {
+ await rule.ResetAsync(context);
+ }
+ }
+
+ protected virtual OperationRateLimitContext EnsureContext(OperationRateLimitContext? context)
+ {
+ context ??= new OperationRateLimitContext();
+ context.ServiceProvider = ServiceProvider;
+ return context;
+ }
+
+ protected virtual List CreateRules(OperationRateLimitPolicy policy)
+ {
+ var rules = new List();
+
+ for (var i = 0; i < policy.Rules.Count; i++)
+ {
+ rules.Add(new FixedWindowOperationRateLimitRule(
+ policy.Name,
+ i,
+ policy.Rules[i],
+ Store,
+ CurrentUser,
+ CurrentTenant,
+ ClientIpAddressProvider));
+ }
+
+ foreach (var customRuleType in policy.CustomRuleTypes)
+ {
+ rules.Add((IOperationRateLimitRule)ServiceProvider.GetRequiredService(customRuleType));
+ }
+
+ return rules;
+ }
+
+ protected virtual OperationRateLimitResult AggregateResults(
+ List ruleResults,
+ OperationRateLimitPolicy policy)
+ {
+ var isAllowed = ruleResults.All(r => r.IsAllowed);
+ var mostRestrictive = ruleResults
+ .OrderBy(r => r.RemainingCount)
+ .ThenByDescending(r => r.RetryAfter ?? TimeSpan.Zero)
+ .First();
+
+ return new OperationRateLimitResult
+ {
+ IsAllowed = isAllowed,
+ RemainingCount = mostRestrictive.RemainingCount,
+ MaxCount = mostRestrictive.MaxCount,
+ CurrentCount = mostRestrictive.MaxCount - mostRestrictive.RemainingCount,
+ 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(
+ OperationRateLimitPolicy policy,
+ OperationRateLimitResult result,
+ OperationRateLimitContext context)
+ {
+ var formatter = context.ServiceProvider.GetRequiredService();
+
+ var exception = new AbpOperationRateLimitException(
+ 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>();
+ foreach (var ruleResult in result.RuleResults)
+ {
+ ruleDetails.Add(new Dictionary
+ {
+ ["RuleName"] = ruleResult.RuleName,
+ ["IsAllowed"] = ruleResult.IsAllowed,
+ ["MaxCount"] = ruleResult.MaxCount,
+ ["RemainingCount"] = ruleResult.RemainingCount,
+ ["CurrentCount"] = ruleResult.MaxCount - ruleResult.RemainingCount,
+ ["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;
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs
new file mode 100644
index 0000000000..d3e706a9ff
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitContext
+{
+ ///
+ /// Optional parameter passed by the caller.
+ /// Used by rules configured with PartitionByParameter().
+ /// Can be email, phone number, resource id, or any string.
+ ///
+ public string? Parameter { get; set; }
+
+ ///
+ /// Additional properties that can be read by custom implementations
+ /// and are forwarded to the exception's Data dictionary when the rate limit is exceeded.
+ ///
+ public Dictionary ExtraProperties { get; set; } = new();
+
+ ///
+ /// The service provider for resolving services.
+ /// Set automatically by the checker.
+ ///
+ public IServiceProvider ServiceProvider { get; set; } = default!;
+
+ public T GetRequiredService() where T : notnull
+ => ServiceProvider.GetRequiredService();
+
+ public T? GetService() => ServiceProvider.GetService();
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs
new file mode 100644
index 0000000000..3435f07bd0
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs
@@ -0,0 +1,12 @@
+namespace Volo.Abp.OperationRateLimit;
+
+public enum OperationRateLimitPartitionType
+{
+ Parameter,
+ CurrentUser,
+ CurrentTenant,
+ ClientIp,
+ Email,
+ PhoneNumber,
+ Custom
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs
new file mode 100644
index 0000000000..cf720ba112
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Collections.Generic;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitPolicy
+{
+ public string Name { get; set; } = default!;
+
+ public string? ErrorCode { get; set; }
+
+ public List Rules { get; set; } = new();
+
+ public List CustomRuleTypes { get; set; } = new();
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs
new file mode 100644
index 0000000000..173af66758
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitPolicyBuilder
+{
+ private readonly string _name;
+ private string? _errorCode;
+ private readonly List _rules = new();
+ private readonly List _customRuleTypes = new();
+
+ public OperationRateLimitPolicyBuilder(string name)
+ {
+ _name = Check.NotNullOrWhiteSpace(name, nameof(name));
+ }
+
+ ///
+ /// Add a built-in rule. Multiple rules are AND-combined.
+ ///
+ public OperationRateLimitPolicyBuilder AddRule(
+ Action configure)
+ {
+ var builder = new OperationRateLimitRuleBuilder();
+ configure(builder);
+ _rules.Add(builder.Build());
+ return this;
+ }
+
+ ///
+ /// Add a custom rule type (resolved from DI).
+ ///
+ public OperationRateLimitPolicyBuilder AddRule()
+ where TRule : class, IOperationRateLimitRule
+ {
+ _customRuleTypes.Add(typeof(TRule));
+ return this;
+ }
+
+ ///
+ /// Shortcut: single-rule policy with fixed window.
+ /// Returns the rule builder for partition configuration.
+ ///
+ public OperationRateLimitRuleBuilder WithFixedWindow(
+ TimeSpan duration, int maxCount)
+ {
+ var builder = new OperationRateLimitRuleBuilder(this);
+ builder.WithFixedWindow(duration, maxCount);
+ return builder;
+ }
+
+ ///
+ /// Set a custom ErrorCode for this policy's exception.
+ ///
+ public OperationRateLimitPolicyBuilder WithErrorCode(string errorCode)
+ {
+ _errorCode = Check.NotNullOrWhiteSpace(errorCode, nameof(errorCode));
+ return this;
+ }
+
+ internal void AddRuleDefinition(OperationRateLimitRuleDefinition definition)
+ {
+ _rules.Add(definition);
+ }
+
+ internal OperationRateLimitPolicy 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
+ .GroupBy(r => (r.Duration, r.MaxCount, r.PartitionType))
+ .FirstOrDefault(g => g.Count() > 1);
+
+ if (duplicate != null)
+ {
+ var (duration, maxCount, partitionType) = duplicate.Key;
+ throw new AbpException(
+ $"Operation rate limit policy '{_name}' has duplicate rules with the same " +
+ $"Duration ({duration}), MaxCount ({maxCount}), and PartitionType ({partitionType}). " +
+ "Each rule in a policy must have a unique combination of these properties.");
+ }
+
+ return new OperationRateLimitPolicy
+ {
+ Name = _name,
+ ErrorCode = _errorCode,
+ Rules = new List(_rules),
+ CustomRuleTypes = new List(_customRuleTypes)
+ };
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs
new file mode 100644
index 0000000000..83d77d21af
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitResult
+{
+ 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; }
+
+ ///
+ /// Detailed results per rule (for composite policies).
+ ///
+ public List? RuleResults { get; set; }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs
new file mode 100644
index 0000000000..98dfd65f92
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs
@@ -0,0 +1,155 @@
+using System;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitRuleBuilder
+{
+ private readonly OperationRateLimitPolicyBuilder? _policyBuilder;
+ private TimeSpan _duration;
+ private int _maxCount;
+ private OperationRateLimitPartitionType? _partitionType;
+ private Func? _customPartitionKeyResolver;
+ private bool _isMultiTenant;
+
+ public OperationRateLimitRuleBuilder()
+ {
+ }
+
+ internal OperationRateLimitRuleBuilder(OperationRateLimitPolicyBuilder policyBuilder)
+ {
+ _policyBuilder = policyBuilder;
+ }
+
+ public OperationRateLimitRuleBuilder WithFixedWindow(
+ TimeSpan duration, int maxCount)
+ {
+ _duration = duration;
+ _maxCount = maxCount;
+ return this;
+ }
+
+ public OperationRateLimitRuleBuilder WithMultiTenancy()
+ {
+ _isMultiTenant = true;
+ return this;
+ }
+
+ ///
+ /// Use context.Parameter as partition key.
+ ///
+ public OperationRateLimitPolicyBuilder PartitionByParameter()
+ {
+ _partitionType = OperationRateLimitPartitionType.Parameter;
+ CommitToPolicyBuilder();
+ return _policyBuilder!;
+ }
+
+ ///
+ /// Auto resolve from ICurrentUser.Id.
+ ///
+ public OperationRateLimitPolicyBuilder PartitionByCurrentUser()
+ {
+ _partitionType = OperationRateLimitPartitionType.CurrentUser;
+ CommitToPolicyBuilder();
+ return _policyBuilder!;
+ }
+
+ ///
+ /// Auto resolve from ICurrentTenant.Id.
+ ///
+ public OperationRateLimitPolicyBuilder PartitionByCurrentTenant()
+ {
+ _partitionType = OperationRateLimitPartitionType.CurrentTenant;
+ CommitToPolicyBuilder();
+ return _policyBuilder!;
+ }
+
+ ///
+ /// Auto resolve from IClientIpAddressProvider.ClientIpAddress.
+ ///
+ public OperationRateLimitPolicyBuilder PartitionByClientIp()
+ {
+ _partitionType = OperationRateLimitPartitionType.ClientIp;
+ CommitToPolicyBuilder();
+ return _policyBuilder!;
+ }
+
+ ///
+ /// Partition by email address.
+ /// Resolves from context.Parameter, falls back to ICurrentUser.Email.
+ ///
+ public OperationRateLimitPolicyBuilder PartitionByEmail()
+ {
+ _partitionType = OperationRateLimitPartitionType.Email;
+ CommitToPolicyBuilder();
+ return _policyBuilder!;
+ }
+
+ ///
+ /// Partition by phone number.
+ /// Resolves from context.Parameter, falls back to ICurrentUser.PhoneNumber.
+ ///
+ public OperationRateLimitPolicyBuilder PartitionByPhoneNumber()
+ {
+ _partitionType = OperationRateLimitPartitionType.PhoneNumber;
+ CommitToPolicyBuilder();
+ return _policyBuilder!;
+ }
+
+ ///
+ /// Custom partition key resolver from context.
+ ///
+ public OperationRateLimitPolicyBuilder PartitionBy(
+ Func keyResolver)
+ {
+ _partitionType = OperationRateLimitPartitionType.Custom;
+ _customPartitionKeyResolver = Check.NotNull(keyResolver, nameof(keyResolver));
+ CommitToPolicyBuilder();
+ return _policyBuilder!;
+ }
+
+ protected virtual void CommitToPolicyBuilder()
+ {
+ _policyBuilder?.AddRuleDefinition(Build());
+ }
+
+ internal OperationRateLimitRuleDefinition 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 == OperationRateLimitPartitionType.Custom && _customPartitionKeyResolver == null)
+ {
+ throw new AbpException(
+ "Custom partition type requires a key resolver. " +
+ "Call PartitionBy(keyResolver) instead of setting partition type directly.");
+ }
+
+ return new OperationRateLimitRuleDefinition
+ {
+ Duration = _duration,
+ MaxCount = _maxCount,
+ PartitionType = _partitionType.Value,
+ CustomPartitionKeyResolver = _customPartitionKeyResolver,
+ IsMultiTenant = _isMultiTenant
+ };
+ }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleDefinition.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleDefinition.cs
new file mode 100644
index 0000000000..856fb299fa
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleDefinition.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitRuleDefinition
+{
+ public TimeSpan Duration { get; set; }
+
+ public int MaxCount { get; set; }
+
+ public OperationRateLimitPartitionType PartitionType { get; set; }
+
+ public Func? CustomPartitionKeyResolver { get; set; }
+
+ public bool IsMultiTenant { get; set; }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs
new file mode 100644
index 0000000000..efc0fd8548
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitRuleResult
+{
+ public string RuleName { get; set; } = default!;
+
+ public bool IsAllowed { get; set; }
+
+ public int RemainingCount { get; set; }
+
+ public int MaxCount { get; set; }
+
+ public TimeSpan? RetryAfter { get; set; }
+
+ public TimeSpan WindowDuration { get; set; }
+}
diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs
new file mode 100644
index 0000000000..d67d650298
--- /dev/null
+++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitStoreResult
+{
+ public bool IsAllowed { get; set; }
+
+ public int CurrentCount { get; set; }
+
+ public int MaxCount { get; set; }
+
+ public TimeSpan? RetryAfter { get; set; }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj
new file mode 100644
index 0000000000..5f284a7c3b
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+ net10.0
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs
new file mode 100644
index 0000000000..bcbf6a2300
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs
@@ -0,0 +1,99 @@
+using System;
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class AbpOperationRateLimitException_Tests
+{
+ [Fact]
+ public void Should_Set_HttpStatusCode_To_429()
+ {
+ var result = new OperationRateLimitResult
+ {
+ IsAllowed = false,
+ MaxCount = 3,
+ CurrentCount = 3,
+ RemainingCount = 0,
+ RetryAfter = TimeSpan.FromMinutes(15)
+ };
+
+ var exception = new AbpOperationRateLimitException("TestPolicy", result);
+
+ exception.HttpStatusCode.ShouldBe(429);
+ }
+
+ [Fact]
+ public void Should_Set_Default_ErrorCode()
+ {
+ var result = new OperationRateLimitResult
+ {
+ IsAllowed = false,
+ MaxCount = 3,
+ CurrentCount = 3,
+ RemainingCount = 0
+ };
+
+ var exception = new AbpOperationRateLimitException("TestPolicy", result);
+
+ exception.Code.ShouldBe(AbpOperationRateLimitErrorCodes.ExceedLimit);
+ }
+
+ [Fact]
+ public void Should_Set_Custom_ErrorCode()
+ {
+ var result = new OperationRateLimitResult
+ {
+ IsAllowed = false,
+ MaxCount = 3,
+ CurrentCount = 3,
+ RemainingCount = 0
+ };
+
+ var exception = new AbpOperationRateLimitException("TestPolicy", result, "App:Custom:Error");
+
+ exception.Code.ShouldBe("App:Custom:Error");
+ }
+
+ [Fact]
+ public void Should_Include_Data_Properties()
+ {
+ var result = new OperationRateLimitResult
+ {
+ IsAllowed = false,
+ MaxCount = 3,
+ CurrentCount = 3,
+ RemainingCount = 0,
+ RetryAfter = TimeSpan.FromMinutes(15),
+ WindowDuration = TimeSpan.FromHours(1)
+ };
+
+ var exception = new AbpOperationRateLimitException("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 OperationRateLimitResult
+ {
+ IsAllowed = false,
+ MaxCount = 5,
+ CurrentCount = 5,
+ RemainingCount = 0,
+ RetryAfter = TimeSpan.FromHours(1)
+ };
+
+ var exception = new AbpOperationRateLimitException("MyPolicy", result);
+
+ exception.PolicyName.ShouldBe("MyPolicy");
+ exception.Result.ShouldBeSameAs(result);
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitPhase2RaceTestModule.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitPhase2RaceTestModule.cs
new file mode 100644
index 0000000000..f390d6d0e9
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitPhase2RaceTestModule.cs
@@ -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.OperationRateLimit;
+
+///
+/// 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).
+///
+internal class RaceConditionSimulatorStore : IOperationRateLimitStore
+{
+ public Task GetAsync(string key, TimeSpan duration, int maxCount)
+ {
+ return Task.FromResult(new OperationRateLimitStoreResult
+ {
+ IsAllowed = true,
+ CurrentCount = 0,
+ MaxCount = maxCount
+ });
+ }
+
+ public Task 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 OperationRateLimitStoreResult
+ {
+ IsAllowed = false,
+ CurrentCount = maxCount,
+ MaxCount = maxCount,
+ RetryAfter = duration
+ });
+ }
+
+ public Task ResetAsync(string key)
+ {
+ return Task.CompletedTask;
+ }
+}
+
+[DependsOn(
+ typeof(AbpOperationRateLimitModule),
+ typeof(AbpExceptionHandlingModule),
+ typeof(AbpTestBaseModule),
+ typeof(AbpAutofacModule)
+)]
+public class AbpOperationRateLimitPhase2RaceTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.Replace(
+ ServiceDescriptor.Transient());
+
+ Configure(options =>
+ {
+ options.AddPolicy("TestRacePolicy", policy =>
+ {
+ policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3)
+ .PartitionByParameter();
+ });
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs
new file mode 100644
index 0000000000..13a9a3a4f5
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs
@@ -0,0 +1,179 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using Volo.Abp.AspNetCore.ClientIpAddress;
+using Volo.Abp.Autofac;
+using Volo.Abp.ExceptionHandling;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.OperationRateLimit;
+
+[DependsOn(
+ typeof(AbpOperationRateLimitModule),
+ typeof(AbpExceptionHandlingModule),
+ typeof(AbpTestBaseModule),
+ typeof(AbpAutofacModule)
+)]
+public class AbpOperationRateLimitTestModule : AbpModule
+{
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ var mockIpProvider = Substitute.For();
+ mockIpProvider.ClientIpAddress.Returns("127.0.0.1");
+ context.Services.AddSingleton(mockIpProvider);
+
+ Configure(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 => $"action:{ctx.Parameter}");
+ });
+
+ // 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();
+ });
+ });
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs
new file mode 100644
index 0000000000..d4748b60e3
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Threading.Tasks;
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class DistributedCacheOperationRateLimitStore_Tests : OperationRateLimitTestBase
+{
+ private readonly IOperationRateLimitStore _store;
+
+ public DistributedCacheOperationRateLimitStore_Tests()
+ {
+ _store = GetRequiredService();
+ }
+
+ [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.ShouldNotBeNull();
+ }
+
+ [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.ShouldNotBeNull();
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs
new file mode 100644
index 0000000000..6254ada97f
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Threading.Tasks;
+using Shouldly;
+using Volo.Abp.Testing;
+using Xunit;
+
+namespace Volo.Abp.OperationRateLimit;
+
+///
+/// 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.
+///
+public class OperationRateLimitCheckerPhase1_Tests : OperationRateLimitTestBase
+{
+ private readonly IOperationRateLimitChecker _checker;
+
+ public OperationRateLimitCheckerPhase1_Tests()
+ {
+ _checker = GetRequiredService();
+ }
+
+ [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 OperationRateLimitContext { 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(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 OperationRateLimitContext { Parameter = param };
+
+ // Exhaust both rules
+ await _checker.CheckAsync("TestCompositeMaxRetryAfter", context);
+
+ var exception = await Assert.ThrowsAsync(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 OperationRateLimitContext { Parameter = param };
+
+ // Exhaust only Rule0 (max=1)
+ await _checker.CheckAsync("TestCompositePartialBlock", context);
+
+ var exception = await Assert.ThrowsAsync(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
+ }
+}
+
+///
+/// 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.
+///
+public class OperationRateLimitCheckerPhase2Race_Tests
+ : AbpIntegratedTest
+{
+ 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();
+ var context = new OperationRateLimitContext { Parameter = "race-test" };
+
+ var exception = await Assert.ThrowsAsync(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();
+ var context = new OperationRateLimitContext { 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();
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs
new file mode 100644
index 0000000000..347aea5f37
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs
@@ -0,0 +1,731 @@
+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.OperationRateLimit;
+
+public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase
+{
+ private readonly IOperationRateLimitChecker _checker;
+
+ public OperationRateLimitChecker_Tests()
+ {
+ _checker = GetRequiredService();
+ }
+
+ [Fact]
+ public async Task Should_Allow_Within_Limit()
+ {
+ var context = new OperationRateLimitContext { 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 OperationRateLimitContext { Parameter = param };
+
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await _checker.CheckAsync("TestSimple", context);
+ });
+
+ exception.PolicyName.ShouldBe("TestSimple");
+ exception.Result.IsAllowed.ShouldBeFalse();
+ exception.HttpStatusCode.ShouldBe(429);
+ exception.Code.ShouldBe(AbpOperationRateLimitErrorCodes.ExceedLimit);
+ }
+
+ [Fact]
+ public async Task Should_Return_Correct_RemainingCount()
+ {
+ var param = $"remaining-{Guid.NewGuid()}";
+ var context = new OperationRateLimitContext { 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 OperationRateLimitContext { Parameter = param };
+
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+
+ var exception = await Assert.ThrowsAsync(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();
+ var claimsPrincipal = CreateClaimsPrincipal(userId);
+
+ using (principalAccessor.Change(claimsPrincipal))
+ {
+ var checker = scope.ServiceProvider.GetRequiredService();
+ var context = new OperationRateLimitContext { 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();
+ var claimsPrincipal = CreateClaimsPrincipal(userId);
+
+ using (principalAccessor.Change(claimsPrincipal))
+ {
+ var checker = scope.ServiceProvider.GetRequiredService();
+ var param = $"composite-reject-{Guid.NewGuid()}";
+ var context = new OperationRateLimitContext { 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(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 OperationRateLimitContext { Parameter = param };
+
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+
+ // Should be at limit
+ await Assert.ThrowsAsync(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 OperationRateLimitContext { Parameter = param };
+
+ await _checker.CheckAsync("TestCustomErrorCode", context);
+ await _checker.CheckAsync("TestCustomErrorCode", context);
+
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await _checker.CheckAsync("TestCustomErrorCode", context);
+ });
+
+ exception.Code.ShouldBe("Test:CustomError");
+ }
+
+ [Fact]
+ public async Task Should_Throw_For_Unknown_Policy()
+ {
+ await Assert.ThrowsAsync(async () =>
+ {
+ await _checker.CheckAsync("NonExistentPolicy");
+ });
+ }
+
+ [Fact]
+ public async Task Should_Skip_When_Disabled()
+ {
+ var options = GetRequiredService>();
+ var originalValue = options.Value.IsEnabled;
+
+ try
+ {
+ options.Value.IsEnabled = false;
+
+ var param = $"disabled-{Guid.NewGuid()}";
+ var context = new OperationRateLimitContext { 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 OperationRateLimitContext { 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 OperationRateLimitContext { Parameter = param1 };
+ var context2 = new OperationRateLimitContext { 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 OperationRateLimitContext
+ {
+ 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(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 OperationRateLimitContext { Parameter = email };
+
+ await _checker.CheckAsync("TestEmailBased", context);
+ await _checker.CheckAsync("TestEmailBased", context);
+ await _checker.CheckAsync("TestEmailBased", context);
+
+ await Assert.ThrowsAsync(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();
+ var claimsPrincipal = CreateClaimsPrincipal(userId);
+
+ using (principalAccessor.Change(claimsPrincipal))
+ {
+ var checker = scope.ServiceProvider.GetRequiredService();
+
+ // No Parameter set, should fall back to ICurrentUser.Email
+ var context = new OperationRateLimitContext();
+
+ await checker.CheckAsync("TestEmailBased", context);
+ await checker.CheckAsync("TestEmailBased", context);
+ await checker.CheckAsync("TestEmailBased", context);
+
+ await Assert.ThrowsAsync(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 OperationRateLimitContext { Parameter = phone };
+
+ await _checker.CheckAsync("TestPhoneNumberBased", context);
+ await _checker.CheckAsync("TestPhoneNumberBased", context);
+ await _checker.CheckAsync("TestPhoneNumberBased", context);
+
+ await Assert.ThrowsAsync(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();
+ var claimsPrincipal = CreateClaimsPrincipal(userId);
+
+ using (principalAccessor.Change(claimsPrincipal))
+ {
+ var checker = scope.ServiceProvider.GetRequiredService();
+
+ // No Parameter set, should fall back to ICurrentUser.PhoneNumber
+ var context = new OperationRateLimitContext();
+
+ await checker.CheckAsync("TestPhoneNumberBased", context);
+ await checker.CheckAsync("TestPhoneNumberBased", context);
+ await checker.CheckAsync("TestPhoneNumberBased", context);
+
+ await Assert.ThrowsAsync(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 OperationRateLimitContext();
+
+ await Assert.ThrowsAsync(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();
+ var claimsPrincipal = CreateClaimsPrincipal(userId);
+
+ using (principalAccessor.Change(claimsPrincipal))
+ {
+ var checker = scope.ServiceProvider.GetRequiredService();
+ var param = $"no-waste-{Guid.NewGuid()}";
+ var context = new OperationRateLimitContext { 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(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 OperationRateLimitContext { 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(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 OperationRateLimitContext { Parameter = param1 };
+ var context2 = new OperationRateLimitContext { 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(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();
+ var claimsPrincipal = CreateClaimsPrincipal(userId);
+
+ using (principalAccessor.Change(claimsPrincipal))
+ {
+ var checker = scope.ServiceProvider.GetRequiredService();
+ var param = $"triple-{Guid.NewGuid()}";
+ var context = new OperationRateLimitContext { 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(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();
+ var claimsPrincipal = CreateClaimsPrincipal(userId);
+
+ using (principalAccessor.Change(claimsPrincipal))
+ {
+ var checker = scope.ServiceProvider.GetRequiredService();
+ var param = $"triple-nowaste-{Guid.NewGuid()}";
+ var context = new OperationRateLimitContext { 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(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();
+ var claimsPrincipal = CreateClaimsPrincipal(userId);
+
+ using (principalAccessor.Change(claimsPrincipal))
+ {
+ var checker = scope.ServiceProvider.GetRequiredService();
+ var param = $"triple-reset-{Guid.NewGuid()}";
+ var context = new OperationRateLimitContext { Parameter = param };
+
+ // Exhaust IP limit
+ await checker.CheckAsync("TestCompositeTriple", context);
+ await checker.CheckAsync("TestCompositeTriple", context);
+ await checker.CheckAsync("TestCompositeTriple", context);
+
+ await Assert.ThrowsAsync(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 OperationRateLimitContext();
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await _checker.CheckAsync("TestPhoneNumberBased", context);
+ });
+ }
+
+ [Fact]
+ public async Task Should_Deny_First_Request_When_MaxCount_Is_Zero()
+ {
+ var context = new OperationRateLimitContext { Parameter = $"ban-{Guid.NewGuid()}" };
+
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ await _checker.CheckAsync("TestBanPolicy", context);
+ });
+
+ exception.Result.IsAllowed.ShouldBeFalse();
+ exception.Result.MaxCount.ShouldBe(0);
+ exception.HttpStatusCode.ShouldBe(429);
+ }
+
+ [Fact]
+ public async Task Should_IsAllowed_Return_False_When_MaxCount_Is_Zero()
+ {
+ var context = new OperationRateLimitContext { 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 OperationRateLimitContext { Parameter = $"ban-status-{Guid.NewGuid()}" };
+
+ var status = await _checker.GetStatusAsync("TestBanPolicy", context);
+ status.IsAllowed.ShouldBeFalse();
+ status.MaxCount.ShouldBe(0);
+ status.RemainingCount.ShouldBe(0);
+ }
+
+ [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 OperationRateLimitContext { Parameter = param1 };
+ var ctx2 = new OperationRateLimitContext { Parameter = param2 };
+
+ // Exhaust param1's quota (max=2)
+ await _checker.CheckAsync("TestCustomResolver", ctx1);
+ await _checker.CheckAsync("TestCustomResolver", ctx1);
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await _checker.CheckAsync("TestCustomResolver", ctx1);
+ });
+
+ // param2 should still be allowed
+ await _checker.CheckAsync("TestCustomResolver", ctx2);
+ (await _checker.IsAllowedAsync("TestCustomResolver", ctx2)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Should_Throw_When_Policy_Has_Duplicate_Rules()
+ {
+ var options = new AbpOperationRateLimitOptions();
+
+ Assert.Throws(() =>
+ {
+ options.AddPolicy("DuplicateRulePolicy", policy =>
+ {
+ policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter());
+ policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter());
+ });
+ });
+ }
+
+ 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"));
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs
new file mode 100644
index 0000000000..48a68b876e
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs
@@ -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.OperationRateLimit;
+
+public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTestBase
+{
+ private readonly IOperationRateLimitChecker _checker;
+ private readonly IExceptionToErrorInfoConverter _errorInfoConverter;
+ private readonly IOperationRateLimitFormatter _formatter;
+
+ public OperationRateLimitFrontendIntegration_Tests()
+ {
+ _checker = GetRequiredService();
+ _errorInfoConverter = GetRequiredService();
+ _formatter = GetRequiredService();
+ }
+
+ [Fact]
+ public async Task ErrorInfo_Should_Contain_Localized_Message_En()
+ {
+ using (CultureHelper.Use("en"))
+ {
+ var param = $"frontend-en-{Guid.NewGuid()}";
+ var context = new OperationRateLimitContext { Parameter = param };
+
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+
+ var exception = await Assert.ThrowsAsync(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 OperationRateLimitContext { Parameter = param };
+
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+ await _checker.CheckAsync("TestSimple", context);
+
+ var exception = await Assert.ThrowsAsync(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 OperationRateLimitContext
+ {
+ 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(async () =>
+ {
+ await _checker.CheckAsync("TestSimple", context);
+ });
+
+ var errorInfo = _errorInfoConverter.Convert(exception);
+
+ // Frontend receives error.code
+ errorInfo.Code.ShouldBe(AbpOperationRateLimitErrorCodes.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();
+
+ // 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();
+
+ // RuleDetails: complete rule information for frontend
+ var ruleDetails = exception.Data["RuleDetails"].ShouldBeOfType>>();
+ 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 OperationRateLimitContext { 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 OperationRateLimitContext { Parameter = param };
+
+ await _checker.CheckAsync("TestCustomErrorCode", context);
+ await _checker.CheckAsync("TestCustomErrorCode", context);
+
+ var exception = await Assert.ThrowsAsync(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 OperationRateLimitContext { 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 OperationRateLimitContext { 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();
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs
new file mode 100644
index 0000000000..5ec3ad2ae3
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs
@@ -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.OperationRateLimit;
+
+///
+/// Verifies per-tenant isolation for tenant-scoped partition types and
+/// global (cross-tenant) sharing for ClientIp partition type.
+///
+public class OperationRateLimitMultiTenant_Tests : OperationRateLimitTestBase
+{
+ private readonly ICurrentTenant _currentTenant;
+ private readonly IOperationRateLimitChecker _checker;
+
+ private static readonly Guid TenantA = Guid.NewGuid();
+ private static readonly Guid TenantB = Guid.NewGuid();
+
+ public OperationRateLimitMultiTenant_Tests()
+ {
+ _currentTenant = GetRequiredService();
+ _checker = GetRequiredService();
+ }
+
+ [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 OperationRateLimitContext { Parameter = param };
+ await _checker.CheckAsync("TestMultiTenantByParameter", ctx);
+ await _checker.CheckAsync("TestMultiTenantByParameter", ctx);
+
+ // Tenant A exhausted (max=2)
+ await Assert.ThrowsAsync(async () =>
+ {
+ await _checker.CheckAsync("TestMultiTenantByParameter", ctx);
+ });
+ }
+
+ using (_currentTenant.Change(TenantB))
+ {
+ var ctx = new OperationRateLimitContext { 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 NullClientIpAddressProvider returns null, which resolves to "unknown" in the rule.
+
+ using (_currentTenant.Change(TenantA))
+ {
+ var ctx = new OperationRateLimitContext();
+ await _checker.CheckAsync("TestMultiTenantByClientIp", ctx);
+ await _checker.CheckAsync("TestMultiTenantByClientIp", ctx);
+ }
+
+ using (_currentTenant.Change(TenantB))
+ {
+ var ctx = new OperationRateLimitContext();
+
+ // Tenant B shares the same IP counter; should be at limit now
+ await Assert.ThrowsAsync(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 OperationRateLimitContext { Parameter = param };
+ await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx);
+ await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx);
+ await Assert.ThrowsAsync(async () =>
+ {
+ await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx);
+ });
+
+ // Tenant A should have its own counter, unaffected by host
+ using (_currentTenant.Change(TenantA))
+ {
+ var tenantCtx = new OperationRateLimitContext { Parameter = param };
+ await _checker.CheckAsync("TestMultiTenantByParameter", tenantCtx);
+ (await _checker.IsAllowedAsync("TestMultiTenantByParameter", tenantCtx)).ShouldBeTrue();
+ }
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder_Tests.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder_Tests.cs
new file mode 100644
index 0000000000..1be970f4f4
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder_Tests.cs
@@ -0,0 +1,209 @@
+using System;
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitPolicyBuilder_Tests
+{
+ [Fact]
+ public void Should_Build_Simple_Policy()
+ {
+ var options = new AbpOperationRateLimitOptions();
+ 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(OperationRateLimitPartitionType.Parameter);
+ policy.ErrorCode.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Should_Build_Composite_Policy()
+ {
+ var options = new AbpOperationRateLimitOptions();
+ 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(OperationRateLimitPartitionType.Parameter);
+ policy.Rules[0].MaxCount.ShouldBe(3);
+ policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitPartitionType.CurrentUser);
+ policy.Rules[1].MaxCount.ShouldBe(10);
+ }
+
+ [Fact]
+ public void Should_Set_ErrorCode()
+ {
+ var options = new AbpOperationRateLimitOptions();
+ 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 AbpOperationRateLimitOptions();
+ options.AddPolicy("CustomPolicy", policy =>
+ {
+ policy.AddRule(rule => rule
+ .WithFixedWindow(TimeSpan.FromMinutes(30), maxCount: 5)
+ .PartitionBy(ctx => $"custom:{ctx.Parameter}"));
+ });
+
+ var policy = options.Policies["CustomPolicy"];
+
+ policy.Rules.Count.ShouldBe(1);
+ policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.Custom);
+ policy.Rules[0].CustomPartitionKeyResolver.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Should_Support_All_Partition_Types()
+ {
+ var options = new AbpOperationRateLimitOptions();
+
+ 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(OperationRateLimitPartitionType.Parameter);
+ options.Policies["P2"].Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.CurrentUser);
+ options.Policies["P3"].Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.CurrentTenant);
+ options.Policies["P4"].Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.ClientIp);
+ options.Policies["P5"].Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.Email);
+ options.Policies["P6"].Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.PhoneNumber);
+ }
+
+ [Fact]
+ public void Should_Throw_When_Policy_Has_No_Rules()
+ {
+ var options = new AbpOperationRateLimitOptions();
+
+ var exception = Assert.Throws(() =>
+ {
+ 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 AbpOperationRateLimitOptions();
+
+ var exception = Assert.Throws(() =>
+ {
+ 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 AbpOperationRateLimitOptions();
+
+ var exception = Assert.Throws(() =>
+ {
+ 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 AbpOperationRateLimitOptions();
+
+ // 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 AbpOperationRateLimitOptions();
+
+ var exception = Assert.Throws(() =>
+ {
+ 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 AbpOperationRateLimitOptions();
+
+ var exception = Assert.Throws(() =>
+ {
+ options.AddPolicy("NegativePolicy", policy =>
+ {
+ policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: -1)
+ .PartitionByParameter();
+ });
+ });
+
+ exception.Message.ShouldContain("maxCount >= 0");
+ }
+}
diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs
new file mode 100644
index 0000000000..3139024e9d
--- /dev/null
+++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs
@@ -0,0 +1,11 @@
+using Volo.Abp.Testing;
+
+namespace Volo.Abp.OperationRateLimit;
+
+public class OperationRateLimitTestBase : AbpIntegratedTest
+{
+ protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
+ {
+ options.UseAutofac();
+ }
+}
diff --git a/nupkg/common.ps1 b/nupkg/common.ps1
index 6fbc34e80c..edc5374c28 100644
--- a/nupkg/common.ps1
+++ b/nupkg/common.ps1
@@ -237,6 +237,7 @@ $projects = (
"framework/src/Volo.Abp.Minify",
"framework/src/Volo.Abp.ObjectExtending",
"framework/src/Volo.Abp.ObjectMapping",
+ "framework/src/Volo.Abp.OperationRateLimit",
"framework/src/Volo.Abp.Quartz",
"framework/src/Volo.Abp.RabbitMQ",
"framework/src/Volo.Abp.RemoteServices",