From 6f157406e0fe637d3f608acdf24df84c2c42f883 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 12:15:20 +0800 Subject: [PATCH 01/15] Add Volo.Abp.OperationRateLimit module --- framework/Volo.Abp.slnx | 2 + .../AbpAspNetCoreAbstractionsModule.cs | 4 +- .../IClientIpAddressProvider.cs | 6 + .../NullClientIpAddressProvider.cs | 6 + .../HttpContextClientIpAddressProvider.cs | 36 + .../FodyWeavers.xml | 3 + .../Volo.Abp.OperationRateLimit.csproj | 32 + .../AbpOperationRateLimitErrorCodes.cs | 9 + .../AbpOperationRateLimitException.cs | 41 + .../AbpOperationRateLimitModule.cs | 42 + .../AbpOperationRateLimitOptions.cs | 20 + .../AbpOperationRateLimitResource.cs | 8 + .../DefaultOperationRateLimitFormatter.cs | 68 ++ ...DefaultOperationRateLimitPolicyProvider.cs | 34 + ...DistributedCacheOperationRateLimitStore.cs | 155 ++++ .../FixedWindowOperationRateLimitRule.cs | 134 ++++ .../IOperationRateLimitChecker.cs | 14 + .../IOperationRateLimitFormatter.cs | 8 + .../IOperationRateLimitPolicyProvider.cs | 11 + .../IOperationRateLimitRule.cs | 12 + .../IOperationRateLimitStore.cs | 13 + .../OperationRateLimit/Localization/ar.json | 17 + .../OperationRateLimit/Localization/cs.json | 17 + .../OperationRateLimit/Localization/de.json | 17 + .../OperationRateLimit/Localization/el.json | 17 + .../Localization/en-GB.json | 17 + .../OperationRateLimit/Localization/en.json | 17 + .../OperationRateLimit/Localization/es.json | 17 + .../OperationRateLimit/Localization/fa.json | 17 + .../OperationRateLimit/Localization/fi.json | 17 + .../OperationRateLimit/Localization/fr.json | 17 + .../OperationRateLimit/Localization/hi.json | 17 + .../OperationRateLimit/Localization/hr.json | 17 + .../OperationRateLimit/Localization/hu.json | 17 + .../OperationRateLimit/Localization/is.json | 17 + .../OperationRateLimit/Localization/it.json | 17 + .../OperationRateLimit/Localization/nl.json | 17 + .../Localization/pl-PL.json | 17 + .../Localization/pt-BR.json | 17 + .../Localization/ro-RO.json | 17 + .../OperationRateLimit/Localization/ru.json | 17 + .../OperationRateLimit/Localization/sk.json | 17 + .../OperationRateLimit/Localization/sl.json | 17 + .../OperationRateLimit/Localization/sv.json | 17 + .../OperationRateLimit/Localization/tr.json | 17 + .../OperationRateLimit/Localization/vi.json | 17 + .../Localization/zh-Hans.json | 17 + .../OperationRateLimitCacheItem.cs | 14 + .../OperationRateLimitChecker.cs | 258 +++++++ .../OperationRateLimitContext.cs | 32 + .../OperationRateLimitPartitionType.cs | 12 + .../OperationRateLimitPolicy.cs | 15 + .../OperationRateLimitPolicyBuilder.cs | 97 +++ .../OperationRateLimitResult.cs | 24 + .../OperationRateLimitRuleBuilder.cs | 155 ++++ .../OperationRateLimitRuleDefinition.cs | 16 + .../OperationRateLimitRuleResult.cs | 18 + .../OperationRateLimitStoreResult.cs | 14 + .../Volo.Abp.OperationRateLimit.Tests.csproj | 18 + .../AbpOperationRateLimitException_Tests.cs | 99 +++ ...pOperationRateLimitPhase2RaceTestModule.cs | 68 ++ .../AbpOperationRateLimitTestModule.cs | 179 +++++ ...butedCacheOperationRateLimitStore_Tests.cs | 135 ++++ .../OperationRateLimitCheckerFixes_Tests.cs | 144 ++++ .../OperationRateLimitChecker_Tests.cs | 731 ++++++++++++++++++ ...ationRateLimitFrontendIntegration_Tests.cs | 408 ++++++++++ .../OperationRateLimitMultiTenant_Tests.cs | 106 +++ .../OperationRateLimitPolicyBuilder_Tests.cs | 209 +++++ .../OperationRateLimitTestBase.cs | 11 + nupkg/common.ps1 | 1 + 70 files changed, 3863 insertions(+), 1 deletion(-) create mode 100644 framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/IClientIpAddressProvider.cs create mode 100644 framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/NullClientIpAddressProvider.cs create mode 100644 framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/ClientIpAddress/HttpContextClientIpAddressProvider.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/FodyWeavers.xml create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitFormatter.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitPolicyProvider.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitRule.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitStore.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ar.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/cs.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/de.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/el.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en-GB.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/es.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fa.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fi.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fr.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hi.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hr.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hu.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/is.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/it.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/nl.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pl-PL.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pt-BR.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ro-RO.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ru.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sk.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sl.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sv.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/tr.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/vi.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/zh-Hans.json create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitCacheItem.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleDefinition.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitPhase2RaceTestModule.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder_Tests.cs create mode 100644 framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs 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", From 62fcb628cfd55c2bb3e689d9060f8ecd8934022d Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 13:14:43 +0800 Subject: [PATCH 02/15] fix: change IClientIpAddressProvider registration from transient to singleton --- .../Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2c27ea864a..7d72a0bfa8 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 @@ -12,6 +12,6 @@ public class AbpAspNetCoreAbstractionsModule : AbpModule { context.Services.AddSingleton(); context.Services.AddSingleton(); - context.Services.AddTransient(); + context.Services.AddSingleton(); } } From 4c3448be325d904d85bcb4faaf45d9a678c23dca Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 13:46:29 +0800 Subject: [PATCH 03/15] refactor: improve operation rate limit rule handling and add multi-tenancy support in policy builder tests --- .../FixedWindowOperationRateLimitRule.cs | 4 +- .../OperationRateLimitPolicyBuilder.cs | 15 ++++-- .../OperationRateLimitRuleBuilder.cs | 23 +++++---- .../OperationRateLimitPolicyBuilder_Tests.cs | 48 +++++++++++++++++++ 4 files changed, 71 insertions(+), 19 deletions(-) diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs index 737e957788..a13d00c087 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs +++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs @@ -24,7 +24,7 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule IOperationRateLimitStore store, ICurrentUser currentUser, ICurrentTenant currentTenant, - IClientIpAddressProvider clientInfoProvider) + IClientIpAddressProvider clientIpAddressProvider) { PolicyName = policyName; RuleIndex = ruleIndex; @@ -32,7 +32,7 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule Store = store; CurrentUser = currentUser; CurrentTenant = currentTenant; - ClientIpAddressProvider = clientInfoProvider; + ClientIpAddressProvider = clientIpAddressProvider; } public virtual async Task AcquireAsync( diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs index 173af66758..a420b088fb 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs +++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs @@ -22,9 +22,12 @@ public class OperationRateLimitPolicyBuilder public OperationRateLimitPolicyBuilder AddRule( Action configure) { - var builder = new OperationRateLimitRuleBuilder(); + var builder = new OperationRateLimitRuleBuilder(this); configure(builder); - _rules.Add(builder.Build()); + if (!builder.IsCommitted) + { + _rules.Add(builder.Build()); + } return this; } @@ -74,15 +77,17 @@ public class OperationRateLimitPolicyBuilder } var duplicate = _rules - .GroupBy(r => (r.Duration, r.MaxCount, r.PartitionType)) + .Where(r => r.PartitionType != OperationRateLimitPartitionType.Custom) + .GroupBy(r => (r.Duration, r.MaxCount, r.PartitionType, r.IsMultiTenant)) .FirstOrDefault(g => g.Count() > 1); if (duplicate != null) { - var (duration, maxCount, partitionType) = duplicate.Key; + var (duration, maxCount, partitionType, isMultiTenant) = duplicate.Key; throw new AbpException( $"Operation rate limit policy '{_name}' has duplicate rules with the same " + - $"Duration ({duration}), MaxCount ({maxCount}), and PartitionType ({partitionType}). " + + $"Duration ({duration}), MaxCount ({maxCount}), PartitionType ({partitionType}), " + + $"and IsMultiTenant ({isMultiTenant}). " + "Each rule in a policy must have a unique combination of these properties."); } diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs index 98dfd65f92..6cf8a89921 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs +++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs @@ -4,16 +4,14 @@ namespace Volo.Abp.OperationRateLimit; public class OperationRateLimitRuleBuilder { - private readonly OperationRateLimitPolicyBuilder? _policyBuilder; + private readonly OperationRateLimitPolicyBuilder _policyBuilder; private TimeSpan _duration; private int _maxCount; private OperationRateLimitPartitionType? _partitionType; private Func? _customPartitionKeyResolver; private bool _isMultiTenant; - public OperationRateLimitRuleBuilder() - { - } + internal bool IsCommitted { get; private set; } internal OperationRateLimitRuleBuilder(OperationRateLimitPolicyBuilder policyBuilder) { @@ -41,7 +39,7 @@ public class OperationRateLimitRuleBuilder { _partitionType = OperationRateLimitPartitionType.Parameter; CommitToPolicyBuilder(); - return _policyBuilder!; + return _policyBuilder; } /// @@ -51,7 +49,7 @@ public class OperationRateLimitRuleBuilder { _partitionType = OperationRateLimitPartitionType.CurrentUser; CommitToPolicyBuilder(); - return _policyBuilder!; + return _policyBuilder; } /// @@ -61,7 +59,7 @@ public class OperationRateLimitRuleBuilder { _partitionType = OperationRateLimitPartitionType.CurrentTenant; CommitToPolicyBuilder(); - return _policyBuilder!; + return _policyBuilder; } /// @@ -71,7 +69,7 @@ public class OperationRateLimitRuleBuilder { _partitionType = OperationRateLimitPartitionType.ClientIp; CommitToPolicyBuilder(); - return _policyBuilder!; + return _policyBuilder; } /// @@ -82,7 +80,7 @@ public class OperationRateLimitRuleBuilder { _partitionType = OperationRateLimitPartitionType.Email; CommitToPolicyBuilder(); - return _policyBuilder!; + return _policyBuilder; } /// @@ -93,7 +91,7 @@ public class OperationRateLimitRuleBuilder { _partitionType = OperationRateLimitPartitionType.PhoneNumber; CommitToPolicyBuilder(); - return _policyBuilder!; + return _policyBuilder; } /// @@ -105,12 +103,13 @@ public class OperationRateLimitRuleBuilder _partitionType = OperationRateLimitPartitionType.Custom; _customPartitionKeyResolver = Check.NotNull(keyResolver, nameof(keyResolver)); CommitToPolicyBuilder(); - return _policyBuilder!; + return _policyBuilder; } protected virtual void CommitToPolicyBuilder() { - _policyBuilder?.AddRuleDefinition(Build()); + _policyBuilder.AddRuleDefinition(Build()); + IsCommitted = true; } internal OperationRateLimitRuleDefinition Build() 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 index 1be970f4f4..76dac315ae 100644 --- 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 @@ -206,4 +206,52 @@ public class OperationRateLimitPolicyBuilder_Tests exception.Message.ShouldContain("maxCount >= 0"); } + + [Fact] + public void Should_Allow_Same_Rule_With_Different_MultiTenancy() + { + var options = new AbpOperationRateLimitOptions(); + + // Same Duration/MaxCount/PartitionType but different IsMultiTenant should be allowed + options.AddPolicy("MultiTenancyPolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .WithMultiTenancy() + .PartitionByParameter()); + }); + + var policy = options.Policies["MultiTenancyPolicy"]; + policy.Rules.Count.ShouldBe(2); + policy.Rules[0].IsMultiTenant.ShouldBeFalse(); + policy.Rules[1].IsMultiTenant.ShouldBeTrue(); + } + + [Fact] + public void Should_Allow_Multiple_Custom_Partition_Rules() + { + var options = new AbpOperationRateLimitOptions(); + + // Multiple custom partition rules with same Duration/MaxCount should be allowed + // because they may use different key resolvers + options.AddPolicy("MultiCustomPolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionBy(ctx => $"by-ip:{ctx.Parameter}")); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionBy(ctx => $"by-device:{ctx.Parameter}")); + }); + + var policy = options.Policies["MultiCustomPolicy"]; + policy.Rules.Count.ShouldBe(2); + policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.Custom); + policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitPartitionType.Custom); + } } From c67db4ff15db318868dcdc84f4192da4c4e9d0eb Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 14:19:29 +0800 Subject: [PATCH 04/15] refactor: remove IClientIpAddressProvider and replace with IWebClientInfoProvider in operation rate limiting --- .../AbpAspNetCoreAbstractionsModule.cs | 2 -- .../IClientIpAddressProvider.cs | 6 ---- .../NullClientIpAddressProvider.cs | 6 ---- .../HttpContextClientIpAddressProvider.cs | 36 ------------------- .../FixedWindowOperationRateLimitRule.cs | 12 +++---- .../OperationRateLimitChecker.cs | 10 +++--- .../OperationRateLimitRuleBuilder.cs | 2 +- .../AbpOperationRateLimitTestModule.cs | 8 ++--- 8 files changed, 16 insertions(+), 66 deletions(-) delete mode 100644 framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/IClientIpAddressProvider.cs delete mode 100644 framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/NullClientIpAddressProvider.cs delete mode 100644 framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/ClientIpAddress/HttpContextClientIpAddressProvider.cs 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 7d72a0bfa8..603a578ef4 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,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Volo.Abp.AspNetCore.ClientIpAddress; using Volo.Abp.AspNetCore.VirtualFileSystem; using Volo.Abp.AspNetCore.WebClientInfo; using Volo.Abp.Modularity; @@ -12,6 +11,5 @@ public class AbpAspNetCoreAbstractionsModule : AbpModule { context.Services.AddSingleton(); context.Services.AddSingleton(); - context.Services.AddSingleton(); } } 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 deleted file mode 100644 index 6318ec0989..0000000000 --- a/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/IClientIpAddressProvider.cs +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index f1dbcc903e..0000000000 --- a/framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/NullClientIpAddressProvider.cs +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index fa0a252e3c..0000000000 --- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/ClientIpAddress/HttpContextClientIpAddressProvider.cs +++ /dev/null @@ -1,36 +0,0 @@ -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/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs index a13d00c087..3b46cf3c7b 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs +++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs @@ -1,5 +1,5 @@ using System.Threading.Tasks; -using Volo.Abp.AspNetCore.ClientIpAddress; +using Volo.Abp.AspNetCore.WebClientInfo; using Volo.Abp.MultiTenancy; using Volo.Abp.Users; @@ -15,7 +15,7 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule protected IOperationRateLimitStore Store { get; } protected ICurrentUser CurrentUser { get; } protected ICurrentTenant CurrentTenant { get; } - protected IClientIpAddressProvider ClientIpAddressProvider { get; } + protected IWebClientInfoProvider WebClientInfoProvider { get; } public FixedWindowOperationRateLimitRule( string policyName, @@ -24,7 +24,7 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule IOperationRateLimitStore store, ICurrentUser currentUser, ICurrentTenant currentTenant, - IClientIpAddressProvider clientIpAddressProvider) + IWebClientInfoProvider webClientInfoProvider) { PolicyName = policyName; RuleIndex = ruleIndex; @@ -32,7 +32,7 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule Store = store; CurrentUser = currentUser; CurrentTenant = currentTenant; - ClientIpAddressProvider = clientIpAddressProvider; + WebClientInfoProvider = webClientInfoProvider; } public virtual async Task AcquireAsync( @@ -78,10 +78,10 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule CurrentTenant.Id?.ToString() ?? HostTenantKey, OperationRateLimitPartitionType.ClientIp => - ClientIpAddressProvider.ClientIpAddress + WebClientInfoProvider.ClientIpAddress ?? throw new AbpException( $"Client IP address could not be determined. Policy '{PolicyName}' requires PartitionByClientIp. " + - "Ensure IClientIpAddressProvider is properly configured."), + "Ensure IWebClientInfoProvider is properly configured."), OperationRateLimitPartitionType.Email => context.Parameter diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs index 98965c445f..9240d096b7 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs +++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs @@ -4,7 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Volo.Abp.AspNetCore.ClientIpAddress; +using Volo.Abp.AspNetCore.WebClientInfo; using Volo.Abp.DependencyInjection; using Volo.Abp.MultiTenancy; using Volo.Abp.Users; @@ -19,7 +19,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD protected IOperationRateLimitStore Store { get; } protected ICurrentUser CurrentUser { get; } protected ICurrentTenant CurrentTenant { get; } - protected IClientIpAddressProvider ClientIpAddressProvider { get; } + protected IWebClientInfoProvider WebClientInfoProvider { get; } public OperationRateLimitChecker( IOptions options, @@ -28,7 +28,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD IOperationRateLimitStore store, ICurrentUser currentUser, ICurrentTenant currentTenant, - IClientIpAddressProvider clientIpAddressProvider) + IWebClientInfoProvider webClientInfoProvider) { Options = options.Value; PolicyProvider = policyProvider; @@ -36,7 +36,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD Store = store; CurrentUser = currentUser; CurrentTenant = currentTenant; - ClientIpAddressProvider = clientIpAddressProvider; + WebClientInfoProvider = webClientInfoProvider; } public virtual async Task CheckAsync(string policyName, OperationRateLimitContext? context = null) @@ -162,7 +162,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD Store, CurrentUser, CurrentTenant, - ClientIpAddressProvider)); + WebClientInfoProvider)); } foreach (var customRuleType in policy.CustomRuleTypes) diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs index 6cf8a89921..2908f9a538 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs +++ b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs @@ -63,7 +63,7 @@ public class OperationRateLimitRuleBuilder } /// - /// Auto resolve from IClientIpAddressProvider.ClientIpAddress. + /// Auto resolve from IWebClientInfoProvider.ClientIpAddress. /// public OperationRateLimitPolicyBuilder PartitionByClientIp() { 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 index 13a9a3a4f5..45cf7320e1 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs +++ b/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs @@ -1,7 +1,7 @@ using System; using Microsoft.Extensions.DependencyInjection; using NSubstitute; -using Volo.Abp.AspNetCore.ClientIpAddress; +using Volo.Abp.AspNetCore.WebClientInfo; using Volo.Abp.Autofac; using Volo.Abp.ExceptionHandling; using Volo.Abp.Modularity; @@ -18,9 +18,9 @@ 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); + var mockWebClientInfoProvider = Substitute.For(); + mockWebClientInfoProvider.ClientIpAddress.Returns("127.0.0.1"); + context.Services.AddSingleton(mockWebClientInfoProvider); Configure(options => { From b92eda2419347479af162edcd98f80bdab7e9cc6 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 14:37:56 +0800 Subject: [PATCH 05/15] Rename Volo.Abp.OperationRateLimit to Volo.Abp.OperationRateLimiting --- framework/Volo.Abp.slnx | 4 +- .../AbpOperationRateLimitResource.cs | 8 -- .../IOperationRateLimitChecker.cs | 14 -- .../IOperationRateLimitFormatter.cs | 8 -- .../IOperationRateLimitPolicyProvider.cs | 11 -- .../IOperationRateLimitRule.cs | 12 -- .../IOperationRateLimitStore.cs | 13 -- .../OperationRateLimitRuleDefinition.cs | 16 --- .../FodyWeavers.xml | 0 .../Volo.Abp.OperationRateLimiting.csproj} | 8 +- .../AbpOperationRateLimitingErrorCodes.cs} | 6 +- .../AbpOperationRateLimitingModule.cs} | 14 +- .../AbpOperationRateLimitingOptions.cs} | 10 +- .../AbpOperationRateLimitingResource.cs | 8 ++ .../Checker/IOperationRateLimitingChecker.cs | 14 ++ .../Checker/OperationRateLimitingChecker.cs} | 64 ++++----- .../Checker/OperationRateLimitingContext.cs} | 6 +- .../Checker/OperationRateLimitingResult.cs} | 6 +- .../OperationRateLimitingRuleResult.cs} | 4 +- .../AbpOperationRateLimitingException.cs} | 12 +- .../DefaultOperationRateLimitingFormatter.cs} | 12 +- .../IOperationRateLimitingFormatter.cs | 8 ++ .../Localization/ar.json | 2 +- .../Localization/cs.json | 2 +- .../Localization/de.json | 2 +- .../Localization/el.json | 2 +- .../Localization/en-GB.json | 2 +- .../Localization/en.json | 2 +- .../Localization/es.json | 2 +- .../Localization/fa.json | 2 +- .../Localization/fi.json | 2 +- .../Localization/fr.json | 2 +- .../Localization/hi.json | 2 +- .../Localization/hr.json | 2 +- .../Localization/hu.json | 2 +- .../Localization/is.json | 2 +- .../Localization/it.json | 2 +- .../Localization/nl.json | 2 +- .../Localization/pl-PL.json | 2 +- .../Localization/pt-BR.json | 2 +- .../Localization/ro-RO.json | 2 +- .../Localization/ru.json | 2 +- .../Localization/sk.json | 2 +- .../Localization/sl.json | 2 +- .../Localization/sv.json | 2 +- .../Localization/tr.json | 2 +- .../Localization/vi.json | 2 +- .../Localization/zh-Hans.json | 2 +- ...ultOperationRateLimitingPolicyProvider.cs} | 14 +- .../IOperationRateLimitingPolicyProvider.cs | 11 ++ .../OperationRateLimitingPartitionType.cs} | 4 +- .../Policies/OperationRateLimitingPolicy.cs} | 6 +- .../OperationRateLimitingPolicyBuilder.cs} | 34 ++--- .../OperationRateLimitingRuleBuilder.cs} | 52 +++---- .../OperationRateLimitingRuleDefinition.cs | 16 +++ .../FixedWindowOperationRateLimitingRule.cs} | 46 +++---- .../Rules/IOperationRateLimitingRule.cs | 12 ++ ...ributedCacheOperationRateLimitingStore.cs} | 38 ++--- .../Store/IOperationRateLimitingStore.cs | 13 ++ .../Store/OperationRateLimitingCacheItem.cs} | 6 +- .../OperationRateLimitingStoreResult.cs} | 4 +- ...lo.Abp.OperationRateLimiting.Tests.csproj} | 2 +- ...bpOperationRateLimitingException_Tests.cs} | 26 ++-- ...rationRateLimitingPhase2RaceTestModule.cs} | 20 +-- .../AbpOperationRateLimitingTestModule.cs} | 8 +- ...dCacheOperationRateLimitingStore_Tests.cs} | 10 +- ...perationRateLimitingCheckerFixes_Tests.cs} | 36 ++--- .../OperationRateLimitingChecker_Tests.cs} | 130 +++++++++--------- ...nRateLimitingFrontendIntegration_Tests.cs} | 38 ++--- ...OperationRateLimitingMultiTenant_Tests.cs} | 28 ++-- ...erationRateLimitingPolicyBuilder_Tests.cs} | 54 ++++---- .../OperationRateLimitingTestBase.cs} | 4 +- 72 files changed, 461 insertions(+), 461 deletions(-) delete mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs delete mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs delete mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitFormatter.cs delete mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitPolicyProvider.cs delete mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitRule.cs delete mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitStore.cs delete mode 100644 framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleDefinition.cs rename framework/src/{Volo.Abp.OperationRateLimit => Volo.Abp.OperationRateLimiting}/FodyWeavers.xml (100%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj => Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj} (81%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs} (58%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs} (67%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs} (54%) create mode 100644 framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs create mode 100644 framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs} (78%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs} (88%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs} (72%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs} (77%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs} (74%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs} (84%) create mode 100644 framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/ar.json (82%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/cs.json (85%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/de.json (82%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/el.json (79%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/en-GB.json (83%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/en.json (83%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/es.json (80%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/fa.json (76%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/fi.json (85%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/fr.json (81%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/hi.json (78%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/hr.json (81%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/hu.json (81%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/is.json (84%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/it.json (82%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/nl.json (82%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/pl-PL.json (82%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/pt-BR.json (81%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/ro-RO.json (82%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/ru.json (81%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/sk.json (83%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/sl.json (83%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/sv.json (81%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/tr.json (81%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/vi.json (79%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting}/Localization/zh-Hans.json (83%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs} (54%) create mode 100644 framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs} (55%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs} (56%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs} (68%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs} (64%) create mode 100644 framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs} (74%) create mode 100644 framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs} (75%) create mode 100644 framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitCacheItem.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs} (59%) rename framework/src/{Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs => Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs} (68%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj => Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj} (92%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs} (68%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitPhase2RaceTestModule.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs} (69%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs} (97%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs} (92%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs} (82%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs} (84%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs} (91%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs} (75%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder_Tests.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs} (85%) rename framework/test/{Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs => Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs} (54%) diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx index b5d1f87166..1e36f1d212 100644 --- a/framework/Volo.Abp.slnx +++ b/framework/Volo.Abp.slnx @@ -169,7 +169,7 @@ - + @@ -257,6 +257,6 @@ - + diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs deleted file mode 100644 index d180b89838..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Volo.Abp.Localization; - -namespace Volo.Abp.OperationRateLimit; - -[LocalizationResourceName("AbpOperationRateLimit")] -public class AbpOperationRateLimitResource -{ -} diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs deleted file mode 100644 index 8cccb0d51f..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 8fd61d3925..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitFormatter.cs +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 504b8da745..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitPolicyProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index b7c83265f2..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitRule.cs +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index c6c736b45c..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitStore.cs +++ /dev/null @@ -1,13 +0,0 @@ -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/OperationRateLimitRuleDefinition.cs b/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleDefinition.cs deleted file mode 100644 index 856fb299fa..0000000000 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleDefinition.cs +++ /dev/null @@ -1,16 +0,0 @@ -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/FodyWeavers.xml b/framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml similarity index 100% rename from framework/src/Volo.Abp.OperationRateLimit/FodyWeavers.xml rename to framework/src/Volo.Abp.OperationRateLimiting/FodyWeavers.xml diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj b/framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj similarity index 81% rename from framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj rename to framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj index f550b7c7cf..ffac7ef34e 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo.Abp.OperationRateLimiting.csproj @@ -7,8 +7,8 @@ netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0 enable Nullable - Volo.Abp.OperationRateLimit - Volo.Abp.OperationRateLimit + Volo.Abp.OperationRateLimiting + Volo.Abp.OperationRateLimiting $(AssetTargetFallback);portable-net45+win8+wp8+wpa81; false false @@ -17,8 +17,8 @@ - - + + diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs similarity index 58% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs index 783b52e3f2..5ff8d88b5a 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs @@ -1,9 +1,9 @@ -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public static class AbpOperationRateLimitErrorCodes +public static class AbpOperationRateLimitingErrorCodes { /// /// Default error code for rate limit exceeded. /// - public const string ExceedLimit = "Volo.Abp.OperationRateLimit:010001"; + public const string ExceedLimit = "Volo.Abp.OperationRateLimiting:010001"; } diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs similarity index 67% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs index ac74e4c80c..0462a285a5 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingModule.cs @@ -7,7 +7,7 @@ using Volo.Abp.Modularity; using Volo.Abp.Security; using Volo.Abp.VirtualFileSystem; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; [DependsOn( typeof(AbpCachingModule), @@ -16,27 +16,27 @@ namespace Volo.Abp.OperationRateLimit; typeof(AbpAspNetCoreAbstractionsModule), typeof(AbpDistributedLockingAbstractionsModule) )] -public class AbpOperationRateLimitModule : AbpModule +public class AbpOperationRateLimitingModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { Configure(options => { - options.FileSets.AddEmbedded(); + options.FileSets.AddEmbedded(); }); Configure(options => { options.Resources - .Add("en") - .AddVirtualJson("/Volo/Abp/OperationRateLimit/Localization"); + .Add("en") + .AddVirtualJson("/Volo/Abp/OperationRateLimiting/Localization"); }); Configure(options => { options.MapCodeNamespace( - "Volo.Abp.OperationRateLimit", - typeof(AbpOperationRateLimitResource)); + "Volo.Abp.OperationRateLimiting", + typeof(AbpOperationRateLimitingResource)); }); } } diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs similarity index 54% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs index 5ed35d4de7..711f2b17d0 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingOptions.cs @@ -1,19 +1,19 @@ using System; using System.Collections.Generic; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class AbpOperationRateLimitOptions +public class AbpOperationRateLimitingOptions { public bool IsEnabled { get; set; } = true; public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(5); - public Dictionary Policies { get; } = new(); + public Dictionary Policies { get; } = new(); - public void AddPolicy(string name, Action configure) + public void AddPolicy(string name, Action configure) { - var builder = new OperationRateLimitPolicyBuilder(name); + var builder = new OperationRateLimitingPolicyBuilder(name); configure(builder); Policies[name] = builder.Build(); } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs new file mode 100644 index 0000000000..e4f93d97d0 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingResource.cs @@ -0,0 +1,8 @@ +using Volo.Abp.Localization; + +namespace Volo.Abp.OperationRateLimiting; + +[LocalizationResourceName("AbpOperationRateLimiting")] +public class AbpOperationRateLimitingResource +{ +} diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs new file mode 100644 index 0000000000..2220c241e8 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/IOperationRateLimitingChecker.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public interface IOperationRateLimitingChecker +{ + Task CheckAsync(string policyName, OperationRateLimitingContext? context = null); + + Task IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null); + + Task GetStatusAsync(string policyName, OperationRateLimitingContext? context = null); + + Task ResetAsync(string policyName, OperationRateLimitingContext? context = null); +} diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs similarity index 78% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs index 9240d096b7..3b3006b248 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs @@ -9,23 +9,23 @@ using Volo.Abp.DependencyInjection; using Volo.Abp.MultiTenancy; using Volo.Abp.Users; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientDependency +public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITransientDependency { - protected AbpOperationRateLimitOptions Options { get; } - protected IOperationRateLimitPolicyProvider PolicyProvider { get; } + protected AbpOperationRateLimitingOptions Options { get; } + protected IOperationRateLimitingPolicyProvider PolicyProvider { get; } protected IServiceProvider ServiceProvider { get; } - protected IOperationRateLimitStore Store { get; } + protected IOperationRateLimitingStore Store { get; } protected ICurrentUser CurrentUser { get; } protected ICurrentTenant CurrentTenant { get; } protected IWebClientInfoProvider WebClientInfoProvider { get; } - public OperationRateLimitChecker( - IOptions options, - IOperationRateLimitPolicyProvider policyProvider, + public OperationRateLimitingChecker( + IOptions options, + IOperationRateLimitingPolicyProvider policyProvider, IServiceProvider serviceProvider, - IOperationRateLimitStore store, + IOperationRateLimitingStore store, ICurrentUser currentUser, ICurrentTenant currentTenant, IWebClientInfoProvider webClientInfoProvider) @@ -39,7 +39,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD WebClientInfoProvider = webClientInfoProvider; } - public virtual async Task CheckAsync(string policyName, OperationRateLimitContext? context = null) + public virtual async Task CheckAsync(string policyName, OperationRateLimitingContext? context = null) { if (!Options.IsEnabled) { @@ -52,7 +52,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD // 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(); + var checkResults = new List(); foreach (var rule in rules) { checkResults.Add(await rule.CheckAsync(context)); @@ -68,7 +68,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD // 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(); + var incrementResults = new List(); foreach (var rule in rules) { incrementResults.Add(await rule.AcquireAsync(context)); @@ -81,7 +81,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD } } - public virtual async Task IsAllowedAsync(string policyName, OperationRateLimitContext? context = null) + public virtual async Task IsAllowedAsync(string policyName, OperationRateLimitingContext? context = null) { if (!Options.IsEnabled) { @@ -104,11 +104,11 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD return true; } - public virtual async Task GetStatusAsync(string policyName, OperationRateLimitContext? context = null) + public virtual async Task GetStatusAsync(string policyName, OperationRateLimitingContext? context = null) { if (!Options.IsEnabled) { - return new OperationRateLimitResult + return new OperationRateLimitingResult { IsAllowed = true, RemainingCount = int.MaxValue, @@ -120,7 +120,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD context = EnsureContext(context); var policy = await PolicyProvider.GetAsync(policyName); var rules = CreateRules(policy); - var ruleResults = new List(); + var ruleResults = new List(); foreach (var rule in rules) { @@ -130,7 +130,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD return AggregateResults(ruleResults, policy); } - public virtual async Task ResetAsync(string policyName, OperationRateLimitContext? context = null) + public virtual async Task ResetAsync(string policyName, OperationRateLimitingContext? context = null) { context = EnsureContext(context); var policy = await PolicyProvider.GetAsync(policyName); @@ -142,20 +142,20 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD } } - protected virtual OperationRateLimitContext EnsureContext(OperationRateLimitContext? context) + protected virtual OperationRateLimitingContext EnsureContext(OperationRateLimitingContext? context) { - context ??= new OperationRateLimitContext(); + context ??= new OperationRateLimitingContext(); context.ServiceProvider = ServiceProvider; return context; } - protected virtual List CreateRules(OperationRateLimitPolicy policy) + protected virtual List CreateRules(OperationRateLimitingPolicy policy) { - var rules = new List(); + var rules = new List(); for (var i = 0; i < policy.Rules.Count; i++) { - rules.Add(new FixedWindowOperationRateLimitRule( + rules.Add(new FixedWindowOperationRateLimitingRule( policy.Name, i, policy.Rules[i], @@ -167,15 +167,15 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD foreach (var customRuleType in policy.CustomRuleTypes) { - rules.Add((IOperationRateLimitRule)ServiceProvider.GetRequiredService(customRuleType)); + rules.Add((IOperationRateLimitingRule)ServiceProvider.GetRequiredService(customRuleType)); } return rules; } - protected virtual OperationRateLimitResult AggregateResults( - List ruleResults, - OperationRateLimitPolicy policy) + protected virtual OperationRateLimitingResult AggregateResults( + List ruleResults, + OperationRateLimitingPolicy policy) { var isAllowed = ruleResults.All(r => r.IsAllowed); var mostRestrictive = ruleResults @@ -183,7 +183,7 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD .ThenByDescending(r => r.RetryAfter ?? TimeSpan.Zero) .First(); - return new OperationRateLimitResult + return new OperationRateLimitingResult { IsAllowed = isAllowed, RemainingCount = mostRestrictive.RemainingCount, @@ -201,13 +201,13 @@ public class OperationRateLimitChecker : IOperationRateLimitChecker, ITransientD } protected virtual void ThrowRateLimitException( - OperationRateLimitPolicy policy, - OperationRateLimitResult result, - OperationRateLimitContext context) + OperationRateLimitingPolicy policy, + OperationRateLimitingResult result, + OperationRateLimitingContext context) { - var formatter = context.ServiceProvider.GetRequiredService(); + var formatter = context.ServiceProvider.GetRequiredService(); - var exception = new AbpOperationRateLimitException( + var exception = new AbpOperationRateLimitingException( policy.Name, result, policy.ErrorCode); diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs similarity index 88% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs index d3e706a9ff..3f408b8240 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitContext +public class OperationRateLimitingContext { /// /// Optional parameter passed by the caller. @@ -14,7 +14,7 @@ public class OperationRateLimitContext public string? Parameter { get; set; } /// - /// Additional properties that can be read by custom implementations + /// 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(); diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs similarity index 72% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs index 83d77d21af..6659947099 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingResult.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitResult +public class OperationRateLimitingResult { public bool IsAllowed { get; set; } @@ -20,5 +20,5 @@ public class OperationRateLimitResult /// /// Detailed results per rule (for composite policies). /// - public List? RuleResults { get; set; } + public List? RuleResults { get; set; } } diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs similarity index 77% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs index efc0fd8548..e05e6bf4fb 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs @@ -1,8 +1,8 @@ using System; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitRuleResult +public class OperationRateLimitingRuleResult { public string RuleName { get; set; } = default!; diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs similarity index 74% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs index 852b506e46..9872d26e76 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs @@ -1,21 +1,21 @@ using System; using Volo.Abp.ExceptionHandling; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class AbpOperationRateLimitException : BusinessException, IHasHttpStatusCode +public class AbpOperationRateLimitingException : BusinessException, IHasHttpStatusCode { public string PolicyName { get; } - public OperationRateLimitResult Result { get; } + public OperationRateLimitingResult Result { get; } public int HttpStatusCode => 429; - public AbpOperationRateLimitException( + public AbpOperationRateLimitingException( string policyName, - OperationRateLimitResult result, + OperationRateLimitingResult result, string? errorCode = null) - : base(code: errorCode ?? AbpOperationRateLimitErrorCodes.ExceedLimit) + : base(code: errorCode ?? AbpOperationRateLimitingErrorCodes.ExceedLimit) { PolicyName = policyName; Result = result; diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs similarity index 84% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs index 7a506e5d5e..e69dd7082b 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/DefaultOperationRateLimitingFormatter.cs @@ -2,15 +2,15 @@ using System; using Microsoft.Extensions.Localization; using Volo.Abp.DependencyInjection; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class DefaultOperationRateLimitFormatter - : IOperationRateLimitFormatter, ITransientDependency +public class DefaultOperationRateLimitingFormatter + : IOperationRateLimitingFormatter, ITransientDependency { - protected IStringLocalizer Localizer { get; } + protected IStringLocalizer Localizer { get; } - public DefaultOperationRateLimitFormatter( - IStringLocalizer localizer) + public DefaultOperationRateLimitingFormatter( + IStringLocalizer localizer) { Localizer = localizer; } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs new file mode 100644 index 0000000000..7e6370e215 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Formatting/IOperationRateLimitingFormatter.cs @@ -0,0 +1,8 @@ +using System; + +namespace Volo.Abp.OperationRateLimiting; + +public interface IOperationRateLimitingFormatter +{ + string Format(TimeSpan duration); +} diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ar.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json similarity index 82% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ar.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json index 8e2cf120cd..46b937bb99 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ar.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json @@ -1,7 +1,7 @@ { "culture": "ar", "texts": { - "Volo.Abp.OperationRateLimit:010001": "تم تجاوز حد معدل العملية. يمكنك المحاولة مرة أخرى بعد {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "تم تجاوز حد معدل العملية. يمكنك المحاولة مرة أخرى بعد {RetryAfter}.", "RetryAfter:Years": "{0} سنة/سنوات", "RetryAfter:YearsAndMonths": "{0} سنة/سنوات و {1} شهر/أشهر", "RetryAfter:Months": "{0} شهر/أشهر", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/cs.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json similarity index 85% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/cs.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json index d1db9eb671..205d229c10 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/cs.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json @@ -1,7 +1,7 @@ { "culture": "cs", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Překročen limit rychlosti operace. Můžete to zkusit znovu za {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Překročen limit rychlosti operace. Můžete to zkusit znovu za {RetryAfter}.", "RetryAfter:Years": "{0} rok(y/let)", "RetryAfter:YearsAndMonths": "{0} rok(y/let) a {1} měsíc(e/ů)", "RetryAfter:Months": "{0} měsíc(e/ů)", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/de.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json similarity index 82% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/de.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json index 5fcca27604..3313e0156d 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/de.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json @@ -1,7 +1,7 @@ { "culture": "de", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Betriebsratenlimit überschritten. Sie können es nach {RetryAfter} erneut versuchen.", + "Volo.Abp.OperationRateLimiting:010001": "Betriebsratenlimit überschritten. Sie können es nach {RetryAfter} erneut versuchen.", "RetryAfter:Years": "{0} Jahr(e)", "RetryAfter:YearsAndMonths": "{0} Jahr(e) und {1} Monat(e)", "RetryAfter:Months": "{0} Monat(e)", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/el.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json similarity index 79% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/el.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json index f5d5ba20b7..53359d6fa8 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/el.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json @@ -1,7 +1,7 @@ { "culture": "el", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Υπέρβαση ορίου ρυθμού λειτουργίας. Μπορείτε να δοκιμάσετε ξανά μετά από {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Υπέρβαση ορίου ρυθμού λειτουργίας. Μπορείτε να δοκιμάσετε ξανά μετά από {RetryAfter}.", "RetryAfter:Years": "{0} έτος/η", "RetryAfter:YearsAndMonths": "{0} έτος/η και {1} μήνας/ες", "RetryAfter:Months": "{0} μήνας/ες", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en-GB.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json similarity index 83% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en-GB.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json index 4dad40dd1a..de3b1de3a3 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en-GB.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json @@ -1,7 +1,7 @@ { "culture": "en-GB", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.", "RetryAfter:Years": "{0} year(s)", "RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)", "RetryAfter:Months": "{0} month(s)", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json similarity index 83% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json index a962e3d9c9..0abcff4386 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json @@ -1,7 +1,7 @@ { "culture": "en", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Operation rate limit exceeded. You can try again after {RetryAfter}.", "RetryAfter:Years": "{0} year(s)", "RetryAfter:YearsAndMonths": "{0} year(s) and {1} month(s)", "RetryAfter:Months": "{0} month(s)", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/es.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json similarity index 80% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/es.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json index fa5ce16176..299ccd0fa2 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/es.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json @@ -1,7 +1,7 @@ { "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}.", + "Volo.Abp.OperationRateLimiting:010001": "Se ha excedido el límite de tasa de operación. Puede intentarlo de nuevo después de {RetryAfter}.", "RetryAfter:Years": "{0} año(s)", "RetryAfter:YearsAndMonths": "{0} año(s) y {1} mes(es)", "RetryAfter:Months": "{0} mes(es)", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fa.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json similarity index 76% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fa.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json index 9bd5fa51c5..09c7f21376 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fa.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json @@ -1,7 +1,7 @@ { "culture": "fa", "texts": { - "Volo.Abp.OperationRateLimit:010001": "محدودیت نرخ عملیات فراتر رفته است. می‌توانید بعد از {RetryAfter} دوباره تلاش کنید.", + "Volo.Abp.OperationRateLimiting:010001": "محدودیت نرخ عملیات فراتر رفته است. می‌توانید بعد از {RetryAfter} دوباره تلاش کنید.", "RetryAfter:Years": "{0} سال", "RetryAfter:YearsAndMonths": "{0} سال و {1} ماه", "RetryAfter:Months": "{0} ماه", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json similarity index 85% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fi.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json index 91d5a799e2..de379d5989 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fi.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json @@ -1,7 +1,7 @@ { "culture": "fi", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Toiminnon nopeusraja ylitetty. Voit yrittää uudelleen {RetryAfter} kuluttua.", + "Volo.Abp.OperationRateLimiting:010001": "Toiminnon nopeusraja ylitetty. Voit yrittää uudelleen {RetryAfter} kuluttua.", "RetryAfter:Years": "{0} vuosi/vuotta", "RetryAfter:YearsAndMonths": "{0} vuosi/vuotta ja {1} kuukausi/kuukautta", "RetryAfter:Months": "{0} kuukausi/kuukautta", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json similarity index 81% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fr.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json index ce1b2a5da5..b531b036f0 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fr.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json @@ -1,7 +1,7 @@ { "culture": "fr", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Limite de taux d'opération dépassée. Vous pouvez réessayer après {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Limite de taux d'opération dépassée. Vous pouvez réessayer après {RetryAfter}.", "RetryAfter:Years": "{0} an(s)", "RetryAfter:YearsAndMonths": "{0} an(s) et {1} mois", "RetryAfter:Months": "{0} mois", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json similarity index 78% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hi.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json index c23d01b4e1..6a53a5106e 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hi.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json @@ -1,7 +1,7 @@ { "culture": "hi", "texts": { - "Volo.Abp.OperationRateLimit:010001": "ऑपरेशन दर सीमा पार हो गई। आप {RetryAfter} के बाद पुनः प्रयास कर सकते हैं।", + "Volo.Abp.OperationRateLimiting:010001": "ऑपरेशन दर सीमा पार हो गई। आप {RetryAfter} के बाद पुनः प्रयास कर सकते हैं।", "RetryAfter:Years": "{0} वर्ष", "RetryAfter:YearsAndMonths": "{0} वर्ष और {1} महीना/महीने", "RetryAfter:Months": "{0} महीना/महीने", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json similarity index 81% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hr.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json index 77a253b33e..d8a0ce4a18 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hr.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json @@ -1,7 +1,7 @@ { "culture": "hr", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Prekoračeno ograničenje brzine operacije. Možete pokušati ponovo nakon {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Prekoračeno ograničenje brzine operacije. Možete pokušati ponovo nakon {RetryAfter}.", "RetryAfter:Years": "{0} godina/e", "RetryAfter:YearsAndMonths": "{0} godina/e i {1} mjesec/i", "RetryAfter:Months": "{0} mjesec/i", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hu.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json similarity index 81% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hu.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json index 30ca0a59a0..b1ece6246a 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hu.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json @@ -1,7 +1,7 @@ { "culture": "hu", "texts": { - "Volo.Abp.OperationRateLimit:010001": "A műveleti sebességkorlát túllépve. Újra próbálkozhat {RetryAfter} múlva.", + "Volo.Abp.OperationRateLimiting:010001": "A műveleti sebességkorlát túllépve. Újra próbálkozhat {RetryAfter} múlva.", "RetryAfter:Years": "{0} év", "RetryAfter:YearsAndMonths": "{0} év és {1} hónap", "RetryAfter:Months": "{0} hónap", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/is.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json similarity index 84% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/is.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json index 1331cc4bef..45c6255ee1 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/is.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json @@ -1,7 +1,7 @@ { "culture": "is", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Aðgerðarhraðatakmörk náð. Þú getur reynt aftur eftir {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Aðgerðarhraðatakmörk náð. Þú getur reynt aftur eftir {RetryAfter}.", "RetryAfter:Years": "{0} ár", "RetryAfter:YearsAndMonths": "{0} ár og {1} mánuð(ir)", "RetryAfter:Months": "{0} mánuð(ur/ir)", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/it.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json similarity index 82% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/it.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json index fb550655f2..f93fb278bf 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/it.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json @@ -1,7 +1,7 @@ { "culture": "it", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Limite di frequenza operazione superato. Puoi riprovare dopo {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Limite di frequenza operazione superato. Puoi riprovare dopo {RetryAfter}.", "RetryAfter:Years": "{0} anno/i", "RetryAfter:YearsAndMonths": "{0} anno/i e {1} mese/i", "RetryAfter:Months": "{0} mese/i", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/nl.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json similarity index 82% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/nl.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json index 68646ea677..a733537123 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/nl.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json @@ -1,7 +1,7 @@ { "culture": "nl", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Bewerkingssnelheidslimiet overschreden. U kunt het opnieuw proberen na {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Bewerkingssnelheidslimiet overschreden. U kunt het opnieuw proberen na {RetryAfter}.", "RetryAfter:Years": "{0} jaar", "RetryAfter:YearsAndMonths": "{0} jaar en {1} maand(en)", "RetryAfter:Months": "{0} maand(en)", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pl-PL.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json similarity index 82% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pl-PL.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json index 085a20af9d..72d6c905a5 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pl-PL.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json @@ -1,7 +1,7 @@ { "culture": "pl-PL", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Przekroczono limit częstotliwości operacji. Możesz spróbować ponownie po {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Przekroczono limit częstotliwości operacji. Możesz spróbować ponownie po {RetryAfter}.", "RetryAfter:Years": "{0} rok/lat", "RetryAfter:YearsAndMonths": "{0} rok/lat i {1} miesiąc/miesięcy", "RetryAfter:Months": "{0} miesiąc/miesięcy", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pt-BR.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json similarity index 81% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pt-BR.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json index f1d7cd1dfe..412f7f2389 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pt-BR.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json @@ -1,7 +1,7 @@ { "culture": "pt-BR", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Limite de taxa de operação excedido. Você pode tentar novamente após {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Limite de taxa de operação excedido. Você pode tentar novamente após {RetryAfter}.", "RetryAfter:Years": "{0} ano(s)", "RetryAfter:YearsAndMonths": "{0} ano(s) e {1} mês/meses", "RetryAfter:Months": "{0} mês/meses", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ro-RO.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json similarity index 82% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ro-RO.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json index 51a7446b4f..cef165fa42 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ro-RO.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json @@ -1,7 +1,7 @@ { "culture": "ro-RO", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Limita ratei de operare a fost depășită. Puteți încerca din nou după {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Limita ratei de operare a fost depășită. Puteți încerca din nou după {RetryAfter}.", "RetryAfter:Years": "{0} an/ani", "RetryAfter:YearsAndMonths": "{0} an/ani și {1} lună/luni", "RetryAfter:Months": "{0} lună/luni", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ru.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json similarity index 81% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ru.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json index fbee7ea360..dc4ddf6db6 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ru.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json @@ -1,7 +1,7 @@ { "culture": "ru", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Превышен лимит частоты операций. Вы можете повторить попытку через {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Превышен лимит частоты операций. Вы можете повторить попытку через {RetryAfter}.", "RetryAfter:Years": "{0} год/лет", "RetryAfter:YearsAndMonths": "{0} год/лет и {1} месяц/месяцев", "RetryAfter:Months": "{0} месяц/месяцев", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sk.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json similarity index 83% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sk.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json index 16e1a32403..bd849adb48 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sk.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json @@ -1,7 +1,7 @@ { "culture": "sk", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Prekročený limit rýchlosti operácie. Môžete to skúsiť znova po {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Prekročený limit rýchlosti operácie. Môžete to skúsiť znova po {RetryAfter}.", "RetryAfter:Years": "{0} rok/rokov", "RetryAfter:YearsAndMonths": "{0} rok/rokov a {1} mesiac/mesiacov", "RetryAfter:Months": "{0} mesiac/mesiacov", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sl.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json similarity index 83% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sl.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json index 22bbbf58c2..eb6c54980e 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sl.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json @@ -1,7 +1,7 @@ { "culture": "sl", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Presežena omejitev hitrosti operacije. Poskusite lahko znova čez {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Presežena omejitev hitrosti operacije. Poskusite lahko znova čez {RetryAfter}.", "RetryAfter:Years": "{0} leto/let", "RetryAfter:YearsAndMonths": "{0} leto/let in {1} mesec/mesecev", "RetryAfter:Months": "{0} mesec/mesecev", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sv.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json similarity index 81% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sv.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json index 1aa6d1f6ed..562ba3d586 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sv.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json @@ -1,7 +1,7 @@ { "culture": "sv", "texts": { - "Volo.Abp.OperationRateLimit:010001": "Hastighetsgränsen för operationen har överskridits. Du kan försöka igen efter {RetryAfter}.", + "Volo.Abp.OperationRateLimiting:010001": "Hastighetsgränsen för operationen har överskridits. Du kan försöka igen efter {RetryAfter}.", "RetryAfter:Years": "{0} år", "RetryAfter:YearsAndMonths": "{0} år och {1} månad(er)", "RetryAfter:Months": "{0} månad(er)", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/tr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json similarity index 81% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/tr.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json index 9dfc82dc7b..3ce04aa915 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/tr.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json @@ -1,7 +1,7 @@ { "culture": "tr", "texts": { - "Volo.Abp.OperationRateLimit:010001": "İşlem hız sınırı aşıldı. {RetryAfter} sonra tekrar deneyebilirsiniz.", + "Volo.Abp.OperationRateLimiting:010001": "İşlem hız sınırı aşıldı. {RetryAfter} sonra tekrar deneyebilirsiniz.", "RetryAfter:Years": "{0} yıl", "RetryAfter:YearsAndMonths": "{0} yıl ve {1} ay", "RetryAfter:Months": "{0} ay", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/vi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json similarity index 79% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/vi.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json index 4744a6c5ce..6b0ee47927 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/vi.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json @@ -1,7 +1,7 @@ { "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}.", + "Volo.Abp.OperationRateLimiting:010001": "Đã vượt quá giới hạn tốc độ thao tác. Bạn có thể thử lại sau {RetryAfter}.", "RetryAfter:Years": "{0} năm", "RetryAfter:YearsAndMonths": "{0} năm và {1} tháng", "RetryAfter:Months": "{0} tháng", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/zh-Hans.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json similarity index 83% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/zh-Hans.json rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json index 1db03def8c..6472587357 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/zh-Hans.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json @@ -1,7 +1,7 @@ { "culture": "zh-Hans", "texts": { - "Volo.Abp.OperationRateLimit:010001": "操作频率超出限制。请在 {RetryAfter} 后重试。", + "Volo.Abp.OperationRateLimiting:010001": "操作频率超出限制。请在 {RetryAfter} 后重试。", "RetryAfter:Years": "{0} 年", "RetryAfter:YearsAndMonths": "{0} 年 {1} 个月", "RetryAfter:Months": "{0} 个月", diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs similarity index 54% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs index 86cec343eb..305863381a 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/DefaultOperationRateLimitingPolicyProvider.cs @@ -4,30 +4,30 @@ using System.Threading.Tasks; using Microsoft.Extensions.Options; using Volo.Abp.DependencyInjection; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class DefaultOperationRateLimitPolicyProvider : IOperationRateLimitPolicyProvider, ITransientDependency +public class DefaultOperationRateLimitingPolicyProvider : IOperationRateLimitingPolicyProvider, ITransientDependency { - protected AbpOperationRateLimitOptions Options { get; } + protected AbpOperationRateLimitingOptions Options { get; } - public DefaultOperationRateLimitPolicyProvider(IOptions options) + public DefaultOperationRateLimitingPolicyProvider(IOptions options) { Options = options.Value; } - public virtual Task GetAsync(string policyName) + 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()."); + $"Make sure to configure it using AbpOperationRateLimitingOptions.AddPolicy()."); } return Task.FromResult(policy); } - public virtual Task> GetListAsync() + public virtual Task> GetListAsync() { return Task.FromResult(Options.Policies.Values.ToList()); } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs new file mode 100644 index 0000000000..1f87137a68 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/IOperationRateLimitingPolicyProvider.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public interface IOperationRateLimitingPolicyProvider +{ + Task GetAsync(string policyName); + + Task> GetListAsync(); +} diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs similarity index 55% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs index 3435f07bd0..e330bd8e46 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPartitionType.cs @@ -1,6 +1,6 @@ -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public enum OperationRateLimitPartitionType +public enum OperationRateLimitingPartitionType { Parameter, CurrentUser, diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs similarity index 56% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs index cf720ba112..45634e5de1 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicy.cs @@ -1,15 +1,15 @@ using System; using System.Collections.Generic; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitPolicy +public class OperationRateLimitingPolicy { public string Name { get; set; } = default!; public string? ErrorCode { get; set; } - public List Rules { get; set; } = new(); + 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.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs similarity index 68% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs index a420b088fb..72cb247614 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingPolicyBuilder.cs @@ -2,16 +2,16 @@ using System; using System.Collections.Generic; using System.Linq; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitPolicyBuilder +public class OperationRateLimitingPolicyBuilder { private readonly string _name; private string? _errorCode; - private readonly List _rules = new(); + private readonly List _rules = new(); private readonly List _customRuleTypes = new(); - public OperationRateLimitPolicyBuilder(string name) + public OperationRateLimitingPolicyBuilder(string name) { _name = Check.NotNullOrWhiteSpace(name, nameof(name)); } @@ -19,10 +19,10 @@ public class OperationRateLimitPolicyBuilder /// /// Add a built-in rule. Multiple rules are AND-combined. /// - public OperationRateLimitPolicyBuilder AddRule( - Action configure) + public OperationRateLimitingPolicyBuilder AddRule( + Action configure) { - var builder = new OperationRateLimitRuleBuilder(this); + var builder = new OperationRateLimitingRuleBuilder(this); configure(builder); if (!builder.IsCommitted) { @@ -34,8 +34,8 @@ public class OperationRateLimitPolicyBuilder /// /// Add a custom rule type (resolved from DI). /// - public OperationRateLimitPolicyBuilder AddRule() - where TRule : class, IOperationRateLimitRule + public OperationRateLimitingPolicyBuilder AddRule() + where TRule : class, IOperationRateLimitingRule { _customRuleTypes.Add(typeof(TRule)); return this; @@ -45,10 +45,10 @@ public class OperationRateLimitPolicyBuilder /// Shortcut: single-rule policy with fixed window. /// Returns the rule builder for partition configuration. /// - public OperationRateLimitRuleBuilder WithFixedWindow( + public OperationRateLimitingRuleBuilder WithFixedWindow( TimeSpan duration, int maxCount) { - var builder = new OperationRateLimitRuleBuilder(this); + var builder = new OperationRateLimitingRuleBuilder(this); builder.WithFixedWindow(duration, maxCount); return builder; } @@ -56,18 +56,18 @@ public class OperationRateLimitPolicyBuilder /// /// Set a custom ErrorCode for this policy's exception. /// - public OperationRateLimitPolicyBuilder WithErrorCode(string errorCode) + public OperationRateLimitingPolicyBuilder WithErrorCode(string errorCode) { _errorCode = Check.NotNullOrWhiteSpace(errorCode, nameof(errorCode)); return this; } - internal void AddRuleDefinition(OperationRateLimitRuleDefinition definition) + internal void AddRuleDefinition(OperationRateLimitingRuleDefinition definition) { _rules.Add(definition); } - internal OperationRateLimitPolicy Build() + internal OperationRateLimitingPolicy Build() { if (_rules.Count == 0 && _customRuleTypes.Count == 0) { @@ -77,7 +77,7 @@ public class OperationRateLimitPolicyBuilder } var duplicate = _rules - .Where(r => r.PartitionType != OperationRateLimitPartitionType.Custom) + .Where(r => r.PartitionType != OperationRateLimitingPartitionType.Custom) .GroupBy(r => (r.Duration, r.MaxCount, r.PartitionType, r.IsMultiTenant)) .FirstOrDefault(g => g.Count() > 1); @@ -91,11 +91,11 @@ public class OperationRateLimitPolicyBuilder "Each rule in a policy must have a unique combination of these properties."); } - return new OperationRateLimitPolicy + return new OperationRateLimitingPolicy { Name = _name, ErrorCode = _errorCode, - Rules = new List(_rules), + Rules = new List(_rules), CustomRuleTypes = new List(_customRuleTypes) }; } diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs similarity index 64% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs index 2908f9a538..82ed356f01 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs @@ -1,24 +1,24 @@ using System; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitRuleBuilder +public class OperationRateLimitingRuleBuilder { - private readonly OperationRateLimitPolicyBuilder _policyBuilder; + private readonly OperationRateLimitingPolicyBuilder _policyBuilder; private TimeSpan _duration; private int _maxCount; - private OperationRateLimitPartitionType? _partitionType; - private Func? _customPartitionKeyResolver; + private OperationRateLimitingPartitionType? _partitionType; + private Func? _customPartitionKeyResolver; private bool _isMultiTenant; internal bool IsCommitted { get; private set; } - internal OperationRateLimitRuleBuilder(OperationRateLimitPolicyBuilder policyBuilder) + internal OperationRateLimitingRuleBuilder(OperationRateLimitingPolicyBuilder policyBuilder) { _policyBuilder = policyBuilder; } - public OperationRateLimitRuleBuilder WithFixedWindow( + public OperationRateLimitingRuleBuilder WithFixedWindow( TimeSpan duration, int maxCount) { _duration = duration; @@ -26,7 +26,7 @@ public class OperationRateLimitRuleBuilder return this; } - public OperationRateLimitRuleBuilder WithMultiTenancy() + public OperationRateLimitingRuleBuilder WithMultiTenancy() { _isMultiTenant = true; return this; @@ -35,9 +35,9 @@ public class OperationRateLimitRuleBuilder /// /// Use context.Parameter as partition key. /// - public OperationRateLimitPolicyBuilder PartitionByParameter() + public OperationRateLimitingPolicyBuilder PartitionByParameter() { - _partitionType = OperationRateLimitPartitionType.Parameter; + _partitionType = OperationRateLimitingPartitionType.Parameter; CommitToPolicyBuilder(); return _policyBuilder; } @@ -45,9 +45,9 @@ public class OperationRateLimitRuleBuilder /// /// Auto resolve from ICurrentUser.Id. /// - public OperationRateLimitPolicyBuilder PartitionByCurrentUser() + public OperationRateLimitingPolicyBuilder PartitionByCurrentUser() { - _partitionType = OperationRateLimitPartitionType.CurrentUser; + _partitionType = OperationRateLimitingPartitionType.CurrentUser; CommitToPolicyBuilder(); return _policyBuilder; } @@ -55,9 +55,9 @@ public class OperationRateLimitRuleBuilder /// /// Auto resolve from ICurrentTenant.Id. /// - public OperationRateLimitPolicyBuilder PartitionByCurrentTenant() + public OperationRateLimitingPolicyBuilder PartitionByCurrentTenant() { - _partitionType = OperationRateLimitPartitionType.CurrentTenant; + _partitionType = OperationRateLimitingPartitionType.CurrentTenant; CommitToPolicyBuilder(); return _policyBuilder; } @@ -65,9 +65,9 @@ public class OperationRateLimitRuleBuilder /// /// Auto resolve from IWebClientInfoProvider.ClientIpAddress. /// - public OperationRateLimitPolicyBuilder PartitionByClientIp() + public OperationRateLimitingPolicyBuilder PartitionByClientIp() { - _partitionType = OperationRateLimitPartitionType.ClientIp; + _partitionType = OperationRateLimitingPartitionType.ClientIp; CommitToPolicyBuilder(); return _policyBuilder; } @@ -76,9 +76,9 @@ public class OperationRateLimitRuleBuilder /// Partition by email address. /// Resolves from context.Parameter, falls back to ICurrentUser.Email. /// - public OperationRateLimitPolicyBuilder PartitionByEmail() + public OperationRateLimitingPolicyBuilder PartitionByEmail() { - _partitionType = OperationRateLimitPartitionType.Email; + _partitionType = OperationRateLimitingPartitionType.Email; CommitToPolicyBuilder(); return _policyBuilder; } @@ -87,9 +87,9 @@ public class OperationRateLimitRuleBuilder /// Partition by phone number. /// Resolves from context.Parameter, falls back to ICurrentUser.PhoneNumber. /// - public OperationRateLimitPolicyBuilder PartitionByPhoneNumber() + public OperationRateLimitingPolicyBuilder PartitionByPhoneNumber() { - _partitionType = OperationRateLimitPartitionType.PhoneNumber; + _partitionType = OperationRateLimitingPartitionType.PhoneNumber; CommitToPolicyBuilder(); return _policyBuilder; } @@ -97,10 +97,10 @@ public class OperationRateLimitRuleBuilder /// /// Custom partition key resolver from context. /// - public OperationRateLimitPolicyBuilder PartitionBy( - Func keyResolver) + public OperationRateLimitingPolicyBuilder PartitionBy( + Func keyResolver) { - _partitionType = OperationRateLimitPartitionType.Custom; + _partitionType = OperationRateLimitingPartitionType.Custom; _customPartitionKeyResolver = Check.NotNull(keyResolver, nameof(keyResolver)); CommitToPolicyBuilder(); return _policyBuilder; @@ -112,7 +112,7 @@ public class OperationRateLimitRuleBuilder IsCommitted = true; } - internal OperationRateLimitRuleDefinition Build() + internal OperationRateLimitingRuleDefinition Build() { if (_duration <= TimeSpan.Zero) { @@ -135,14 +135,14 @@ public class OperationRateLimitRuleBuilder "Call PartitionByParameter(), PartitionByCurrentUser(), PartitionByClientIp(), or another PartitionBy*() method."); } - if (_partitionType == OperationRateLimitPartitionType.Custom && _customPartitionKeyResolver == null) + if (_partitionType == OperationRateLimitingPartitionType.Custom && _customPartitionKeyResolver == null) { throw new AbpException( "Custom partition type requires a key resolver. " + "Call PartitionBy(keyResolver) instead of setting partition type directly."); } - return new OperationRateLimitRuleDefinition + return new OperationRateLimitingRuleDefinition { Duration = _duration, MaxCount = _maxCount, diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs new file mode 100644 index 0000000000..a3415105d9 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs @@ -0,0 +1,16 @@ +using System; + +namespace Volo.Abp.OperationRateLimiting; + +public class OperationRateLimitingRuleDefinition +{ + public TimeSpan Duration { get; set; } + + public int MaxCount { get; set; } + + public OperationRateLimitingPartitionType PartitionType { get; set; } + + public Func? CustomPartitionKeyResolver { get; set; } + + public bool IsMultiTenant { get; set; } +} diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs similarity index 74% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs index 3b46cf3c7b..af072bc8c4 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs @@ -3,25 +3,25 @@ using Volo.Abp.AspNetCore.WebClientInfo; using Volo.Abp.MultiTenancy; using Volo.Abp.Users; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule +public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule { private const string HostTenantKey = "host"; protected string PolicyName { get; } protected int RuleIndex { get; } - protected OperationRateLimitRuleDefinition Definition { get; } - protected IOperationRateLimitStore Store { get; } + protected OperationRateLimitingRuleDefinition Definition { get; } + protected IOperationRateLimitingStore Store { get; } protected ICurrentUser CurrentUser { get; } protected ICurrentTenant CurrentTenant { get; } protected IWebClientInfoProvider WebClientInfoProvider { get; } - public FixedWindowOperationRateLimitRule( + public FixedWindowOperationRateLimitingRule( string policyName, int ruleIndex, - OperationRateLimitRuleDefinition definition, - IOperationRateLimitStore store, + OperationRateLimitingRuleDefinition definition, + IOperationRateLimitingStore store, ICurrentUser currentUser, ICurrentTenant currentTenant, IWebClientInfoProvider webClientInfoProvider) @@ -35,8 +35,8 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule WebClientInfoProvider = webClientInfoProvider; } - public virtual async Task AcquireAsync( - OperationRateLimitContext context) + public virtual async Task AcquireAsync( + OperationRateLimitingContext context) { var partitionKey = ResolvePartitionKey(context); var storeKey = BuildStoreKey(partitionKey); @@ -45,8 +45,8 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule return ToRuleResult(storeResult); } - public virtual async Task CheckAsync( - OperationRateLimitContext context) + public virtual async Task CheckAsync( + OperationRateLimitingContext context) { var partitionKey = ResolvePartitionKey(context); var storeKey = BuildStoreKey(partitionKey); @@ -55,47 +55,47 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule return ToRuleResult(storeResult); } - public virtual async Task ResetAsync(OperationRateLimitContext context) + public virtual async Task ResetAsync(OperationRateLimitingContext context) { var partitionKey = ResolvePartitionKey(context); var storeKey = BuildStoreKey(partitionKey); await Store.ResetAsync(storeKey); } - protected virtual string ResolvePartitionKey(OperationRateLimitContext context) + protected virtual string ResolvePartitionKey(OperationRateLimitingContext context) { return Definition.PartitionType switch { - OperationRateLimitPartitionType.Parameter => + OperationRateLimitingPartitionType.Parameter => context.Parameter ?? throw new AbpException( - $"OperationRateLimitContext.Parameter is required for policy '{PolicyName}' (PartitionByParameter)."), + $"OperationRateLimitingContext.Parameter is required for policy '{PolicyName}' (PartitionByParameter)."), - OperationRateLimitPartitionType.CurrentUser => + OperationRateLimitingPartitionType.CurrentUser => CurrentUser.Id?.ToString() ?? throw new AbpException( $"Current user is not authenticated. Policy '{PolicyName}' requires PartitionByCurrentUser."), - OperationRateLimitPartitionType.CurrentTenant => + OperationRateLimitingPartitionType.CurrentTenant => CurrentTenant.Id?.ToString() ?? HostTenantKey, - OperationRateLimitPartitionType.ClientIp => + OperationRateLimitingPartitionType.ClientIp => WebClientInfoProvider.ClientIpAddress ?? throw new AbpException( $"Client IP address could not be determined. Policy '{PolicyName}' requires PartitionByClientIp. " + "Ensure IWebClientInfoProvider is properly configured."), - OperationRateLimitPartitionType.Email => + OperationRateLimitingPartitionType.Email => context.Parameter ?? CurrentUser.Email ?? throw new AbpException( $"Email is required for policy '{PolicyName}' (PartitionByEmail). Provide it via context.Parameter or ensure the user has an email."), - OperationRateLimitPartitionType.PhoneNumber => + OperationRateLimitingPartitionType.PhoneNumber => context.Parameter ?? CurrentUser.PhoneNumber ?? throw new AbpException( $"Phone number is required for policy '{PolicyName}' (PartitionByPhoneNumber). Provide it via context.Parameter or ensure the user has a phone number."), - OperationRateLimitPartitionType.Custom => + OperationRateLimitingPartitionType.Custom => Definition.CustomPartitionKeyResolver!(context), _ => throw new AbpException($"Unknown partition type: {Definition.PartitionType}") @@ -119,9 +119,9 @@ public class FixedWindowOperationRateLimitRule : IOperationRateLimitRule return $"orl:t:{tenantId}:{PolicyName}:{ruleKey}:{partitionKey}"; } - protected virtual OperationRateLimitRuleResult ToRuleResult(OperationRateLimitStoreResult storeResult) + protected virtual OperationRateLimitingRuleResult ToRuleResult(OperationRateLimitingStoreResult storeResult) { - return new OperationRateLimitRuleResult + return new OperationRateLimitingRuleResult { RuleName = $"{PolicyName}:Rule[{(long)Definition.Duration.TotalSeconds}s,{Definition.MaxCount},{Definition.PartitionType}]", IsAllowed = storeResult.IsAllowed, diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs new file mode 100644 index 0000000000..1bb42a1727 --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/IOperationRateLimitingRule.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public interface IOperationRateLimitingRule +{ + Task AcquireAsync(OperationRateLimitingContext context); + + Task CheckAsync(OperationRateLimitingContext context); + + Task ResetAsync(OperationRateLimitingContext context); +} diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs similarity index 75% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs index 0e86fc31a1..ca64981e3b 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs @@ -7,20 +7,20 @@ using Volo.Abp.DependencyInjection; using Volo.Abp.DistributedLocking; using Volo.Abp.Timing; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, ITransientDependency +public class DistributedCacheOperationRateLimitingStore : IOperationRateLimitingStore, ITransientDependency { - protected IDistributedCache Cache { get; } + protected IDistributedCache Cache { get; } protected IClock Clock { get; } protected IAbpDistributedLock DistributedLock { get; } - protected AbpOperationRateLimitOptions Options { get; } + protected AbpOperationRateLimitingOptions Options { get; } - public DistributedCacheOperationRateLimitStore( - IDistributedCache cache, + public DistributedCacheOperationRateLimitingStore( + IDistributedCache cache, IClock clock, IAbpDistributedLock distributedLock, - IOptions options) + IOptions options) { Cache = cache; Clock = clock; @@ -28,12 +28,12 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, Options = options.Value; } - public virtual async Task IncrementAsync( + public virtual async Task IncrementAsync( string key, TimeSpan duration, int maxCount) { if (maxCount <= 0) { - return new OperationRateLimitStoreResult + return new OperationRateLimitingStoreResult { IsAllowed = false, CurrentCount = 0, @@ -43,7 +43,7 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, } await using (var handle = await DistributedLock.TryAcquireAsync( - $"OperationRateLimit:{key}", Options.LockTimeout)) + $"OperationRateLimiting:{key}", Options.LockTimeout)) { if (handle == null) { @@ -57,14 +57,14 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration)) { - cacheItem = new OperationRateLimitCacheItem { Count = 1, WindowStart = now }; + cacheItem = new OperationRateLimitingCacheItem { Count = 1, WindowStart = now }; await Cache.SetAsync(key, cacheItem, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = duration }); - return new OperationRateLimitStoreResult + return new OperationRateLimitingStoreResult { IsAllowed = true, CurrentCount = 1, @@ -75,7 +75,7 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, if (cacheItem.Count >= maxCount) { var retryAfter = cacheItem.WindowStart.Add(duration) - now; - return new OperationRateLimitStoreResult + return new OperationRateLimitingStoreResult { IsAllowed = false, CurrentCount = cacheItem.Count, @@ -92,7 +92,7 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, AbsoluteExpirationRelativeToNow = expiration > TimeSpan.Zero ? expiration : duration }); - return new OperationRateLimitStoreResult + return new OperationRateLimitingStoreResult { IsAllowed = true, CurrentCount = cacheItem.Count, @@ -101,12 +101,12 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, } } - public virtual async Task GetAsync( + public virtual async Task GetAsync( string key, TimeSpan duration, int maxCount) { if (maxCount <= 0) { - return new OperationRateLimitStoreResult + return new OperationRateLimitingStoreResult { IsAllowed = false, CurrentCount = 0, @@ -120,7 +120,7 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, if (cacheItem == null || now >= cacheItem.WindowStart.Add(duration)) { - return new OperationRateLimitStoreResult + return new OperationRateLimitingStoreResult { IsAllowed = true, CurrentCount = 0, @@ -131,7 +131,7 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, if (cacheItem.Count >= maxCount) { var retryAfter = cacheItem.WindowStart.Add(duration) - now; - return new OperationRateLimitStoreResult + return new OperationRateLimitingStoreResult { IsAllowed = false, CurrentCount = cacheItem.Count, @@ -140,7 +140,7 @@ public class DistributedCacheOperationRateLimitStore : IOperationRateLimitStore, }; } - return new OperationRateLimitStoreResult + return new OperationRateLimitingStoreResult { IsAllowed = true, CurrentCount = cacheItem.Count, diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs new file mode 100644 index 0000000000..049fa35b0c --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/IOperationRateLimitingStore.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public interface IOperationRateLimitingStore +{ + 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/OperationRateLimitCacheItem.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs similarity index 59% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitCacheItem.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs index f2ed13b7b1..2d92d8578e 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitCacheItem.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingCacheItem.cs @@ -2,11 +2,11 @@ using System; using Volo.Abp.Caching; using Volo.Abp.MultiTenancy; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -[CacheName("OperationRateLimit")] +[CacheName("OperationRateLimiting")] [IgnoreMultiTenancy] -public class OperationRateLimitCacheItem +public class OperationRateLimitingCacheItem { public int Count { get; set; } diff --git a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs similarity index 68% rename from framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs rename to framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs index d67d650298..caa2bd640d 100644 --- a/framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/OperationRateLimitingStoreResult.cs @@ -1,8 +1,8 @@ using System; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitStoreResult +public class OperationRateLimitingStoreResult { public bool IsAllowed { get; set; } diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj similarity index 92% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj index 5f284a7c3b..a9d2d9ee36 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo.Abp.OperationRateLimiting.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs similarity index 68% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs index bcbf6a2300..bf20e1f6a4 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs @@ -2,14 +2,14 @@ using System; using Shouldly; using Xunit; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class AbpOperationRateLimitException_Tests +public class AbpOperationRateLimitingException_Tests { [Fact] public void Should_Set_HttpStatusCode_To_429() { - var result = new OperationRateLimitResult + var result = new OperationRateLimitingResult { IsAllowed = false, MaxCount = 3, @@ -18,7 +18,7 @@ public class AbpOperationRateLimitException_Tests RetryAfter = TimeSpan.FromMinutes(15) }; - var exception = new AbpOperationRateLimitException("TestPolicy", result); + var exception = new AbpOperationRateLimitingException("TestPolicy", result); exception.HttpStatusCode.ShouldBe(429); } @@ -26,7 +26,7 @@ public class AbpOperationRateLimitException_Tests [Fact] public void Should_Set_Default_ErrorCode() { - var result = new OperationRateLimitResult + var result = new OperationRateLimitingResult { IsAllowed = false, MaxCount = 3, @@ -34,15 +34,15 @@ public class AbpOperationRateLimitException_Tests RemainingCount = 0 }; - var exception = new AbpOperationRateLimitException("TestPolicy", result); + var exception = new AbpOperationRateLimitingException("TestPolicy", result); - exception.Code.ShouldBe(AbpOperationRateLimitErrorCodes.ExceedLimit); + exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); } [Fact] public void Should_Set_Custom_ErrorCode() { - var result = new OperationRateLimitResult + var result = new OperationRateLimitingResult { IsAllowed = false, MaxCount = 3, @@ -50,7 +50,7 @@ public class AbpOperationRateLimitException_Tests RemainingCount = 0 }; - var exception = new AbpOperationRateLimitException("TestPolicy", result, "App:Custom:Error"); + var exception = new AbpOperationRateLimitingException("TestPolicy", result, "App:Custom:Error"); exception.Code.ShouldBe("App:Custom:Error"); } @@ -58,7 +58,7 @@ public class AbpOperationRateLimitException_Tests [Fact] public void Should_Include_Data_Properties() { - var result = new OperationRateLimitResult + var result = new OperationRateLimitingResult { IsAllowed = false, MaxCount = 3, @@ -68,7 +68,7 @@ public class AbpOperationRateLimitException_Tests WindowDuration = TimeSpan.FromHours(1) }; - var exception = new AbpOperationRateLimitException("TestPolicy", result); + var exception = new AbpOperationRateLimitingException("TestPolicy", result); exception.Data["PolicyName"].ShouldBe("TestPolicy"); exception.Data["MaxCount"].ShouldBe(3); @@ -82,7 +82,7 @@ public class AbpOperationRateLimitException_Tests [Fact] public void Should_Store_PolicyName_And_Result() { - var result = new OperationRateLimitResult + var result = new OperationRateLimitingResult { IsAllowed = false, MaxCount = 5, @@ -91,7 +91,7 @@ public class AbpOperationRateLimitException_Tests RetryAfter = TimeSpan.FromHours(1) }; - var exception = new AbpOperationRateLimitException("MyPolicy", result); + var exception = new AbpOperationRateLimitingException("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.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs similarity index 69% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitPhase2RaceTestModule.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs index f390d6d0e9..c60381c774 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitPhase2RaceTestModule.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2RaceTestModule.cs @@ -6,18 +6,18 @@ using Volo.Abp.Autofac; using Volo.Abp.ExceptionHandling; using Volo.Abp.Modularity; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; /// /// 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 +internal class RaceConditionSimulatorStore : IOperationRateLimitingStore { - public Task GetAsync(string key, TimeSpan duration, int maxCount) + public Task GetAsync(string key, TimeSpan duration, int maxCount) { - return Task.FromResult(new OperationRateLimitStoreResult + return Task.FromResult(new OperationRateLimitingStoreResult { IsAllowed = true, CurrentCount = 0, @@ -25,10 +25,10 @@ internal class RaceConditionSimulatorStore : IOperationRateLimitStore }); } - public Task IncrementAsync(string key, TimeSpan duration, int 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 + return Task.FromResult(new OperationRateLimitingStoreResult { IsAllowed = false, CurrentCount = maxCount, @@ -44,19 +44,19 @@ internal class RaceConditionSimulatorStore : IOperationRateLimitStore } [DependsOn( - typeof(AbpOperationRateLimitModule), + typeof(AbpOperationRateLimitingModule), typeof(AbpExceptionHandlingModule), typeof(AbpTestBaseModule), typeof(AbpAutofacModule) )] -public class AbpOperationRateLimitPhase2RaceTestModule : AbpModule +public class AbpOperationRateLimitingPhase2RaceTestModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { context.Services.Replace( - ServiceDescriptor.Transient()); + ServiceDescriptor.Transient()); - Configure(options => + Configure(options => { options.AddPolicy("TestRacePolicy", policy => { diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs similarity index 97% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs index 45cf7320e1..6bee2b7d83 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs @@ -6,15 +6,15 @@ using Volo.Abp.Autofac; using Volo.Abp.ExceptionHandling; using Volo.Abp.Modularity; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; [DependsOn( - typeof(AbpOperationRateLimitModule), + typeof(AbpOperationRateLimitingModule), typeof(AbpExceptionHandlingModule), typeof(AbpTestBaseModule), typeof(AbpAutofacModule) )] -public class AbpOperationRateLimitTestModule : AbpModule +public class AbpOperationRateLimitingTestModule : AbpModule { public override void ConfigureServices(ServiceConfigurationContext context) { @@ -22,7 +22,7 @@ public class AbpOperationRateLimitTestModule : AbpModule mockWebClientInfoProvider.ClientIpAddress.Returns("127.0.0.1"); context.Services.AddSingleton(mockWebClientInfoProvider); - Configure(options => + Configure(options => { options.AddPolicy("TestSimple", policy => { diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs similarity index 92% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs index d4748b60e3..aa21159613 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs @@ -3,15 +3,15 @@ using System.Threading.Tasks; using Shouldly; using Xunit; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class DistributedCacheOperationRateLimitStore_Tests : OperationRateLimitTestBase +public class DistributedCacheOperationRateLimitingStore_Tests : OperationRateLimitingTestBase { - private readonly IOperationRateLimitStore _store; + private readonly IOperationRateLimitingStore _store; - public DistributedCacheOperationRateLimitStore_Tests() + public DistributedCacheOperationRateLimitingStore_Tests() { - _store = GetRequiredService(); + _store = GetRequiredService(); } [Fact] diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs similarity index 82% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs index 6254ada97f..8d15d2a2b9 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs @@ -4,19 +4,19 @@ using Shouldly; using Volo.Abp.Testing; using Xunit; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; /// /// 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 +public class OperationRateLimitingCheckerPhase1_Tests : OperationRateLimitingTestBase { - private readonly IOperationRateLimitChecker _checker; + private readonly IOperationRateLimitingChecker _checker; - public OperationRateLimitCheckerPhase1_Tests() + public OperationRateLimitingCheckerPhase1_Tests() { - _checker = GetRequiredService(); + _checker = GetRequiredService(); } [Fact] @@ -25,14 +25,14 @@ public class OperationRateLimitCheckerPhase1_Tests : OperationRateLimitTestBase // 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 }; + var context = new OperationRateLimitingContext { Parameter = param }; // First request: both rules go from 0 to 1 (exhausted, since maxCount=1) await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); // Second request: both Rule0 and Rule1 are blocking. // Phase 1 checks all rules → RetryAfter must be the larger one (~2 hours). - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); }); @@ -46,12 +46,12 @@ public class OperationRateLimitCheckerPhase1_Tests : OperationRateLimitTestBase 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 }; + var context = new OperationRateLimitingContext { Parameter = param }; // Exhaust both rules await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestCompositeMaxRetryAfter", context); }); @@ -69,12 +69,12 @@ public class OperationRateLimitCheckerPhase1_Tests : OperationRateLimitTestBase // 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 }; + var context = new OperationRateLimitingContext { Parameter = param }; // Exhaust only Rule0 (max=1) await _checker.CheckAsync("TestCompositePartialBlock", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestCompositePartialBlock", context); }); @@ -102,8 +102,8 @@ public class OperationRateLimitCheckerPhase1_Tests : OperationRateLimitTestBase /// 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 +public class OperationRateLimitingCheckerPhase2Race_Tests + : AbpIntegratedTest { protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) { @@ -116,10 +116,10 @@ public class OperationRateLimitCheckerPhase2Race_Tests // 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 checker = GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = "race-test" }; - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await checker.CheckAsync("TestRacePolicy", context); }); @@ -134,8 +134,8 @@ public class OperationRateLimitCheckerPhase2Race_Tests { // 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" }; + var checker = GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = "is-allowed-race" }; // Should return true because GetAsync always returns allowed in the mock store var allowed = await checker.IsAllowedAsync("TestRacePolicy", context); diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs similarity index 84% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs index 347aea5f37..23dcfb6799 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs @@ -6,21 +6,21 @@ using Shouldly; using Volo.Abp.Security.Claims; using Xunit; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase +public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase { - private readonly IOperationRateLimitChecker _checker; + private readonly IOperationRateLimitingChecker _checker; - public OperationRateLimitChecker_Tests() + public OperationRateLimitingChecker_Tests() { - _checker = GetRequiredService(); + _checker = GetRequiredService(); } [Fact] public async Task Should_Allow_Within_Limit() { - var context = new OperationRateLimitContext { Parameter = "test@example.com" }; + var context = new OperationRateLimitingContext { Parameter = "test@example.com" }; // Should not throw for 3 requests (max is 3) await _checker.CheckAsync("TestSimple", context); @@ -32,13 +32,13 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Reject_When_Exceeded() { var param = $"exceed-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestSimple", context); }); @@ -46,14 +46,14 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase exception.PolicyName.ShouldBe("TestSimple"); exception.Result.IsAllowed.ShouldBeFalse(); exception.HttpStatusCode.ShouldBe(429); - exception.Code.ShouldBe(AbpOperationRateLimitErrorCodes.ExceedLimit); + exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); } [Fact] public async Task Should_Return_Correct_RemainingCount() { var param = $"remaining-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; var status = await _checker.GetStatusAsync("TestSimple", context); status.IsAllowed.ShouldBeTrue(); @@ -73,13 +73,13 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Return_Correct_RetryAfter() { var param = $"retry-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestSimple", context); }); @@ -100,8 +100,8 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase using (principalAccessor.Change(claimsPrincipal)) { - var checker = scope.ServiceProvider.GetRequiredService(); - var context = new OperationRateLimitContext { Parameter = $"composite-{Guid.NewGuid()}" }; + var checker = scope.ServiceProvider.GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = $"composite-{Guid.NewGuid()}" }; // Should pass: both rules within limits await checker.CheckAsync("TestComposite", context); @@ -123,16 +123,16 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase using (principalAccessor.Change(claimsPrincipal)) { - var checker = scope.ServiceProvider.GetRequiredService(); + var checker = scope.ServiceProvider.GetRequiredService(); var param = $"composite-reject-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; await checker.CheckAsync("TestComposite", context); await checker.CheckAsync("TestComposite", context); await checker.CheckAsync("TestComposite", context); // 4th request: Rule1 (max 3 per hour by parameter) should fail - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await checker.CheckAsync("TestComposite", context); }); @@ -146,14 +146,14 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Reset_Counter() { var param = $"reset-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); // Should be at limit - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestSimple", context); }); @@ -169,12 +169,12 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Use_Custom_ErrorCode() { var param = $"custom-error-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestCustomErrorCode", context); await _checker.CheckAsync("TestCustomErrorCode", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestCustomErrorCode", context); }); @@ -194,7 +194,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase [Fact] public async Task Should_Skip_When_Disabled() { - var options = GetRequiredService>(); + var options = GetRequiredService>(); var originalValue = options.Value.IsEnabled; try @@ -202,7 +202,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase options.Value.IsEnabled = false; var param = $"disabled-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // Should pass unlimited times for (var i = 0; i < 100; i++) @@ -220,7 +220,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Work_With_IsAllowedAsync() { var param = $"is-allowed-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // IsAllowedAsync does not consume quota (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); @@ -245,8 +245,8 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase var param1 = $"param1-{Guid.NewGuid()}"; var param2 = $"param2-{Guid.NewGuid()}"; - var context1 = new OperationRateLimitContext { Parameter = param1 }; - var context2 = new OperationRateLimitContext { Parameter = param2 }; + var context1 = new OperationRateLimitingContext { Parameter = param1 }; + var context2 = new OperationRateLimitingContext { Parameter = param2 }; // Consume all for param1 await _checker.CheckAsync("TestSimple", context1); @@ -262,7 +262,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Support_ExtraProperties_In_Exception_Data() { var param = $"extra-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext + var context = new OperationRateLimitingContext { Parameter = param, ExtraProperties = @@ -276,7 +276,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestSimple", context); }); @@ -291,13 +291,13 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Partition_By_Email_Via_Parameter() { var email = $"email-param-{Guid.NewGuid()}@example.com"; - var context = new OperationRateLimitContext { Parameter = email }; + var context = new OperationRateLimitingContext { Parameter = email }; await _checker.CheckAsync("TestEmailBased", context); await _checker.CheckAsync("TestEmailBased", context); await _checker.CheckAsync("TestEmailBased", context); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestEmailBased", context); }); @@ -315,16 +315,16 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase using (principalAccessor.Change(claimsPrincipal)) { - var checker = scope.ServiceProvider.GetRequiredService(); + var checker = scope.ServiceProvider.GetRequiredService(); // No Parameter set, should fall back to ICurrentUser.Email - var context = new OperationRateLimitContext(); + var context = new OperationRateLimitingContext(); await checker.CheckAsync("TestEmailBased", context); await checker.CheckAsync("TestEmailBased", context); await checker.CheckAsync("TestEmailBased", context); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await checker.CheckAsync("TestEmailBased", context); }); @@ -336,13 +336,13 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Partition_By_PhoneNumber_Via_Parameter() { var phone = $"phone-param-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = phone }; + var context = new OperationRateLimitingContext { Parameter = phone }; await _checker.CheckAsync("TestPhoneNumberBased", context); await _checker.CheckAsync("TestPhoneNumberBased", context); await _checker.CheckAsync("TestPhoneNumberBased", context); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestPhoneNumberBased", context); }); @@ -360,16 +360,16 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase using (principalAccessor.Change(claimsPrincipal)) { - var checker = scope.ServiceProvider.GetRequiredService(); + var checker = scope.ServiceProvider.GetRequiredService(); // No Parameter set, should fall back to ICurrentUser.PhoneNumber - var context = new OperationRateLimitContext(); + var context = new OperationRateLimitingContext(); await checker.CheckAsync("TestPhoneNumberBased", context); await checker.CheckAsync("TestPhoneNumberBased", context); await checker.CheckAsync("TestPhoneNumberBased", context); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await checker.CheckAsync("TestPhoneNumberBased", context); }); @@ -381,7 +381,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Throw_When_Email_Not_Available() { // No Parameter and no authenticated user - var context = new OperationRateLimitContext(); + var context = new OperationRateLimitingContext(); await Assert.ThrowsAsync(async () => { @@ -403,16 +403,16 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase using (principalAccessor.Change(claimsPrincipal)) { - var checker = scope.ServiceProvider.GetRequiredService(); + var checker = scope.ServiceProvider.GetRequiredService(); var param = $"no-waste-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // 2 successful requests (Rule1: 2/5, Rule2: 2/2) await checker.CheckAsync("TestCompositeRule2First", context); await checker.CheckAsync("TestCompositeRule2First", context); // 3rd request: Rule2 blocks (2/2 at max) - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await checker.CheckAsync("TestCompositeRule2First", context); }); @@ -442,7 +442,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase // 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 }; + var context = new OperationRateLimitingContext { Parameter = param }; // 3 successful requests await _checker.CheckAsync("TestCompositeParamIp", context); @@ -450,7 +450,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase await _checker.CheckAsync("TestCompositeParamIp", context); // 4th: IP rule blocks (3/3) - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestCompositeParamIp", context); }); @@ -475,8 +475,8 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase // 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 }; + var context1 = new OperationRateLimitingContext { Parameter = param1 }; + var context2 = new OperationRateLimitingContext { Parameter = param2 }; // 2 requests with param1 await _checker.CheckAsync("TestCompositeParamIp", context1); @@ -486,7 +486,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase await _checker.CheckAsync("TestCompositeParamIp", context2); // 4th request with param2: IP rule blocks (3/3 from combined) - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestCompositeParamIp", context2); }); @@ -516,9 +516,9 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase using (principalAccessor.Change(claimsPrincipal)) { - var checker = scope.ServiceProvider.GetRequiredService(); + var checker = scope.ServiceProvider.GetRequiredService(); var param = $"triple-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // 3 successful requests await checker.CheckAsync("TestCompositeTriple", context); @@ -526,7 +526,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase await checker.CheckAsync("TestCompositeTriple", context); // 4th: IP rule blocks (3/3) - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await checker.CheckAsync("TestCompositeTriple", context); }); @@ -559,9 +559,9 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase using (principalAccessor.Change(claimsPrincipal)) { - var checker = scope.ServiceProvider.GetRequiredService(); + var checker = scope.ServiceProvider.GetRequiredService(); var param = $"triple-nowaste-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // 3 successful requests (all rules increment to 3) await checker.CheckAsync("TestCompositeTriple", context); @@ -571,7 +571,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase // Attempt 3 more blocked requests for (var i = 0; i < 3; i++) { - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await checker.CheckAsync("TestCompositeTriple", context); }); @@ -599,16 +599,16 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase using (principalAccessor.Change(claimsPrincipal)) { - var checker = scope.ServiceProvider.GetRequiredService(); + var checker = scope.ServiceProvider.GetRequiredService(); var param = $"triple-reset-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // Exhaust IP limit await checker.CheckAsync("TestCompositeTriple", context); await checker.CheckAsync("TestCompositeTriple", context); await checker.CheckAsync("TestCompositeTriple", context); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await checker.CheckAsync("TestCompositeTriple", context); }); @@ -633,7 +633,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase public async Task Should_Throw_When_PhoneNumber_Not_Available() { // No Parameter and no authenticated user - var context = new OperationRateLimitContext(); + var context = new OperationRateLimitingContext(); await Assert.ThrowsAsync(async () => { @@ -644,9 +644,9 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase [Fact] public async Task Should_Deny_First_Request_When_MaxCount_Is_Zero() { - var context = new OperationRateLimitContext { Parameter = $"ban-{Guid.NewGuid()}" }; + var context = new OperationRateLimitingContext { Parameter = $"ban-{Guid.NewGuid()}" }; - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestBanPolicy", context); }); @@ -659,7 +659,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase [Fact] public async Task Should_IsAllowed_Return_False_When_MaxCount_Is_Zero() { - var context = new OperationRateLimitContext { Parameter = $"ban-allowed-{Guid.NewGuid()}" }; + var context = new OperationRateLimitingContext { Parameter = $"ban-allowed-{Guid.NewGuid()}" }; var allowed = await _checker.IsAllowedAsync("TestBanPolicy", context); allowed.ShouldBeFalse(); @@ -668,7 +668,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase [Fact] public async Task Should_GetStatus_Show_Not_Allowed_When_MaxCount_Is_Zero() { - var context = new OperationRateLimitContext { Parameter = $"ban-status-{Guid.NewGuid()}" }; + var context = new OperationRateLimitingContext { Parameter = $"ban-status-{Guid.NewGuid()}" }; var status = await _checker.GetStatusAsync("TestBanPolicy", context); status.IsAllowed.ShouldBeFalse(); @@ -684,14 +684,14 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase var param1 = $"op1-{Guid.NewGuid()}"; var param2 = $"op2-{Guid.NewGuid()}"; - var ctx1 = new OperationRateLimitContext { Parameter = param1 }; - var ctx2 = new OperationRateLimitContext { Parameter = param2 }; + var ctx1 = new OperationRateLimitingContext { Parameter = param1 }; + var ctx2 = new OperationRateLimitingContext { Parameter = param2 }; // Exhaust param1's quota (max=2) await _checker.CheckAsync("TestCustomResolver", ctx1); await _checker.CheckAsync("TestCustomResolver", ctx1); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestCustomResolver", ctx1); }); @@ -704,7 +704,7 @@ public class OperationRateLimitChecker_Tests : OperationRateLimitTestBase [Fact] public void Should_Throw_When_Policy_Has_Duplicate_Rules() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); Assert.Throws(() => { diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs similarity index 91% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs index 48a68b876e..6566bbccdb 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingFrontendIntegration_Tests.cs @@ -6,19 +6,19 @@ using Volo.Abp.AspNetCore.ExceptionHandling; using Volo.Abp.Localization; using Xunit; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTestBase +public class OperationRateLimitingFrontendIntegration_Tests : OperationRateLimitingTestBase { - private readonly IOperationRateLimitChecker _checker; + private readonly IOperationRateLimitingChecker _checker; private readonly IExceptionToErrorInfoConverter _errorInfoConverter; - private readonly IOperationRateLimitFormatter _formatter; + private readonly IOperationRateLimitingFormatter _formatter; - public OperationRateLimitFrontendIntegration_Tests() + public OperationRateLimitingFrontendIntegration_Tests() { - _checker = GetRequiredService(); + _checker = GetRequiredService(); _errorInfoConverter = GetRequiredService(); - _formatter = GetRequiredService(); + _formatter = GetRequiredService(); } [Fact] @@ -27,13 +27,13 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes using (CultureHelper.Use("en")) { var param = $"frontend-en-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestSimple", context); }); @@ -52,13 +52,13 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes using (CultureHelper.Use("zh-Hans")) { var param = $"frontend-zh-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestSimple", context); }); @@ -75,7 +75,7 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes public async Task ErrorInfo_Should_Include_Structured_Data_For_Frontend() { var param = $"frontend-data-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext + var context = new OperationRateLimitingContext { Parameter = param, ExtraProperties = @@ -88,7 +88,7 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes await _checker.CheckAsync("TestSimple", context); await _checker.CheckAsync("TestSimple", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestSimple", context); }); @@ -96,7 +96,7 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes var errorInfo = _errorInfoConverter.Convert(exception); // Frontend receives error.code - errorInfo.Code.ShouldBe(AbpOperationRateLimitErrorCodes.ExceedLimit); + errorInfo.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); // Frontend receives error.data for countdown timer and UI display exception.Data["PolicyName"].ShouldBe("TestSimple"); @@ -144,7 +144,7 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes public async Task GetStatusAsync_Should_Provide_Countdown_Data_For_Frontend() { var param = $"frontend-status-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // Before any requests: frontend can show "3 remaining" var status = await _checker.GetStatusAsync("TestSimple", context); @@ -180,12 +180,12 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes public async Task Custom_ErrorCode_Should_Appear_In_ErrorInfo() { var param = $"frontend-custom-code-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestCustomErrorCode", context); await _checker.CheckAsync("TestCustomErrorCode", context); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestCustomErrorCode", context); }); @@ -364,7 +364,7 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes public async Task Reset_Should_Allow_Frontend_To_Resume() { var param = $"frontend-reset-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // Exhaust limit await _checker.CheckAsync("TestSimple", context); @@ -389,7 +389,7 @@ public class OperationRateLimitFrontendIntegration_Tests : OperationRateLimitTes public async Task IsAllowedAsync_Can_Be_Used_For_Frontend_PreCheck() { var param = $"frontend-precheck-{Guid.NewGuid()}"; - var context = new OperationRateLimitContext { Parameter = param }; + var context = new OperationRateLimitingContext { Parameter = param }; // Frontend precheck: button should be enabled (await _checker.IsAllowedAsync("TestSimple", context)).ShouldBeTrue(); diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs similarity index 75% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs index 5ec3ad2ae3..fbd1a17059 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs @@ -5,24 +5,24 @@ using Shouldly; using Volo.Abp.MultiTenancy; using Xunit; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; /// /// Verifies per-tenant isolation for tenant-scoped partition types and /// global (cross-tenant) sharing for ClientIp partition type. /// -public class OperationRateLimitMultiTenant_Tests : OperationRateLimitTestBase +public class OperationRateLimitingMultiTenant_Tests : OperationRateLimitingTestBase { private readonly ICurrentTenant _currentTenant; - private readonly IOperationRateLimitChecker _checker; + private readonly IOperationRateLimitingChecker _checker; private static readonly Guid TenantA = Guid.NewGuid(); private static readonly Guid TenantB = Guid.NewGuid(); - public OperationRateLimitMultiTenant_Tests() + public OperationRateLimitingMultiTenant_Tests() { _currentTenant = GetRequiredService(); - _checker = GetRequiredService(); + _checker = GetRequiredService(); } [Fact] @@ -33,12 +33,12 @@ public class OperationRateLimitMultiTenant_Tests : OperationRateLimitTestBase using (_currentTenant.Change(TenantA)) { - var ctx = new OperationRateLimitContext { Parameter = param }; + var ctx = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestMultiTenantByParameter", ctx); await _checker.CheckAsync("TestMultiTenantByParameter", ctx); // Tenant A exhausted (max=2) - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestMultiTenantByParameter", ctx); }); @@ -46,7 +46,7 @@ public class OperationRateLimitMultiTenant_Tests : OperationRateLimitTestBase using (_currentTenant.Change(TenantB)) { - var ctx = new OperationRateLimitContext { Parameter = param }; + var ctx = new OperationRateLimitingContext { Parameter = param }; // Tenant B has its own counter and should still be allowed await _checker.CheckAsync("TestMultiTenantByParameter", ctx); @@ -63,17 +63,17 @@ public class OperationRateLimitMultiTenant_Tests : OperationRateLimitTestBase using (_currentTenant.Change(TenantA)) { - var ctx = new OperationRateLimitContext(); + var ctx = new OperationRateLimitingContext(); await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); } using (_currentTenant.Change(TenantB)) { - var ctx = new OperationRateLimitContext(); + var ctx = new OperationRateLimitingContext(); // Tenant B shares the same IP counter; should be at limit now - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestMultiTenantByClientIp", ctx); }); @@ -87,10 +87,10 @@ public class OperationRateLimitMultiTenant_Tests : OperationRateLimitTestBase var param = $"host-vs-tenant-{Guid.NewGuid()}"; // Host context: exhaust quota - var hostCtx = new OperationRateLimitContext { Parameter = param }; + var hostCtx = new OperationRateLimitingContext { Parameter = param }; await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { await _checker.CheckAsync("TestMultiTenantByParameter", hostCtx); }); @@ -98,7 +98,7 @@ public class OperationRateLimitMultiTenant_Tests : OperationRateLimitTestBase // Tenant A should have its own counter, unaffected by host using (_currentTenant.Change(TenantA)) { - var tenantCtx = new OperationRateLimitContext { Parameter = param }; + var tenantCtx = new OperationRateLimitingContext { 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.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs similarity index 85% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder_Tests.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs index 76dac315ae..56347cf53b 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs @@ -2,14 +2,14 @@ using System; using Shouldly; using Xunit; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitPolicyBuilder_Tests +public class OperationRateLimitingPolicyBuilder_Tests { [Fact] public void Should_Build_Simple_Policy() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); options.AddPolicy("TestPolicy", policy => { policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) @@ -22,14 +22,14 @@ public class OperationRateLimitPolicyBuilder_Tests 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.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); policy.ErrorCode.ShouldBeNull(); } [Fact] public void Should_Build_Composite_Policy() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); options.AddPolicy("CompositePolicy", policy => { policy.AddRule(rule => rule @@ -45,16 +45,16 @@ public class OperationRateLimitPolicyBuilder_Tests policy.Name.ShouldBe("CompositePolicy"); policy.Rules.Count.ShouldBe(2); - policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.Parameter); + policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); policy.Rules[0].MaxCount.ShouldBe(3); - policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitPartitionType.CurrentUser); + policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); policy.Rules[1].MaxCount.ShouldBe(10); } [Fact] public void Should_Set_ErrorCode() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); options.AddPolicy("ErrorPolicy", policy => { policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) @@ -69,7 +69,7 @@ public class OperationRateLimitPolicyBuilder_Tests [Fact] public void Should_Build_Custom_Partition() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); options.AddPolicy("CustomPolicy", policy => { policy.AddRule(rule => rule @@ -80,14 +80,14 @@ public class OperationRateLimitPolicyBuilder_Tests var policy = options.Policies["CustomPolicy"]; policy.Rules.Count.ShouldBe(1); - policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.Custom); + policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); policy.Rules[0].CustomPartitionKeyResolver.ShouldNotBeNull(); } [Fact] public void Should_Support_All_Partition_Types() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); options.AddPolicy("P1", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByParameter()); options.AddPolicy("P2", p => p.WithFixedWindow(TimeSpan.FromHours(1), 1).PartitionByCurrentUser()); @@ -96,18 +96,18 @@ public class OperationRateLimitPolicyBuilder_Tests 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); + options.Policies["P1"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Parameter); + options.Policies["P2"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentUser); + options.Policies["P3"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.CurrentTenant); + options.Policies["P4"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.ClientIp); + options.Policies["P5"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Email); + options.Policies["P6"].Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.PhoneNumber); } [Fact] public void Should_Throw_When_Policy_Has_No_Rules() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); var exception = Assert.Throws(() => { @@ -123,7 +123,7 @@ public class OperationRateLimitPolicyBuilder_Tests [Fact] public void Should_Throw_When_WithFixedWindow_Without_PartitionBy() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); var exception = Assert.Throws(() => { @@ -140,7 +140,7 @@ public class OperationRateLimitPolicyBuilder_Tests [Fact] public void Should_Throw_When_AddRule_Without_WithFixedWindow() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); var exception = Assert.Throws(() => { @@ -159,7 +159,7 @@ public class OperationRateLimitPolicyBuilder_Tests [Fact] public void Should_Allow_MaxCount_Zero_For_Ban_Policy() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); // maxCount=0 is a valid "ban" policy - always deny options.AddPolicy("BanPolicy", policy => @@ -175,7 +175,7 @@ public class OperationRateLimitPolicyBuilder_Tests [Fact] public void Should_Throw_When_AddRule_Without_PartitionBy() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); var exception = Assert.Throws(() => { @@ -193,7 +193,7 @@ public class OperationRateLimitPolicyBuilder_Tests [Fact] public void Should_Throw_When_MaxCount_Is_Negative() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); var exception = Assert.Throws(() => { @@ -210,7 +210,7 @@ public class OperationRateLimitPolicyBuilder_Tests [Fact] public void Should_Allow_Same_Rule_With_Different_MultiTenancy() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); // Same Duration/MaxCount/PartitionType but different IsMultiTenant should be allowed options.AddPolicy("MultiTenancyPolicy", policy => @@ -234,7 +234,7 @@ public class OperationRateLimitPolicyBuilder_Tests [Fact] public void Should_Allow_Multiple_Custom_Partition_Rules() { - var options = new AbpOperationRateLimitOptions(); + var options = new AbpOperationRateLimitingOptions(); // Multiple custom partition rules with same Duration/MaxCount should be allowed // because they may use different key resolvers @@ -251,7 +251,7 @@ public class OperationRateLimitPolicyBuilder_Tests var policy = options.Policies["MultiCustomPolicy"]; policy.Rules.Count.ShouldBe(2); - policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitPartitionType.Custom); - policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitPartitionType.Custom); + policy.Rules[0].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); + policy.Rules[1].PartitionType.ShouldBe(OperationRateLimitingPartitionType.Custom); } } diff --git a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs similarity index 54% rename from framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs rename to framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs index 3139024e9d..4316437e57 100644 --- a/framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingTestBase.cs @@ -1,8 +1,8 @@ using Volo.Abp.Testing; -namespace Volo.Abp.OperationRateLimit; +namespace Volo.Abp.OperationRateLimiting; -public class OperationRateLimitTestBase : AbpIntegratedTest +public class OperationRateLimitingTestBase : AbpIntegratedTest { protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) { From 46b283e3388ff6c0deb927ee9ddff1e2c60e1185 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 15:35:12 +0800 Subject: [PATCH 06/15] refactor: update partition key handling to support async resolution in operation rate limiting --- .../Checker/OperationRateLimitingContext.cs | 5 +++-- .../OperationRateLimitingRuleBuilder.cs | 15 +++++++------ .../OperationRateLimitingRuleDefinition.cs | 3 ++- .../FixedWindowOperationRateLimitingRule.cs | 21 +++++++++++-------- .../AbpOperationRateLimitingTestModule.cs | 3 ++- ...perationRateLimitingPolicyBuilder_Tests.cs | 7 ++++--- 6 files changed, 32 insertions(+), 22 deletions(-) diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs index 3f408b8240..f75c06b05b 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingContext.cs @@ -8,8 +8,9 @@ public class OperationRateLimitingContext { /// /// Optional parameter passed by the caller. - /// Used by rules configured with PartitionByParameter(). - /// Can be email, phone number, resource id, or any string. + /// Used as the partition key by PartitionByParameter() (required), + /// and as a fallback by PartitionByEmail() and PartitionByPhoneNumber(). + /// Can be email, phone number, user id, resource id, or any string. /// public string? Parameter { get; set; } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs index 82ed356f01..b9f2eacf9a 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; namespace Volo.Abp.OperationRateLimiting; @@ -8,7 +9,7 @@ public class OperationRateLimitingRuleBuilder private TimeSpan _duration; private int _maxCount; private OperationRateLimitingPartitionType? _partitionType; - private Func? _customPartitionKeyResolver; + private Func>? _customPartitionKeyResolver; private bool _isMultiTenant; internal bool IsCommitted { get; private set; } @@ -43,7 +44,8 @@ public class OperationRateLimitingRuleBuilder } /// - /// Auto resolve from ICurrentUser.Id. + /// Partition by the current authenticated user (ICurrentUser.Id). + /// Use PartitionByParameter() if you need to specify the user ID explicitly. /// public OperationRateLimitingPolicyBuilder PartitionByCurrentUser() { @@ -53,7 +55,7 @@ public class OperationRateLimitingRuleBuilder } /// - /// Auto resolve from ICurrentTenant.Id. + /// Partition by the current tenant (ICurrentTenant.Id). Uses "host" when no tenant is active. /// public OperationRateLimitingPolicyBuilder PartitionByCurrentTenant() { @@ -63,7 +65,8 @@ public class OperationRateLimitingRuleBuilder } /// - /// Auto resolve from IWebClientInfoProvider.ClientIpAddress. + /// Partition by the client IP address (IWebClientInfoProvider.ClientIpAddress). + /// Use PartitionByParameter() if you need to specify the IP explicitly. /// public OperationRateLimitingPolicyBuilder PartitionByClientIp() { @@ -95,10 +98,10 @@ public class OperationRateLimitingRuleBuilder } /// - /// Custom partition key resolver from context. + /// Custom async partition key resolver from context. /// public OperationRateLimitingPolicyBuilder PartitionBy( - Func keyResolver) + Func> keyResolver) { _partitionType = OperationRateLimitingPartitionType.Custom; _customPartitionKeyResolver = Check.NotNull(keyResolver, nameof(keyResolver)); diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs index a3415105d9..f8d1bcf9e7 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Policies/OperationRateLimitingRuleDefinition.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; namespace Volo.Abp.OperationRateLimiting; @@ -10,7 +11,7 @@ public class OperationRateLimitingRuleDefinition public OperationRateLimitingPartitionType PartitionType { get; set; } - public Func? CustomPartitionKeyResolver { get; set; } + public Func>? CustomPartitionKeyResolver { get; set; } public bool IsMultiTenant { get; set; } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs index af072bc8c4..7df79a0052 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs @@ -38,7 +38,7 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule public virtual async Task AcquireAsync( OperationRateLimitingContext context) { - var partitionKey = ResolvePartitionKey(context); + var partitionKey = await ResolvePartitionKeyAsync(context); var storeKey = BuildStoreKey(partitionKey); var storeResult = await Store.IncrementAsync(storeKey, Definition.Duration, Definition.MaxCount); @@ -48,7 +48,7 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule public virtual async Task CheckAsync( OperationRateLimitingContext context) { - var partitionKey = ResolvePartitionKey(context); + var partitionKey = await ResolvePartitionKeyAsync(context); var storeKey = BuildStoreKey(partitionKey); var storeResult = await Store.GetAsync(storeKey, Definition.Duration, Definition.MaxCount); @@ -57,12 +57,12 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule public virtual async Task ResetAsync(OperationRateLimitingContext context) { - var partitionKey = ResolvePartitionKey(context); + var partitionKey = await ResolvePartitionKeyAsync(context); var storeKey = BuildStoreKey(partitionKey); await Store.ResetAsync(storeKey); } - protected virtual string ResolvePartitionKey(OperationRateLimitingContext context) + protected virtual async Task ResolvePartitionKeyAsync(OperationRateLimitingContext context) { return Definition.PartitionType switch { @@ -71,17 +71,20 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule $"OperationRateLimitingContext.Parameter is required for policy '{PolicyName}' (PartitionByParameter)."), OperationRateLimitingPartitionType.CurrentUser => - CurrentUser.Id?.ToString() ?? throw new AbpException( - $"Current user is not authenticated. Policy '{PolicyName}' requires PartitionByCurrentUser."), + CurrentUser.Id?.ToString() + ?? throw new AbpException( + $"Current user is not authenticated. Policy '{PolicyName}' requires PartitionByCurrentUser. " + + "Use PartitionByParameter() if you need to specify the user ID explicitly."), OperationRateLimitingPartitionType.CurrentTenant => - CurrentTenant.Id?.ToString() ?? HostTenantKey, + CurrentTenant.Id?.ToString() + ?? HostTenantKey, OperationRateLimitingPartitionType.ClientIp => WebClientInfoProvider.ClientIpAddress ?? throw new AbpException( $"Client IP address could not be determined. Policy '{PolicyName}' requires PartitionByClientIp. " + - "Ensure IWebClientInfoProvider is properly configured."), + "Ensure IWebClientInfoProvider is properly configured or use PartitionByParameter() to pass the IP explicitly."), OperationRateLimitingPartitionType.Email => context.Parameter @@ -96,7 +99,7 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule $"Phone number is required for policy '{PolicyName}' (PartitionByPhoneNumber). Provide it via context.Parameter or ensure the user has a phone number."), OperationRateLimitingPartitionType.Custom => - Definition.CustomPartitionKeyResolver!(context), + await Definition.CustomPartitionKeyResolver!(context), _ => throw new AbpException($"Unknown partition type: {Definition.PartitionType}") }; diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs index 6bee2b7d83..7a43716927 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Volo.Abp.AspNetCore.WebClientInfo; @@ -157,7 +158,7 @@ public class AbpOperationRateLimitingTestModule : AbpModule options.AddPolicy("TestCustomResolver", policy => { policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) - .PartitionBy(ctx => $"action:{ctx.Parameter}"); + .PartitionBy(ctx => Task.FromResult($"action:{ctx.Parameter}")); }); // Multi-tenant: ByParameter with tenant isolation - same param, different tenants = different counters diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs index 56347cf53b..6a503a6191 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingPolicyBuilder_Tests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using Shouldly; using Xunit; @@ -74,7 +75,7 @@ public class OperationRateLimitingPolicyBuilder_Tests { policy.AddRule(rule => rule .WithFixedWindow(TimeSpan.FromMinutes(30), maxCount: 5) - .PartitionBy(ctx => $"custom:{ctx.Parameter}")); + .PartitionBy(ctx => Task.FromResult($"custom:{ctx.Parameter}"))); }); var policy = options.Policies["CustomPolicy"]; @@ -242,11 +243,11 @@ public class OperationRateLimitingPolicyBuilder_Tests { policy.AddRule(rule => rule .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionBy(ctx => $"by-ip:{ctx.Parameter}")); + .PartitionBy(ctx => Task.FromResult($"by-ip:{ctx.Parameter}"))); policy.AddRule(rule => rule .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) - .PartitionBy(ctx => $"by-device:{ctx.Parameter}")); + .PartitionBy(ctx => Task.FromResult($"by-device:{ctx.Parameter}"))); }); var policy = options.Policies["MultiCustomPolicy"]; From 1229b2b2ee382e5ea3f79841ee9e9041743aac89 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 15:44:55 +0800 Subject: [PATCH 07/15] feat: add Operation Rate Limiting section to documentation --- docs/en/docs-nav.json | 4 + .../infrastructure/operation-rate-limiting.md | 480 ++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 docs/en/framework/infrastructure/operation-rate-limiting.md diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 51b3c3e889..f84892243b 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -721,6 +721,10 @@ "text": "Distributed Locking", "path": "framework/infrastructure/distributed-locking.md" }, + { + "text": "Operation Rate Limiting", + "path": "framework/infrastructure/operation-rate-limiting.md" + }, { "text": "Email Sending", "items": [ diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md new file mode 100644 index 0000000000..8859d4a0a1 --- /dev/null +++ b/docs/en/framework/infrastructure/operation-rate-limiting.md @@ -0,0 +1,480 @@ +````json +//[doc-seo] +{ + "Description": "Learn how to use the Operation Rate Limiting module in ABP Framework to control the frequency of specific operations like SMS sending, login attempts, and resource-intensive tasks." +} +```` + +# Operation Rate Limiting + +ABP provides an operation rate limiting system that allows you to control the frequency of specific operations in your application. You may need operation rate limiting for several reasons: + +* Do not allow sending an SMS verification code to the same phone number more than 3 times in an hour. +* Do not allow generating a "monthly sales report" more than 2 times per day for each user (if generating the report is resource-intensive). +* Restrict login attempts per IP address to prevent brute-force attacks. + +> This is not for [ASP.NET Core's built-in rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) which works at the HTTP request pipeline level. This module works at the **application/domain code level** and is called explicitly from your services. See the [Combining with ASP.NET Core Rate Limiting](#combining-with-aspnet-core-rate-limiting) section for a comparison. + +## Installation + +You can open a command-line terminal and type the following command to install the [Volo.Abp.OperationRateLimiting](https://www.nuget.org/packages/Volo.Abp.OperationRateLimiting) package into your project: + +````bash +abp add-package Volo.Abp.OperationRateLimiting +```` + +> If you haven't done it yet, you first need to install the [ABP CLI](../../../cli). + +## Quick Start + +This section shows the basic usage of the operation rate limiting system with a simple example. + +### Defining a Policy + +First, define a rate limiting policy in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md): + +````csharp +Configure(options => +{ + options.AddPolicy("SendSmsCode", policy => + { + policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter(); + }); +}); +```` + +* `"SendSmsCode"` is a unique name for this policy. +* `WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1)` means at most **1 request per minute**. +* `PartitionByParameter()` means the counter is keyed by the parameter you pass at check time (e.g., a phone number), so different phone numbers have independent counters. + +### Checking the Limit + +Then inject `IOperationRateLimitingChecker` and call `CheckAsync` in your service: + +````csharp +public class SmsAppService : ApplicationService +{ + private readonly IOperationRateLimitingChecker _rateLimitChecker; + + public SmsAppService(IOperationRateLimitingChecker rateLimitChecker) + { + _rateLimitChecker = rateLimitChecker; + } + + public async Task SendCodeAsync(string phoneNumber) + { + await _rateLimitChecker.CheckAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); + + // If we reach here, the limit was not exceeded. + // Send the SMS code... + } +} +```` + +* `CheckAsync` increments the counter and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded. +* Each phone number has its own counter because we used `PartitionByParameter()`. + +That's the basic usage. The following sections explain each concept in detail. + +## Defining Policies + +Policies are defined using `AbpOperationRateLimitingOptions` in the `ConfigureServices` method of your [module class](../../architecture/modularity/basics.md). Each policy has a unique name, one or more rules, and a partition strategy. + +### Single-Rule Policies + +For simple scenarios, use the `WithFixedWindow` shortcut directly on the policy builder: + +````csharp +options.AddPolicy("SendSmsCode", policy => +{ + policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter(); +}); +```` + +### Multi-Rule Policies + +Use `AddRule` to combine multiple rules. All rules are checked together (**AND** logic) — a request is allowed only when **all** rules pass: + +````csharp +options.AddPolicy("Login", policy => +{ + // Rule 1: Max 5 attempts per 5 minutes per username + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5) + .PartitionByParameter()); + + // Rule 2: Max 20 attempts per hour per IP + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) + .PartitionByClientIp()); +}); +```` + +> When multiple rules are present, the module uses a **two-phase check**: it first verifies all rules without incrementing counters, then increments only if all rules pass. This prevents wasted quota when one rule would block the request. + +### Custom Error Code + +By default, the exception uses the error code `Volo.Abp.OperationRateLimiting:010001`. You can override it per policy: + +````csharp +options.AddPolicy("SendSmsCode", policy => +{ + policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter() + .WithErrorCode("App:SmsCodeLimit"); +}); +```` + +## Partition Types + +Each rule must specify a **partition type** that determines how requests are grouped. Requests with different partition keys have independent counters. + +### PartitionByParameter + +Uses the `Parameter` value from the context you pass to `CheckAsync`: + +````csharp +policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter(); + +// Each phone number has its own counter +await checker.CheckAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); +```` + +### PartitionByCurrentUser + +Uses `ICurrentUser.Id` as the partition key. The user must be authenticated: + +````csharp +policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 10) + .PartitionByCurrentUser(); +```` + +> If you need to check rate limits for a specific user (e.g., admin checking another user's limit), use `PartitionByParameter()` and pass the user ID as the `Parameter`. + +### PartitionByCurrentTenant + +Uses `ICurrentTenant.Id` as the partition key. Uses `"host"` for the host side when no tenant is active: + +````csharp +policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionByCurrentTenant(); +```` + +### PartitionByClientIp + +Uses `IWebClientInfoProvider.ClientIpAddress` as the partition key: + +````csharp +policy.WithFixedWindow(TimeSpan.FromMinutes(15), maxCount: 10) + .PartitionByClientIp(); +```` + +> This requires an ASP.NET Core environment. In non-web scenarios, the IP address cannot be determined and an exception will be thrown. Use `PartitionByParameter()` if you need to pass the IP explicitly. + +### PartitionByEmail + +Resolves from `context.Parameter` first, then falls back to `ICurrentUser.Email`: + +````csharp +policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByEmail(); + +// For unauthenticated users, pass the email explicitly: +await checker.CheckAsync("SendEmailCode", + new OperationRateLimitingContext { Parameter = email }); +```` + +### PartitionByPhoneNumber + +Works the same way as `PartitionByEmail`: resolves from `context.Parameter` first, then falls back to `ICurrentUser.PhoneNumber`. + +### Custom Partition (PartitionBy) + +You can provide a custom async function to generate the partition key. The async signature allows you to perform database queries or other I/O operations: + +````csharp +policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionBy(ctx => Task.FromResult( + $"{ctx.Parameter}:{ctx.ExtraProperties["DeviceId"]}")); +```` + +## Multi-Tenancy + +By default, rate limit counters are **shared across all tenants**. You can enable tenant isolation for a rule by calling `WithMultiTenancy()`: + +````csharp +policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .WithMultiTenancy() + .PartitionByParameter()); +```` + +When multi-tenancy is enabled, the cache key includes the tenant ID, so each tenant has independent counters: + +* **Global key format:** `orl:{PolicyName}:{RuleKey}:{PartitionKey}` +* **Tenant-isolated key format:** `orl:t:{TenantId}:{PolicyName}:{RuleKey}:{PartitionKey}` + +## Checking the Limit + +Inject `IOperationRateLimitingChecker` to interact with rate limits. It provides four methods: + +### CheckAsync + +The primary method. It checks the rate limit and **increments the counter** if allowed. Throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded: + +````csharp +await checker.CheckAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); +```` + +### IsAllowedAsync + +A read-only check that returns `true` or `false` **without incrementing** the counter. Useful for UI pre-checks (e.g., disabling a button before the user clicks): + +````csharp +var isAllowed = await checker.IsAllowedAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); +```` + +### GetStatusAsync + +Returns detailed status information **without incrementing** the counter: + +````csharp +var status = await checker.GetStatusAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); + +// status.IsAllowed - whether the next request would be allowed +// status.RemainingCount - how many requests are left in this window +// status.RetryAfter - time until the window resets +// status.MaxCount - maximum allowed count +// status.CurrentCount - current usage count +```` + +### ResetAsync + +Resets the counter for a specific policy and context. This can be useful for administrative operations: + +````csharp +await checker.ResetAsync("SendSmsCode", + new OperationRateLimitingContext { Parameter = phoneNumber }); +```` + +## The Exception + +When a rate limit is exceeded, `CheckAsync` throws `AbpOperationRateLimitingException`. This exception: + +* Extends `BusinessException` and implements `IHasHttpStatusCode` with status code **429** (Too Many Requests). +* Is automatically handled by ABP's exception handling pipeline and serialized into the HTTP response. + +The exception includes the following data properties: + +| Key | Type | Description | +|-----|------|-------------| +| `PolicyName` | string | Name of the triggered policy | +| `MaxCount` | int | Maximum allowed count | +| `CurrentCount` | int | Current usage count | +| `RemainingCount` | int | Remaining allowed count | +| `RetryAfterSeconds` | int | Seconds until the window resets | +| `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes") | +| `WindowDurationSeconds` | int | Total window duration in seconds | +| `WindowDescription` | string | Localized window description | +| `RuleDetails` | List | Per-rule details (for multi-rule policies) | + +## Configuration + +### AbpOperationRateLimitingOptions + +`AbpOperationRateLimitingOptions` is the main options class for the operation rate limiting system: + +````csharp +Configure(options => +{ + options.IsEnabled = true; + options.LockTimeout = TimeSpan.FromSeconds(5); +}); +```` + +* **`IsEnabled`** (`bool`, default: `true`): Global switch to enable or disable rate limiting. When set to `false`, all `CheckAsync` calls pass through without checking. This is useful for disabling rate limiting in development (see [below](#disabling-in-development)). +* **`LockTimeout`** (`TimeSpan`, default: `5 seconds`): Timeout for acquiring the distributed lock during counter increment operations. + +## Advanced Usage + +### Disabling in Development + +You may want to disable rate limiting during development to avoid being blocked while testing: + +````csharp +public override void ConfigureServices(ServiceConfigurationContext context) +{ + var hostEnvironment = context.Services.GetHostingEnvironment(); + + Configure(options => + { + if (hostEnvironment.IsDevelopment()) + { + options.IsEnabled = false; + } + }); +} +```` + +### Ban Policy (maxCount: 0) + +Setting `maxCount` to `0` creates a ban policy that blocks all requests for the specified duration: + +````csharp +options.AddPolicy("BlockedUser", policy => +{ + policy.WithFixedWindow(TimeSpan.FromHours(24), maxCount: 0) + .PartitionByParameter(); +}); +```` + +### Passing Extra Properties + +Use `ExtraProperties` on `OperationRateLimitingContext` to pass additional context data. These values are available in custom partition resolvers and are included in the exception data when the limit is exceeded: + +````csharp +await checker.CheckAsync("ApiCall", new OperationRateLimitingContext +{ + Parameter = apiEndpoint, + ExtraProperties = + { + ["DeviceId"] = deviceId, + ["ClientVersion"] = clientVersion + } +}); +```` + +### Pre-checking Before Expensive Operations + +Use `IsAllowedAsync` or `GetStatusAsync` to check the limit **before** performing expensive work (e.g., validating input or querying the database): + +````csharp +public async Task SendCodeAsync(string phoneNumber) +{ + var context = new OperationRateLimitingContext { Parameter = phoneNumber }; + + // Check limit before doing any work + var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", context); + + if (!status.IsAllowed) + { + return new SendCodeResultDto + { + Success = false, + RetryAfterSeconds = (int)(status.RetryAfter?.TotalSeconds ?? 0) + }; + } + + // Now do the actual work and increment the counter + await _rateLimitChecker.CheckAsync("SendSmsCode", context); + + await _smsSender.SendAsync(phoneNumber, GenerateCode()); + return new SendCodeResultDto { Success = true }; +} +```` + +> `IsAllowedAsync` and `GetStatusAsync` are read-only — they do not increment the counter. Only `CheckAsync` increments. + +### Checking on Behalf of Another User + +`PartitionByCurrentUser()`, `PartitionByCurrentTenant()`, and `PartitionByClientIp()` always resolve from their respective services (`ICurrentUser`, `ICurrentTenant`, `IWebClientInfoProvider`) and do not accept explicit overrides. This design avoids partition key conflicts in [composite policies](#multi-rule-policies) where `Parameter` is shared across all rules. + +If you need to check or enforce rate limits for a **specific user, tenant, or IP**, define the policy with `PartitionByParameter()` and pass the value explicitly: + +````csharp +// Policy definition: use PartitionByParameter for explicit control +options.AddPolicy("UserApiLimit", policy => +{ + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) + .PartitionByParameter(); +}); +```` + +````csharp +// Check current user's limit +await checker.CheckAsync("UserApiLimit", + new OperationRateLimitingContext { Parameter = CurrentUser.Id.ToString() }); + +// Admin checking another user's limit +await checker.CheckAsync("UserApiLimit", + new OperationRateLimitingContext { Parameter = targetUserId.ToString() }); + +// Check a specific IP in a background job +await checker.CheckAsync("UserApiLimit", + new OperationRateLimitingContext { Parameter = ipAddress }); +```` + +This approach gives you full flexibility while keeping the API simple — `PartitionByCurrentUser()` is a convenience shortcut for "always use the current authenticated user", and `PartitionByParameter()` is for "I want to specify the value explicitly". + +### Combining with ASP.NET Core Rate Limiting + +This module and ASP.NET Core's built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) serve different purposes and can be used together: + +| | ASP.NET Core Rate Limiting | Operation Rate Limiting | +|---|---|---| +| **Level** | HTTP request pipeline | Application/domain code | +| **Scope** | All incoming requests | Specific business operations | +| **Usage** | Middleware (automatic) | Explicit `CheckAsync` calls | +| **Typical use** | API throttling, DDoS protection | Business logic limits (SMS, reports) | + +A common pattern is to use ASP.NET Core middleware for broad API protection and this module for fine-grained business operation limits. + +## Extensibility + +### Custom Store + +The default store uses ABP's `IDistributedCache`. You can replace it by implementing `IOperationRateLimitingStore`: + +````csharp +public class MyCustomStore : IOperationRateLimitingStore, ITransientDependency +{ + public Task IncrementAsync( + string key, TimeSpan duration, int maxCount) + { + // Your custom implementation (e.g., Redis Lua script for atomicity) + } + + public Task GetAsync( + string key, TimeSpan duration, int maxCount) + { + // Read-only check + } + + public Task ResetAsync(string key) + { + // Reset the counter + } +} +```` + +ABP's [dependency injection](../../fundamentals/dependency-injection.md) system will automatically use your implementation since it replaces the default one. + +### Custom Rule + +You can implement custom rate limiting algorithms (e.g., sliding window, token bucket) by implementing `IOperationRateLimitingRule` and registering it with `AddRule()`: + +````csharp +policy.AddRule(); +```` + +### Custom Formatter + +Replace `IOperationRateLimitingFormatter` to customize how time durations are displayed in error messages (e.g., "5 minutes", "2 hours 30 minutes"). + +### Custom Policy Provider + +Replace `IOperationRateLimitingPolicyProvider` to load policies from a database or external configuration source instead of the in-memory options. + +## See Also + +* [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) +* [Distributed Caching](../../fundamentals/caching.md) +* [Exception Handling](../../fundamentals/exception-handling.md) From 11b99c1f92fb238a80ac6e4eaedc27841bb123fb Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 15:46:59 +0800 Subject: [PATCH 08/15] Fix relative links to fundamentals docs --- docs/en/framework/infrastructure/operation-rate-limiting.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md index 8859d4a0a1..45139b1313 100644 --- a/docs/en/framework/infrastructure/operation-rate-limiting.md +++ b/docs/en/framework/infrastructure/operation-rate-limiting.md @@ -476,5 +476,5 @@ Replace `IOperationRateLimitingPolicyProvider` to load policies from a database ## See Also * [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) -* [Distributed Caching](../../fundamentals/caching.md) -* [Exception Handling](../../fundamentals/exception-handling.md) +* [Distributed Caching](../fundamentals/caching.md) +* [Exception Handling](../fundamentals/exception-handling.md) From 40bc80ab052be5cbcb7db8a413631dace95397e3 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 16:47:25 +0800 Subject: [PATCH 09/15] fix: update OperationRateLimit to OperationRateLimiting in project references --- .../OperationRateLimitingMultiTenant_Tests.cs | 2 +- nupkg/common.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs index fbd1a17059..b55ffbc966 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingMultiTenant_Tests.cs @@ -59,7 +59,7 @@ public class OperationRateLimitingMultiTenant_Tests : OperationRateLimitingTestB { // 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. + // The mock IWebClientInfoProvider returns "127.0.0.1" for all requests. using (_currentTenant.Change(TenantA)) { diff --git a/nupkg/common.ps1 b/nupkg/common.ps1 index edc5374c28..cb356faee4 100644 --- a/nupkg/common.ps1 +++ b/nupkg/common.ps1 @@ -237,7 +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.OperationRateLimiting", "framework/src/Volo.Abp.Quartz", "framework/src/Volo.Abp.RabbitMQ", "framework/src/Volo.Abp.RemoteServices", From fdda4da461fa4de1bc65d99a3e7182c58b134e22 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 17:11:42 +0800 Subject: [PATCH 10/15] fix: update RetryAfter handling to return null for ban policies and add custom resolver null check --- .../infrastructure/operation-rate-limiting.md | 6 ++++-- .../Rules/FixedWindowOperationRateLimitingRule.cs | 14 +++++++++++++- .../DistributedCacheOperationRateLimitingStore.cs | 4 ++-- .../AbpOperationRateLimitingTestModule.cs | 7 +++++++ ...ibutedCacheOperationRateLimitingStore_Tests.cs | 4 ++-- .../OperationRateLimitingChecker_Tests.cs | 15 +++++++++++++++ 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md index 45139b1313..bdc55f1c7e 100644 --- a/docs/en/framework/infrastructure/operation-rate-limiting.md +++ b/docs/en/framework/infrastructure/operation-rate-limiting.md @@ -205,7 +205,9 @@ policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 100) ## Multi-Tenancy -By default, rate limit counters are **shared across all tenants**. You can enable tenant isolation for a rule by calling `WithMultiTenancy()`: +By default, partition keys do not include tenant information — for partition types like `PartitionByParameter`, `PartitionByCurrentUser`, `PartitionByClientIp`, etc., counters are shared across tenants unless you call `WithMultiTenancy()`. Note that `PartitionByCurrentTenant()` is inherently per-tenant since the partition key is the tenant ID itself, and `PartitionByClientIp()` is typically kept global since the same IP should share a counter regardless of tenant. + +You can enable tenant isolation for a rule by calling `WithMultiTenancy()`: ````csharp policy.AddRule(rule => rule @@ -326,7 +328,7 @@ public override void ConfigureServices(ServiceConfigurationContext context) ### Ban Policy (maxCount: 0) -Setting `maxCount` to `0` creates a ban policy that blocks all requests for the specified duration: +Setting `maxCount` to `0` creates a ban policy that permanently denies all requests regardless of the window duration. The `RetryAfter` value will be `null` since there is no window to wait for: ````csharp options.AddPolicy("BlockedUser", policy => diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs index 7df79a0052..cd2dd4db32 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs @@ -99,12 +99,24 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule $"Phone number is required for policy '{PolicyName}' (PartitionByPhoneNumber). Provide it via context.Parameter or ensure the user has a phone number."), OperationRateLimitingPartitionType.Custom => - await Definition.CustomPartitionKeyResolver!(context), + await ResolveCustomPartitionKeyAsync(context), _ => throw new AbpException($"Unknown partition type: {Definition.PartitionType}") }; } + protected virtual async Task ResolveCustomPartitionKeyAsync(OperationRateLimitingContext context) + { + var key = await Definition.CustomPartitionKeyResolver!(context); + if (string.IsNullOrEmpty(key)) + { + throw new AbpException( + $"Custom partition key resolver returned null or empty for policy '{PolicyName}'. " + + "The resolver must return a non-empty string."); + } + return key; + } + protected virtual string BuildStoreKey(string partitionKey) { // Stable rule descriptor based on content so reordering rules does not change the key. diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs index ca64981e3b..d9f13b41d1 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Store/DistributedCacheOperationRateLimitingStore.cs @@ -38,7 +38,7 @@ public class DistributedCacheOperationRateLimitingStore : IOperationRateLimiting IsAllowed = false, CurrentCount = 0, MaxCount = maxCount, - RetryAfter = duration + RetryAfter = null }; } @@ -111,7 +111,7 @@ public class DistributedCacheOperationRateLimitingStore : IOperationRateLimiting IsAllowed = false, CurrentCount = 0, MaxCount = maxCount, - RetryAfter = duration + RetryAfter = null }; } diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs index 7a43716927..cd436546f1 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingTestModule.cs @@ -161,6 +161,13 @@ public class AbpOperationRateLimitingTestModule : AbpModule .PartitionBy(ctx => Task.FromResult($"action:{ctx.Parameter}")); }); + // Custom resolver returning null - should throw + options.AddPolicy("TestCustomResolverNull", policy => + { + policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 2) + .PartitionBy(ctx => Task.FromResult(null!)); + }); + // Multi-tenant: ByParameter with tenant isolation - same param, different tenants = different counters options.AddPolicy("TestMultiTenantByParameter", policy => { diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs index aa21159613..b612419e48 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/DistributedCacheOperationRateLimitingStore_Tests.cs @@ -118,7 +118,7 @@ public class DistributedCacheOperationRateLimitingStore_Tests : OperationRateLim result.IsAllowed.ShouldBeFalse(); result.CurrentCount.ShouldBe(0); result.MaxCount.ShouldBe(0); - result.RetryAfter.ShouldNotBeNull(); + result.RetryAfter.ShouldBeNull(); } [Fact] @@ -130,6 +130,6 @@ public class DistributedCacheOperationRateLimitingStore_Tests : OperationRateLim result.IsAllowed.ShouldBeFalse(); result.CurrentCount.ShouldBe(0); result.MaxCount.ShouldBe(0); - result.RetryAfter.ShouldNotBeNull(); + result.RetryAfter.ShouldBeNull(); } } diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs index 23dcfb6799..8a967077c5 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs @@ -653,6 +653,7 @@ public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase exception.Result.IsAllowed.ShouldBeFalse(); exception.Result.MaxCount.ShouldBe(0); + exception.Result.RetryAfter.ShouldBeNull(); exception.HttpStatusCode.ShouldBe(429); } @@ -674,6 +675,7 @@ public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase status.IsAllowed.ShouldBeFalse(); status.MaxCount.ShouldBe(0); status.RemainingCount.ShouldBe(0); + status.RetryAfter.ShouldBeNull(); } [Fact] @@ -701,6 +703,19 @@ public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase (await _checker.IsAllowedAsync("TestCustomResolver", ctx2)).ShouldBeTrue(); } + [Fact] + public async Task Should_Throw_When_Custom_Resolver_Returns_Null() + { + var context = new OperationRateLimitingContext { Parameter = "test" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestCustomResolverNull", context); + }); + + exception.Message.ShouldContain("Custom partition key resolver returned null or empty"); + } + [Fact] public void Should_Throw_When_Policy_Has_Duplicate_Rules() { From 3f06580fc34c5b643fb2fa40ff57fe171f038418 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 17:33:30 +0800 Subject: [PATCH 11/15] fix: add RetryAfterMinutes property to operation rate limiting exception data --- docs/en/framework/infrastructure/operation-rate-limiting.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md index bdc55f1c7e..89e808d121 100644 --- a/docs/en/framework/infrastructure/operation-rate-limiting.md +++ b/docs/en/framework/infrastructure/operation-rate-limiting.md @@ -283,6 +283,7 @@ The exception includes the following data properties: | `CurrentCount` | int | Current usage count | | `RemainingCount` | int | Remaining allowed count | | `RetryAfterSeconds` | int | Seconds until the window resets | +| `RetryAfterMinutes` | int | Minutes until the window resets (rounded down) | | `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes") | | `WindowDurationSeconds` | int | Total window duration in seconds | | `WindowDescription` | string | Localized window description | From 773252b44a50add9b3c9afcd3da7bb59d2c139e6 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 17:48:56 +0800 Subject: [PATCH 12/15] feat: implement early break in Phase 2 for multi-rule policies and add corresponding tests --- .../Checker/OperationRateLimitingChecker.cs | 23 +++- ...nRateLimitingPhase2EarlyBreakTestModule.cs | 102 ++++++++++++++++++ ...OperationRateLimitingCheckerFixes_Tests.cs | 53 +++++++++ 3 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs index 3b3006b248..56f22a79c6 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs @@ -65,16 +65,31 @@ public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITran 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 + // Phase 2: All rules passed in Phase 1 - now increment counters. + // Guard against concurrent races where another request consumed the last quota // between Phase 1 and Phase 2. + // Once any rule fails during increment, stop incrementing subsequent rules + // to minimize wasted quota. Remaining rules use read-only check instead. var incrementResults = new List(); + var phase2Failed = false; foreach (var rule in rules) { - incrementResults.Add(await rule.AcquireAsync(context)); + if (phase2Failed) + { + incrementResults.Add(await rule.CheckAsync(context)); + } + else + { + var result = await rule.AcquireAsync(context); + incrementResults.Add(result); + if (!result.IsAllowed) + { + phase2Failed = true; + } + } } - if (incrementResults.Any(r => !r.IsAllowed)) + if (phase2Failed) { var aggregatedResult = AggregateResults(incrementResults, policy); ThrowRateLimitException(policy, aggregatedResult, context); diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs new file mode 100644 index 0000000000..b36b9778cd --- /dev/null +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingPhase2EarlyBreakTestModule.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Autofac; +using Volo.Abp.ExceptionHandling; +using Volo.Abp.Modularity; + +namespace Volo.Abp.OperationRateLimiting; + +/// +/// A mock store that simulates a multi-rule Phase 2 race condition: +/// - GetAsync always reports quota available (Phase 1 passes for all rules). +/// - IncrementAsync succeeds for the first call, fails on the second call +/// (simulating a concurrent race on Rule2), and tracks total increment calls +/// so tests can verify that Rule3 was never incremented (early break). +/// +internal class MultiRuleRaceConditionSimulatorStore : IOperationRateLimitingStore +{ + private int _incrementCallCount; + + /// + /// Total number of IncrementAsync calls made. + /// + public int IncrementCallCount => _incrementCallCount; + + public Task GetAsync(string key, TimeSpan duration, int maxCount) + { + return Task.FromResult(new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = 0, + MaxCount = maxCount + }); + } + + public Task IncrementAsync(string key, TimeSpan duration, int maxCount) + { + var callIndex = Interlocked.Increment(ref _incrementCallCount); + + if (callIndex == 2) + { + // Second rule: simulate concurrent race - another request consumed the last slot. + return Task.FromResult(new OperationRateLimitingStoreResult + { + IsAllowed = false, + CurrentCount = maxCount, + MaxCount = maxCount, + RetryAfter = duration + }); + } + + // First rule (and any others if early break fails): succeed. + return Task.FromResult(new OperationRateLimitingStoreResult + { + IsAllowed = true, + CurrentCount = 1, + MaxCount = maxCount + }); + } + + public Task ResetAsync(string key) + { + return Task.CompletedTask; + } +} + +[DependsOn( + typeof(AbpOperationRateLimitingModule), + typeof(AbpExceptionHandlingModule), + typeof(AbpTestBaseModule), + typeof(AbpAutofacModule) +)] +public class AbpOperationRateLimitingPhase2EarlyBreakTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.Replace( + ServiceDescriptor.Singleton()); + + Configure(options => + { + // 3-rule composite policy: all PartitionByParameter with different durations + // so they generate unique cache keys and don't trigger duplicate rule validation. + options.AddPolicy("TestMultiRuleRacePolicy", policy => + { + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(2), maxCount: 5) + .PartitionByParameter()); + + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(3), maxCount: 5) + .PartitionByParameter()); + }); + }); + } +} diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs index 8d15d2a2b9..fce15fa466 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingCheckerFixes_Tests.cs @@ -97,6 +97,59 @@ public class OperationRateLimitingCheckerPhase1_Tests : OperationRateLimitingTes } } +/// +/// Tests for Phase 2 early break: when a multi-rule policy encounters a race condition +/// in Phase 2 (Rule2 fails), Rule3 should NOT be incremented. +/// Uses a mock store where IncrementAsync fails on the 2nd call. +/// +public class OperationRateLimitingCheckerPhase2EarlyBreak_Tests + : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } + + [Fact] + public async Task Should_Not_Increment_Remaining_Rules_After_Phase2_Failure() + { + // 3-rule policy. Mock store: Rule1 increment succeeds, Rule2 increment fails (race), + // Rule3 should NOT be incremented due to early break. + var checker = GetRequiredService(); + var store = (MultiRuleRaceConditionSimulatorStore)GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = "early-break-test" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestMultiRuleRacePolicy", context); + }); + + exception.PolicyName.ShouldBe("TestMultiRuleRacePolicy"); + exception.Result.IsAllowed.ShouldBeFalse(); + + // Key assertion: only 2 IncrementAsync calls were made (Rule1 + Rule2). + // Rule3 was skipped (used CheckAsync instead) due to early break. + store.IncrementCallCount.ShouldBe(2); + } + + [Fact] + public async Task Should_Include_All_Rule_Results_Despite_Early_Break() + { + // Even with early break, the aggregated result should contain all 3 rules + // (Rule3 via CheckAsync instead of AcquireAsync). + var checker = GetRequiredService(); + var context = new OperationRateLimitingContext { Parameter = $"all-results-{Guid.NewGuid()}" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await checker.CheckAsync("TestMultiRuleRacePolicy", context); + }); + + exception.Result.RuleResults.ShouldNotBeNull(); + exception.Result.RuleResults!.Count.ShouldBe(3); + } +} + /// /// Tests for Fix #1: Phase 2 in CheckAsync now checks the result of AcquireAsync. /// Uses a mock store that simulates a concurrent race condition: From 471697841df6ccf45bdc434572ee569a281f0978 Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 18:01:35 +0800 Subject: [PATCH 13/15] feat: enhance operation rate limiting with current count tracking and reset behavior --- .../Checker/OperationRateLimitingChecker.cs | 14 +++-- .../OperationRateLimitingRuleResult.cs | 2 + .../FixedWindowOperationRateLimitingRule.cs | 4 +- .../OperationRateLimitingChecker_Tests.cs | 51 +++++++++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs index 56f22a79c6..095fa6cbf6 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingChecker.cs @@ -147,6 +147,11 @@ public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITran public virtual async Task ResetAsync(string policyName, OperationRateLimitingContext? context = null) { + if (!Options.IsEnabled) + { + return; + } + context = EnsureContext(context); var policy = await PolicyProvider.GetAsync(policyName); var rules = CreateRules(policy); @@ -168,12 +173,11 @@ public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITran { var rules = new List(); - for (var i = 0; i < policy.Rules.Count; i++) + foreach (var ruleDefinition in policy.Rules) { rules.Add(new FixedWindowOperationRateLimitingRule( policy.Name, - i, - policy.Rules[i], + ruleDefinition, Store, CurrentUser, CurrentTenant, @@ -203,7 +207,7 @@ public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITran IsAllowed = isAllowed, RemainingCount = mostRestrictive.RemainingCount, MaxCount = mostRestrictive.MaxCount, - CurrentCount = mostRestrictive.MaxCount - mostRestrictive.RemainingCount, + CurrentCount = mostRestrictive.CurrentCount, RetryAfter = ruleResults.Any(r => !r.IsAllowed && r.RetryAfter.HasValue) ? ruleResults .Where(r => !r.IsAllowed && r.RetryAfter.HasValue) @@ -248,7 +252,7 @@ public class OperationRateLimitingChecker : IOperationRateLimitingChecker, ITran ["IsAllowed"] = ruleResult.IsAllowed, ["MaxCount"] = ruleResult.MaxCount, ["RemainingCount"] = ruleResult.RemainingCount, - ["CurrentCount"] = ruleResult.MaxCount - ruleResult.RemainingCount, + ["CurrentCount"] = ruleResult.CurrentCount, ["WindowDurationSeconds"] = (int)ruleResult.WindowDuration.TotalSeconds, ["WindowDescription"] = ruleResult.WindowDuration > TimeSpan.Zero ? formatter.Format(ruleResult.WindowDuration) diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs index e05e6bf4fb..d725b8f7f2 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingRuleResult.cs @@ -8,6 +8,8 @@ public class OperationRateLimitingRuleResult public bool IsAllowed { get; set; } + public int CurrentCount { get; set; } + public int RemainingCount { get; set; } public int MaxCount { get; set; } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs index cd2dd4db32..bd869e2c5b 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Rules/FixedWindowOperationRateLimitingRule.cs @@ -10,7 +10,6 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule private const string HostTenantKey = "host"; protected string PolicyName { get; } - protected int RuleIndex { get; } protected OperationRateLimitingRuleDefinition Definition { get; } protected IOperationRateLimitingStore Store { get; } protected ICurrentUser CurrentUser { get; } @@ -19,7 +18,6 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule public FixedWindowOperationRateLimitingRule( string policyName, - int ruleIndex, OperationRateLimitingRuleDefinition definition, IOperationRateLimitingStore store, ICurrentUser currentUser, @@ -27,7 +25,6 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule IWebClientInfoProvider webClientInfoProvider) { PolicyName = policyName; - RuleIndex = ruleIndex; Definition = definition; Store = store; CurrentUser = currentUser; @@ -140,6 +137,7 @@ public class FixedWindowOperationRateLimitingRule : IOperationRateLimitingRule { RuleName = $"{PolicyName}:Rule[{(long)Definition.Duration.TotalSeconds}s,{Definition.MaxCount},{Definition.PartitionType}]", IsAllowed = storeResult.IsAllowed, + CurrentCount = storeResult.CurrentCount, RemainingCount = storeResult.MaxCount - storeResult.CurrentCount, MaxCount = storeResult.MaxCount, RetryAfter = storeResult.RetryAfter, diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs index 8a967077c5..08a605c894 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs @@ -731,6 +731,57 @@ public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase }); } + + [Fact] + public async Task Should_Return_Correct_CurrentCount_In_RuleResults() + { + var param = $"current-count-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + var status = await _checker.GetStatusAsync("TestSimple", context); + status.RuleResults.ShouldNotBeNull(); + status.RuleResults!.Count.ShouldBe(1); + status.RuleResults[0].CurrentCount.ShouldBe(2); + status.RuleResults[0].RemainingCount.ShouldBe(1); + status.RuleResults[0].MaxCount.ShouldBe(3); + } + + [Fact] + public async Task ResetAsync_Should_Skip_When_Disabled() + { + var options = GetRequiredService>(); + var originalValue = options.Value.IsEnabled; + + try + { + var param = $"reset-disabled-{Guid.NewGuid()}"; + var context = new OperationRateLimitingContext { Parameter = param }; + + // Exhaust the quota + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + await _checker.CheckAsync("TestSimple", context); + + // Disable and call ResetAsync — should be a no-op (counter not actually reset) + options.Value.IsEnabled = false; + await _checker.ResetAsync("TestSimple", context); + + // Re-enable: quota should still be exhausted because reset was skipped + options.Value.IsEnabled = true; + await Assert.ThrowsAsync(async () => + { + await _checker.CheckAsync("TestSimple", context); + }); + } + finally + { + options.Value.IsEnabled = originalValue; + } + } + private static ClaimsPrincipal CreateClaimsPrincipal(Guid userId) { return new ClaimsPrincipal( From 76b279c6ac1dcfd90bee8f932389f69cda42891a Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 6 Mar 2026 18:16:25 +0800 Subject: [PATCH 14/15] feat: Enhance operation rate limiting with permanent denial error codes --- .../infrastructure/operation-rate-limiting.md | 17 ++++++++++---- .../AbpOperationRateLimitingErrorCodes.cs | 7 +++++- .../AbpOperationRateLimitingException.cs | 9 +++++++- .../Localization/ar.json | 3 ++- .../Localization/cs.json | 3 ++- .../Localization/de.json | 3 ++- .../Localization/el.json | 3 ++- .../Localization/en-GB.json | 3 ++- .../Localization/en.json | 3 ++- .../Localization/es.json | 3 ++- .../Localization/fa.json | 3 ++- .../Localization/fi.json | 3 ++- .../Localization/fr.json | 3 ++- .../Localization/hi.json | 3 ++- .../Localization/hr.json | 3 ++- .../Localization/hu.json | 3 ++- .../Localization/is.json | 3 ++- .../Localization/it.json | 3 ++- .../Localization/nl.json | 3 ++- .../Localization/pl-PL.json | 3 ++- .../Localization/pt-BR.json | 3 ++- .../Localization/ro-RO.json | 3 ++- .../Localization/ru.json | 3 ++- .../Localization/sk.json | 3 ++- .../Localization/sl.json | 3 ++- .../Localization/sv.json | 3 ++- .../Localization/tr.json | 3 ++- .../Localization/vi.json | 3 ++- .../Localization/zh-Hans.json | 3 ++- ...AbpOperationRateLimitingException_Tests.cs | 22 +++++++++++++++++-- .../OperationRateLimitingChecker_Tests.cs | 1 + 31 files changed, 100 insertions(+), 34 deletions(-) diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md index 89e808d121..736152444f 100644 --- a/docs/en/framework/infrastructure/operation-rate-limiting.md +++ b/docs/en/framework/infrastructure/operation-rate-limiting.md @@ -274,6 +274,15 @@ When a rate limit is exceeded, `CheckAsync` throws `AbpOperationRateLimitingExce * Extends `BusinessException` and implements `IHasHttpStatusCode` with status code **429** (Too Many Requests). * Is automatically handled by ABP's exception handling pipeline and serialized into the HTTP response. +The exception uses one of two error codes depending on the policy type: + +| Error Code | Constant | When Used | +|---|---|---| +| `Volo.Abp.OperationRateLimiting:010001` | `AbpOperationRateLimitingErrorCodes.ExceedLimit` | Regular rate limit exceeded (has a retry-after window) | +| `Volo.Abp.OperationRateLimiting:010002` | `AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently` | Ban policy (`maxCount: 0`, permanently denied) | + +You can override the error code per policy using `WithErrorCode()`. When a custom code is set, it is always used regardless of the policy type. + The exception includes the following data properties: | Key | Type | Description | @@ -282,9 +291,9 @@ The exception includes the following data properties: | `MaxCount` | int | Maximum allowed count | | `CurrentCount` | int | Current usage count | | `RemainingCount` | int | Remaining allowed count | -| `RetryAfterSeconds` | int | Seconds until the window resets | -| `RetryAfterMinutes` | int | Minutes until the window resets (rounded down) | -| `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes") | +| `RetryAfterSeconds` | int | Seconds until the window resets (`0` for ban policies) | +| `RetryAfterMinutes` | int | Minutes until the window resets, rounded down (`0` for ban policies) | +| `RetryAfter` | string | Localized retry-after description (e.g., "5 minutes"); absent for ban policies | | `WindowDurationSeconds` | int | Total window duration in seconds | | `WindowDescription` | string | Localized window description | | `RuleDetails` | List | Per-rule details (for multi-rule policies) | @@ -329,7 +338,7 @@ public override void ConfigureServices(ServiceConfigurationContext context) ### Ban Policy (maxCount: 0) -Setting `maxCount` to `0` creates a ban policy that permanently denies all requests regardless of the window duration. The `RetryAfter` value will be `null` since there is no window to wait for: +Setting `maxCount` to `0` creates a ban policy that permanently denies all requests regardless of the window duration. The `RetryAfter` value will be `null` since there is no window to wait for. The exception uses the error code `Volo.Abp.OperationRateLimiting:010002` (`AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently`) with the message "Operation rate limit exceeded. This request is permanently denied.": ````csharp options.AddPolicy("BlockedUser", policy => diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs index 5ff8d88b5a..ba2bb5c189 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingErrorCodes.cs @@ -3,7 +3,12 @@ namespace Volo.Abp.OperationRateLimiting; public static class AbpOperationRateLimitingErrorCodes { /// - /// Default error code for rate limit exceeded. + /// Default error code for rate limit exceeded (with a retry-after window). /// public const string ExceedLimit = "Volo.Abp.OperationRateLimiting:010001"; + + /// + /// Error code for ban policy (maxCount: 0) where requests are permanently denied. + /// + public const string ExceedLimitPermanently = "Volo.Abp.OperationRateLimiting:010002"; } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs index 9872d26e76..88cfb2c6ff 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Exceptions/AbpOperationRateLimitingException.cs @@ -15,7 +15,7 @@ public class AbpOperationRateLimitingException : BusinessException, IHasHttpStat string policyName, OperationRateLimitingResult result, string? errorCode = null) - : base(code: errorCode ?? AbpOperationRateLimitingErrorCodes.ExceedLimit) + : base(code: errorCode ?? ResolveDefaultErrorCode(result)) { PolicyName = policyName; Result = result; @@ -38,4 +38,11 @@ public class AbpOperationRateLimitingException : BusinessException, IHasHttpStat { WithData("WindowDescription", formattedWindowDescription); } + + private static string ResolveDefaultErrorCode(OperationRateLimitingResult result) + { + return result.RetryAfter.HasValue + ? AbpOperationRateLimitingErrorCodes.ExceedLimit + : AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently; + } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json index 46b937bb99..3ca1c8f042 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ar.json @@ -12,6 +12,7 @@ "RetryAfter:HoursAndMinutes": "{0} ساعة/ساعات و {1} دقيقة/دقائق", "RetryAfter:Minutes": "{0} دقيقة/دقائق", "RetryAfter:MinutesAndSeconds": "{0} دقيقة/دقائق و {1} ثانية/ثوان", - "RetryAfter:Seconds": "{0} ثانية/ثوان" + "RetryAfter:Seconds": "{0} ثانية/ثوان", + "Volo.Abp.OperationRateLimiting:010002": "تم تجاوز حد معدل العملية. هذا الطلب مرفوض بشكل دائم." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json index 205d229c10..44cfb86437 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/cs.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} sekunda/sekundy/sekund", + "Volo.Abp.OperationRateLimiting:010002": "Byl překročen limit četnosti operace. Tento požadavek je trvale zamítnut." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json index 3313e0156d..44286d68a8 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/de.json @@ -12,6 +12,7 @@ "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)" + "RetryAfter:Seconds": "{0} Sekunde(n)", + "Volo.Abp.OperationRateLimiting:010002": "Das Vorgangshäufigkeitslimit wurde überschritten. Diese Anfrage wird dauerhaft abgelehnt." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json index 53359d6fa8..a688778eb5 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/el.json @@ -12,6 +12,7 @@ "RetryAfter:HoursAndMinutes": "{0} ώρα/ες και {1} λεπτό/ά", "RetryAfter:Minutes": "{0} λεπτό/ά", "RetryAfter:MinutesAndSeconds": "{0} λεπτό/ά και {1} δευτερόλεπτο/α", - "RetryAfter:Seconds": "{0} δευτερόλεπτο/α" + "RetryAfter:Seconds": "{0} δευτερόλεπτο/α", + "Volo.Abp.OperationRateLimiting:010002": "Υπερβλήθηκε το όριο συχνότητας λειτουργίας. Αυτό το αίτημα απορρίπτεται μόνιμα." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json index de3b1de3a3..4eab97f134 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en-GB.json @@ -12,6 +12,7 @@ "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)" + "RetryAfter:Seconds": "{0} second(s)", + "Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json index 0abcff4386..fc41cabc2f 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/en.json @@ -12,6 +12,7 @@ "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)" + "RetryAfter:Seconds": "{0} second(s)", + "Volo.Abp.OperationRateLimiting:010002": "Operation rate limit exceeded. This request is permanently denied." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json index 299ccd0fa2..ce13a9bceb 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/es.json @@ -12,6 +12,7 @@ "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)" + "RetryAfter:Seconds": "{0} segundo(s)", + "Volo.Abp.OperationRateLimiting:010002": "Se superó el límite de frecuencia de operación. Esta solicitud está permanentemente denegada." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json index 09c7f21376..0f200b5472 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fa.json @@ -12,6 +12,7 @@ "RetryAfter:HoursAndMinutes": "{0} ساعت و {1} دقیقه", "RetryAfter:Minutes": "{0} دقیقه", "RetryAfter:MinutesAndSeconds": "{0} دقیقه و {1} ثانیه", - "RetryAfter:Seconds": "{0} ثانیه" + "RetryAfter:Seconds": "{0} ثانیه", + "Volo.Abp.OperationRateLimiting:010002": "محدودیت نرخ عملیات از حد مجاز فراتر رفت. این درخواست به طور دائمی رد شده است." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json index de379d5989..bcb88bf9ed 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fi.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} sekunti/sekuntia", + "Volo.Abp.OperationRateLimiting:010002": "Toiminnan nopeusraja ylitettiin. Tämä pyyntö on pysyvästi hylätty." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json index b531b036f0..dca23d7a80 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/fr.json @@ -12,6 +12,7 @@ "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)" + "RetryAfter:Seconds": "{0} seconde(s)", + "Volo.Abp.OperationRateLimiting:010002": "La limite de fréquence d'opération a été dépassée. Cette demande est définitivement refusée." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json index 6a53a5106e..a84a1e44a8 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hi.json @@ -12,6 +12,7 @@ "RetryAfter:HoursAndMinutes": "{0} घंटा/घंटे और {1} मिनट", "RetryAfter:Minutes": "{0} मिनट", "RetryAfter:MinutesAndSeconds": "{0} मिनट और {1} सेकंड", - "RetryAfter:Seconds": "{0} सेकंड" + "RetryAfter:Seconds": "{0} सेकंड", + "Volo.Abp.OperationRateLimiting:010002": "ऑपरेशन दर सीमा पार हो गई। यह अनुरोध स्थायी रूप से अस्वीकृत है।" } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json index d8a0ce4a18..c6597be78e 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hr.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} sekunda/e", + "Volo.Abp.OperationRateLimiting:010002": "Prekoračeno je ograničenje brzine operacije. Ovaj zahtjev je trajno odbijen." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json index b1ece6246a..f72dfea397 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/hu.json @@ -12,6 +12,7 @@ "RetryAfter:HoursAndMinutes": "{0} óra és {1} perc", "RetryAfter:Minutes": "{0} perc", "RetryAfter:MinutesAndSeconds": "{0} perc és {1} másodperc", - "RetryAfter:Seconds": "{0} másodperc" + "RetryAfter:Seconds": "{0} másodperc", + "Volo.Abp.OperationRateLimiting:010002": "A műveleti ráta korlátja túllépve. Ez a kérés véglegesen elutasítva." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json index 45c6255ee1..cce36e42cd 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/is.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} sekúnda/úr", + "Volo.Abp.OperationRateLimiting:010002": "Farið var yfir takmörk á rekstrartíðni. Þessari beiðni er varanlega hafnað." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json index f93fb278bf..f602fd1a66 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/it.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} secondo/i", + "Volo.Abp.OperationRateLimiting:010002": "Limite di frequenza operazione superato. Questa richiesta è permanentemente negata." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json index a733537123..bb21e7f313 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/nl.json @@ -12,6 +12,7 @@ "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)" + "RetryAfter:Seconds": "{0} seconde(n)", + "Volo.Abp.OperationRateLimiting:010002": "Het bewerkingsfrequentielimiet is overschreden. Dit verzoek wordt permanent geweigerd." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json index 72d6c905a5..e4503f6aa7 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pl-PL.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} sekunda/sekund", + "Volo.Abp.OperationRateLimiting:010002": "Przekroczono limit częstotliwości operacji. To żądanie jest trwale odrzucone." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json index 412f7f2389..fb6f873805 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/pt-BR.json @@ -12,6 +12,7 @@ "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)" + "RetryAfter:Seconds": "{0} segundo(s)", + "Volo.Abp.OperationRateLimiting:010002": "Limite de taxa de operação excedido. Esta solicitação está permanentemente negada." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json index cef165fa42..178042fa85 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ro-RO.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} secundă/secunde", + "Volo.Abp.OperationRateLimiting:010002": "Limita de rată a operației a fost depășită. Această solicitare este permanent refuzată." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json index dc4ddf6db6..5260a9c90c 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/ru.json @@ -12,6 +12,7 @@ "RetryAfter:HoursAndMinutes": "{0} час/часов и {1} минута/минут", "RetryAfter:Minutes": "{0} минута/минут", "RetryAfter:MinutesAndSeconds": "{0} минута/минут и {1} секунда/секунд", - "RetryAfter:Seconds": "{0} секунда/секунд" + "RetryAfter:Seconds": "{0} секунда/секунд", + "Volo.Abp.OperationRateLimiting:010002": "Превышен лимит частоты операций. Этот запрос постоянно отклонён." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json index bd849adb48..73cdbb2b5d 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sk.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} sekunda/sekúnd", + "Volo.Abp.OperationRateLimiting:010002": "Bol prekročený limit frekvencie operácie. Táto požiadavka je trvalo zamietnutá." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json index eb6c54980e..333f51efb5 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sl.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} sekunda/sekund", + "Volo.Abp.OperationRateLimiting:010002": "Prekoračena je omejitev hitrosti operacije. Ta zahteva je trajno zavrnjena." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json index 562ba3d586..ef5172867c 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/sv.json @@ -12,6 +12,7 @@ "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)" + "RetryAfter:Seconds": "{0} sekund(er)", + "Volo.Abp.OperationRateLimiting:010002": "Hastighetsgränsen för operationen har överskridits. Denna förfrågan är permanent nekad." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json index 3ce04aa915..0480003f9c 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/tr.json @@ -12,6 +12,7 @@ "RetryAfter:HoursAndMinutes": "{0} saat ve {1} dakika", "RetryAfter:Minutes": "{0} dakika", "RetryAfter:MinutesAndSeconds": "{0} dakika ve {1} saniye", - "RetryAfter:Seconds": "{0} saniye" + "RetryAfter:Seconds": "{0} saniye", + "Volo.Abp.OperationRateLimiting:010002": "İşlem hızı sınırı aşıldı. Bu istek kalıcı olarak reddedildi." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json index 6b0ee47927..53059f819c 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/vi.json @@ -12,6 +12,7 @@ "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" + "RetryAfter:Seconds": "{0} giây", + "Volo.Abp.OperationRateLimiting:010002": "Vượt quá giới hạn tần suất thao tác. Yêu cầu này bị từ chối vĩnh viễn." } } diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json index 6472587357..632d0e438f 100644 --- a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Localization/zh-Hans.json @@ -12,6 +12,7 @@ "RetryAfter:HoursAndMinutes": "{0} 小时 {1} 分钟", "RetryAfter:Minutes": "{0} 分钟", "RetryAfter:MinutesAndSeconds": "{0} 分钟 {1} 秒", - "RetryAfter:Seconds": "{0} 秒" + "RetryAfter:Seconds": "{0} 秒", + "Volo.Abp.OperationRateLimiting:010002": "操作频率超出限制。此请求已被永久拒绝。" } } diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs index bf20e1f6a4..9d88c5e57d 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/AbpOperationRateLimitingException_Tests.cs @@ -24,14 +24,15 @@ public class AbpOperationRateLimitingException_Tests } [Fact] - public void Should_Set_Default_ErrorCode() + public void Should_Use_ExceedLimit_Code_When_RetryAfter_Is_Set() { var result = new OperationRateLimitingResult { IsAllowed = false, MaxCount = 3, CurrentCount = 3, - RemainingCount = 0 + RemainingCount = 0, + RetryAfter = TimeSpan.FromMinutes(5) }; var exception = new AbpOperationRateLimitingException("TestPolicy", result); @@ -39,6 +40,23 @@ public class AbpOperationRateLimitingException_Tests exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimit); } + [Fact] + public void Should_Use_ExceedLimitPermanently_Code_When_RetryAfter_Is_Null() + { + var result = new OperationRateLimitingResult + { + IsAllowed = false, + MaxCount = 0, + CurrentCount = 0, + RemainingCount = 0, + RetryAfter = null + }; + + var exception = new AbpOperationRateLimitingException("TestPolicy", result); + + exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently); + } + [Fact] public void Should_Set_Custom_ErrorCode() { diff --git a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs index 08a605c894..fd3d9f4214 100644 --- a/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs +++ b/framework/test/Volo.Abp.OperationRateLimiting.Tests/Volo/Abp/OperationRateLimiting/OperationRateLimitingChecker_Tests.cs @@ -655,6 +655,7 @@ public class OperationRateLimitingChecker_Tests : OperationRateLimitingTestBase exception.Result.MaxCount.ShouldBe(0); exception.Result.RetryAfter.ShouldBeNull(); exception.HttpStatusCode.ShouldBe(429); + exception.Code.ShouldBe(AbpOperationRateLimitingErrorCodes.ExceedLimitPermanently); } [Fact] From 5b1f3e8304d29204c249e7e268bbbed4ef31e71b Mon Sep 17 00:00:00 2001 From: maliming Date: Sat, 7 Mar 2026 13:34:09 +0800 Subject: [PATCH 15/15] feat: Refactor operation rate limiting methods for simplified parameter handling and add extension methods --- docs/en/docs-nav.json | 8 ++-- .../infrastructure/operation-rate-limiting.md | 4 +- .../OperationRateLimitingCheckerExtensions.cs | 38 +++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index f84892243b..c3acbb6bb8 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -721,10 +721,6 @@ "text": "Distributed Locking", "path": "framework/infrastructure/distributed-locking.md" }, - { - "text": "Operation Rate Limiting", - "path": "framework/infrastructure/operation-rate-limiting.md" - }, { "text": "Email Sending", "items": [ @@ -811,6 +807,10 @@ "text": "Object to Object Mapping", "path": "framework/infrastructure/object-to-object-mapping.md" }, + { + "text": "Operation Rate Limiting", + "path": "framework/infrastructure/operation-rate-limiting.md" + }, { "text": "Settings", "path": "framework/infrastructure/settings.md" diff --git a/docs/en/framework/infrastructure/operation-rate-limiting.md b/docs/en/framework/infrastructure/operation-rate-limiting.md index 736152444f..5208c2c959 100644 --- a/docs/en/framework/infrastructure/operation-rate-limiting.md +++ b/docs/en/framework/infrastructure/operation-rate-limiting.md @@ -64,8 +64,7 @@ public class SmsAppService : ApplicationService public async Task SendCodeAsync(string phoneNumber) { - await _rateLimitChecker.CheckAsync("SendSmsCode", - new OperationRateLimitingContext { Parameter = phoneNumber }); + await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber); // If we reach here, the limit was not exceeded. // Send the SMS code... @@ -75,6 +74,7 @@ public class SmsAppService : ApplicationService * `CheckAsync` increments the counter and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is exceeded. * Each phone number has its own counter because we used `PartitionByParameter()`. +* Passing `phoneNumber` directly is a shortcut for `new OperationRateLimitingContext { Parameter = phoneNumber }`. Extension methods are provided for all four methods (`CheckAsync`, `IsAllowedAsync`, `GetStatusAsync`, `ResetAsync`) when you only need to pass a `parameter` string. That's the basic usage. The following sections explain each concept in detail. diff --git a/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs new file mode 100644 index 0000000000..df8d195aab --- /dev/null +++ b/framework/src/Volo.Abp.OperationRateLimiting/Volo/Abp/OperationRateLimiting/Checker/OperationRateLimitingCheckerExtensions.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.OperationRateLimiting; + +public static class OperationRateLimitingCheckerExtensions +{ + public static Task CheckAsync( + this IOperationRateLimitingChecker checker, + string policyName, + string parameter) + { + return checker.CheckAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); + } + + public static Task IsAllowedAsync( + this IOperationRateLimitingChecker checker, + string policyName, + string parameter) + { + return checker.IsAllowedAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); + } + + public static Task GetStatusAsync( + this IOperationRateLimitingChecker checker, + string policyName, + string parameter) + { + return checker.GetStatusAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); + } + + public static Task ResetAsync( + this IOperationRateLimitingChecker checker, + string policyName, + string parameter) + { + return checker.ResetAsync(policyName, new OperationRateLimitingContext { Parameter = parameter }); + } +}