Browse Source

Add Volo.Abp.OperationRateLimit module

pull/25025/head
maliming 3 weeks ago
parent
commit
6f157406e0
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 2
      framework/Volo.Abp.slnx
  2. 4
      framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/AbpAspNetCoreAbstractionsModule.cs
  3. 6
      framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/IClientIpAddressProvider.cs
  4. 6
      framework/src/Volo.Abp.AspNetCore.Abstractions/Volo/Abp/AspNetCore/ClientIpAddress/NullClientIpAddressProvider.cs
  5. 36
      framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/ClientIpAddress/HttpContextClientIpAddressProvider.cs
  6. 3
      framework/src/Volo.Abp.OperationRateLimit/FodyWeavers.xml
  7. 32
      framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj
  8. 9
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs
  9. 41
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException.cs
  10. 42
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitModule.cs
  11. 20
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitOptions.cs
  12. 8
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitResource.cs
  13. 68
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitFormatter.cs
  14. 34
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DefaultOperationRateLimitPolicyProvider.cs
  15. 155
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore.cs
  16. 134
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/FixedWindowOperationRateLimitRule.cs
  17. 14
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitChecker.cs
  18. 8
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitFormatter.cs
  19. 11
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitPolicyProvider.cs
  20. 12
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitRule.cs
  21. 13
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/IOperationRateLimitStore.cs
  22. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ar.json
  23. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/cs.json
  24. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/de.json
  25. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/el.json
  26. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en-GB.json
  27. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/en.json
  28. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/es.json
  29. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fa.json
  30. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fi.json
  31. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/fr.json
  32. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hi.json
  33. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hr.json
  34. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/hu.json
  35. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/is.json
  36. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/it.json
  37. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/nl.json
  38. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pl-PL.json
  39. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/pt-BR.json
  40. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ro-RO.json
  41. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/ru.json
  42. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sk.json
  43. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sl.json
  44. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/sv.json
  45. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/tr.json
  46. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/vi.json
  47. 17
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/Localization/zh-Hans.json
  48. 14
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitCacheItem.cs
  49. 258
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitChecker.cs
  50. 32
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitContext.cs
  51. 12
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPartitionType.cs
  52. 15
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicy.cs
  53. 97
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder.cs
  54. 24
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitResult.cs
  55. 155
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleBuilder.cs
  56. 16
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleDefinition.cs
  57. 18
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitRuleResult.cs
  58. 14
      framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/OperationRateLimitStoreResult.cs
  59. 18
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj
  60. 99
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitException_Tests.cs
  61. 68
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitPhase2RaceTestModule.cs
  62. 179
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/AbpOperationRateLimitTestModule.cs
  63. 135
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/DistributedCacheOperationRateLimitStore_Tests.cs
  64. 144
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitCheckerFixes_Tests.cs
  65. 731
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitChecker_Tests.cs
  66. 408
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitFrontendIntegration_Tests.cs
  67. 106
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitMultiTenant_Tests.cs
  68. 209
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitPolicyBuilder_Tests.cs
  69. 11
      framework/test/Volo.Abp.OperationRateLimit.Tests/Volo/Abp/OperationRateLimit/OperationRateLimitTestBase.cs
  70. 1
      nupkg/common.ps1

2
framework/Volo.Abp.slnx

@ -169,6 +169,7 @@
<Project Path="src/Volo.Abp.TickerQ/Volo.Abp.TickerQ.csproj" />
<Project Path="src/Volo.Abp.BackgroundJobs.TickerQ/Volo.Abp.BackgroundJobs.TickerQ.csproj" />
<Project Path="src/Volo.Abp.BackgroundWorkers.TickerQ/Volo.Abp.BackgroundWorkers.TickerQ.csproj" />
<Project Path="src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/AbpTestBase/AbpTestBase.csproj" />
@ -256,5 +257,6 @@
<Project Path="test/Volo.Abp.Uow.Tests/Volo.Abp.Uow.Tests.csproj" />
<Project Path="test/Volo.Abp.Validation.Tests/Volo.Abp.Validation.Tests.csproj" />
<Project Path="test/Volo.Abp.VirtualFileSystem.Tests/Volo.Abp.VirtualFileSystem.Tests.csproj" />
<Project Path="test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj" />
</Folder>
</Solution>

4
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<IWebContentFileProvider, NullWebContentFileProvider>();
context.Services.AddSingleton<IWebClientInfoProvider, NullWebClientInfoProvider>();;
context.Services.AddSingleton<IWebClientInfoProvider, NullWebClientInfoProvider>();
context.Services.AddTransient<IClientIpAddressProvider, NullClientIpAddressProvider>();
}
}

6
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; }
}

6
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;
}

36
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<HttpContextClientIpAddressProvider> Logger { get; }
protected IHttpContextAccessor HttpContextAccessor { get; }
public HttpContextClientIpAddressProvider(
ILogger<HttpContextClientIpAddressProvider> 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;
}
}
}

3
framework/src/Volo.Abp.OperationRateLimit/FodyWeavers.xml

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait ContinueOnCapturedContext="false" />
</Weavers>

32
framework/src/Volo.Abp.OperationRateLimit/Volo.Abp.OperationRateLimit.csproj

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\configureawait.props" />
<Import Project="..\..\..\common.props" />
<PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<WarningsAsErrors>Nullable</WarningsAsErrors>
<AssemblyName>Volo.Abp.OperationRateLimit</AssemblyName>
<PackageId>Volo.Abp.OperationRateLimit</PackageId>
<AssetTargetFallback>$(AssetTargetFallback);portable-net45+win8+wp8+wpa81;</AssetTargetFallback>
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute>
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute>
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute>
<RootNamespace />
</PropertyGroup>
<ItemGroup>
<None Remove="Volo\Abp\OperationRateLimit\Localization\*.json" />
<EmbeddedResource Include="Volo\Abp\OperationRateLimit\Localization\*.json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Volo.Abp.AspNetCore.Abstractions\Volo.Abp.AspNetCore.Abstractions.csproj" />
<ProjectReference Include="..\Volo.Abp.Caching\Volo.Abp.Caching.csproj" />
<ProjectReference Include="..\Volo.Abp.DistributedLocking.Abstractions\Volo.Abp.DistributedLocking.Abstractions.csproj" />
<ProjectReference Include="..\Volo.Abp.Localization\Volo.Abp.Localization.csproj" />
<ProjectReference Include="..\Volo.Abp.Security\Volo.Abp.Security.csproj" />
</ItemGroup>
</Project>

9
framework/src/Volo.Abp.OperationRateLimit/Volo/Abp/OperationRateLimit/AbpOperationRateLimitErrorCodes.cs

@ -0,0 +1,9 @@
namespace Volo.Abp.OperationRateLimit;
public static class AbpOperationRateLimitErrorCodes
{
/// <summary>
/// Default error code for rate limit exceeded.
/// </summary>
public const string ExceedLimit = "Volo.Abp.OperationRateLimit:010001";
}

41
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);
}
}

42
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<AbpVirtualFileSystemOptions>(options =>
{
options.FileSets.AddEmbedded<AbpOperationRateLimitModule>();
});
Configure<AbpLocalizationOptions>(options =>
{
options.Resources
.Add<AbpOperationRateLimitResource>("en")
.AddVirtualJson("/Volo/Abp/OperationRateLimit/Localization");
});
Configure<AbpExceptionLocalizationOptions>(options =>
{
options.MapCodeNamespace(
"Volo.Abp.OperationRateLimit",
typeof(AbpOperationRateLimitResource));
});
}
}

20
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<string, OperationRateLimitPolicy> Policies { get; } = new();
public void AddPolicy(string name, Action<OperationRateLimitPolicyBuilder> configure)
{
var builder = new OperationRateLimitPolicyBuilder(name);
configure(builder);
Policies[name] = builder.Build();
}
}

8
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
{
}

68
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<AbpOperationRateLimitResource> Localizer { get; }
public DefaultOperationRateLimitFormatter(
IStringLocalizer<AbpOperationRateLimitResource> 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];
}
}

34
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<AbpOperationRateLimitOptions> options)
{
Options = options.Value;
}
public virtual Task<OperationRateLimitPolicy> 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<List<OperationRateLimitPolicy>> GetListAsync()
{
return Task.FromResult(Options.Policies.Values.ToList());
}
}

155
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<OperationRateLimitCacheItem> Cache { get; }
protected IClock Clock { get; }
protected IAbpDistributedLock DistributedLock { get; }
protected AbpOperationRateLimitOptions Options { get; }
public DistributedCacheOperationRateLimitStore(
IDistributedCache<OperationRateLimitCacheItem> cache,
IClock clock,
IAbpDistributedLock distributedLock,
IOptions<AbpOperationRateLimitOptions> options)
{
Cache = cache;
Clock = clock;
DistributedLock = distributedLock;
Options = options.Value;
}
public virtual async Task<OperationRateLimitStoreResult> 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<OperationRateLimitStoreResult> 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);
}
}

134
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<OperationRateLimitRuleResult> 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<OperationRateLimitRuleResult> 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
};
}
}

14
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<bool> IsAllowedAsync(string policyName, OperationRateLimitContext? context = null);
Task<OperationRateLimitResult> GetStatusAsync(string policyName, OperationRateLimitContext? context = null);
Task ResetAsync(string policyName, OperationRateLimitContext? context = null);
}

8
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);
}

11
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<OperationRateLimitPolicy> GetAsync(string policyName);
Task<List<OperationRateLimitPolicy>> GetListAsync();
}

12
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<OperationRateLimitRuleResult> AcquireAsync(OperationRateLimitContext context);
Task<OperationRateLimitRuleResult> CheckAsync(OperationRateLimitContext context);
Task ResetAsync(OperationRateLimitContext context);
}

13
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<OperationRateLimitStoreResult> IncrementAsync(string key, TimeSpan duration, int maxCount);
Task<OperationRateLimitStoreResult> GetAsync(string key, TimeSpan duration, int maxCount);
Task ResetAsync(string key);
}

17
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} ثانية/ثوان"
}
}

17
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"
}
}

17
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)"
}
}

17
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} δευτερόλεπτο/α"
}
}

17
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)"
}
}

17
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)"
}
}

17
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)"
}
}

17
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} ثانیه"
}
}

17
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"
}
}

17
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)"
}
}

17
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} सेकंड"
}
}

17
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"
}
}

17
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"
}
}

17
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"
}
}

17
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"
}
}

17
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)"
}
}

17
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"
}
}

17
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)"
}
}

17
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"
}
}

17
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} секунда/секунд"
}
}

17
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"
}
}

17
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"
}
}

17
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)"
}
}

17
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"
}
}

17
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"
}
}

17
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} 秒"
}
}

14
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; }
}

258
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<AbpOperationRateLimitOptions> 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<OperationRateLimitRuleResult>();
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<OperationRateLimitRuleResult>();
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<bool> 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<OperationRateLimitResult> 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<OperationRateLimitRuleResult>();
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<IOperationRateLimitRule> CreateRules(OperationRateLimitPolicy policy)
{
var rules = new List<IOperationRateLimitRule>();
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<OperationRateLimitRuleResult> 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<IOperationRateLimitFormatter>();
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<Dictionary<string, object>>();
foreach (var ruleResult in result.RuleResults)
{
ruleDetails.Add(new Dictionary<string, object>
{
["RuleName"] = ruleResult.RuleName,
["IsAllowed"] = ruleResult.IsAllowed,
["MaxCount"] = ruleResult.MaxCount,
["RemainingCount"] = ruleResult.RemainingCount,
["CurrentCount"] = ruleResult.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;
}
}

32
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
{
/// <summary>
/// Optional parameter passed by the caller.
/// Used by rules configured with PartitionByParameter().
/// Can be email, phone number, resource id, or any string.
/// </summary>
public string? Parameter { get; set; }
/// <summary>
/// Additional properties that can be read by custom <see cref="IOperationRateLimitRule"/> implementations
/// and are forwarded to the exception's Data dictionary when the rate limit is exceeded.
/// </summary>
public Dictionary<string, object?> ExtraProperties { get; set; } = new();
/// <summary>
/// The service provider for resolving services.
/// Set automatically by the checker.
/// </summary>
public IServiceProvider ServiceProvider { get; set; } = default!;
public T GetRequiredService<T>() where T : notnull
=> ServiceProvider.GetRequiredService<T>();
public T? GetService<T>() => ServiceProvider.GetService<T>();
}

12
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
}

15
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<OperationRateLimitRuleDefinition> Rules { get; set; } = new();
public List<Type> CustomRuleTypes { get; set; } = new();
}

97
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<OperationRateLimitRuleDefinition> _rules = new();
private readonly List<Type> _customRuleTypes = new();
public OperationRateLimitPolicyBuilder(string name)
{
_name = Check.NotNullOrWhiteSpace(name, nameof(name));
}
/// <summary>
/// Add a built-in rule. Multiple rules are AND-combined.
/// </summary>
public OperationRateLimitPolicyBuilder AddRule(
Action<OperationRateLimitRuleBuilder> configure)
{
var builder = new OperationRateLimitRuleBuilder();
configure(builder);
_rules.Add(builder.Build());
return this;
}
/// <summary>
/// Add a custom rule type (resolved from DI).
/// </summary>
public OperationRateLimitPolicyBuilder AddRule<TRule>()
where TRule : class, IOperationRateLimitRule
{
_customRuleTypes.Add(typeof(TRule));
return this;
}
/// <summary>
/// Shortcut: single-rule policy with fixed window.
/// Returns the rule builder for partition configuration.
/// </summary>
public OperationRateLimitRuleBuilder WithFixedWindow(
TimeSpan duration, int maxCount)
{
var builder = new OperationRateLimitRuleBuilder(this);
builder.WithFixedWindow(duration, maxCount);
return builder;
}
/// <summary>
/// Set a custom ErrorCode for this policy's exception.
/// </summary>
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<OperationRateLimitRuleDefinition>(_rules),
CustomRuleTypes = new List<Type>(_customRuleTypes)
};
}
}

24
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; }
/// <summary>
/// Detailed results per rule (for composite policies).
/// </summary>
public List<OperationRateLimitRuleResult>? RuleResults { get; set; }
}

155
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<OperationRateLimitContext, string>? _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;
}
/// <summary>
/// Use context.Parameter as partition key.
/// </summary>
public OperationRateLimitPolicyBuilder PartitionByParameter()
{
_partitionType = OperationRateLimitPartitionType.Parameter;
CommitToPolicyBuilder();
return _policyBuilder!;
}
/// <summary>
/// Auto resolve from ICurrentUser.Id.
/// </summary>
public OperationRateLimitPolicyBuilder PartitionByCurrentUser()
{
_partitionType = OperationRateLimitPartitionType.CurrentUser;
CommitToPolicyBuilder();
return _policyBuilder!;
}
/// <summary>
/// Auto resolve from ICurrentTenant.Id.
/// </summary>
public OperationRateLimitPolicyBuilder PartitionByCurrentTenant()
{
_partitionType = OperationRateLimitPartitionType.CurrentTenant;
CommitToPolicyBuilder();
return _policyBuilder!;
}
/// <summary>
/// Auto resolve from IClientIpAddressProvider.ClientIpAddress.
/// </summary>
public OperationRateLimitPolicyBuilder PartitionByClientIp()
{
_partitionType = OperationRateLimitPartitionType.ClientIp;
CommitToPolicyBuilder();
return _policyBuilder!;
}
/// <summary>
/// Partition by email address.
/// Resolves from context.Parameter, falls back to ICurrentUser.Email.
/// </summary>
public OperationRateLimitPolicyBuilder PartitionByEmail()
{
_partitionType = OperationRateLimitPartitionType.Email;
CommitToPolicyBuilder();
return _policyBuilder!;
}
/// <summary>
/// Partition by phone number.
/// Resolves from context.Parameter, falls back to ICurrentUser.PhoneNumber.
/// </summary>
public OperationRateLimitPolicyBuilder PartitionByPhoneNumber()
{
_partitionType = OperationRateLimitPartitionType.PhoneNumber;
CommitToPolicyBuilder();
return _policyBuilder!;
}
/// <summary>
/// Custom partition key resolver from context.
/// </summary>
public OperationRateLimitPolicyBuilder PartitionBy(
Func<OperationRateLimitContext, string> 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
};
}
}

16
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<OperationRateLimitContext, string>? CustomPartitionKeyResolver { get; set; }
public bool IsMultiTenant { get; set; }
}

18
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; }
}

14
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; }
}

18
framework/test/Volo.Abp.OperationRateLimit.Tests/Volo.Abp.OperationRateLimit.Tests.csproj

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\common.test.props" />
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace />
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Volo.Abp.Autofac\Volo.Abp.Autofac.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.ExceptionHandling\Volo.Abp.ExceptionHandling.csproj" />
<ProjectReference Include="..\AbpTestBase\AbpTestBase.csproj" />
<ProjectReference Include="..\..\src\Volo.Abp.OperationRateLimit\Volo.Abp.OperationRateLimit.csproj" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
</ItemGroup>
</Project>

99
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);
}
}

68
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;
/// <summary>
/// A mock store that simulates a concurrent race condition:
/// - GetAsync always says the quota is available (Phase 1 checks pass).
/// - IncrementAsync always says the quota is exhausted (Phase 2 finds another request consumed it).
/// </summary>
internal class RaceConditionSimulatorStore : IOperationRateLimitStore
{
public Task<OperationRateLimitStoreResult> GetAsync(string key, TimeSpan duration, int maxCount)
{
return Task.FromResult(new OperationRateLimitStoreResult
{
IsAllowed = true,
CurrentCount = 0,
MaxCount = maxCount
});
}
public Task<OperationRateLimitStoreResult> 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<IOperationRateLimitStore, RaceConditionSimulatorStore>());
Configure<AbpOperationRateLimitOptions>(options =>
{
options.AddPolicy("TestRacePolicy", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 3)
.PartitionByParameter();
});
});
}
}

179
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<IClientIpAddressProvider>();
mockIpProvider.ClientIpAddress.Returns("127.0.0.1");
context.Services.AddSingleton<IClientIpAddressProvider>(mockIpProvider);
Configure<AbpOperationRateLimitOptions>(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();
});
});
}
}

135
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<IOperationRateLimitStore>();
}
[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();
}
}

144
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;
/// <summary>
/// Tests for Fix #6: Phase 1 in CheckAsync now checks ALL rules before throwing,
/// so RetryAfter is the maximum across all blocking rules and RuleResults is complete.
/// </summary>
public class OperationRateLimitCheckerPhase1_Tests : OperationRateLimitTestBase
{
private readonly IOperationRateLimitChecker _checker;
public OperationRateLimitCheckerPhase1_Tests()
{
_checker = GetRequiredService<IOperationRateLimitChecker>();
}
[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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(async () =>
{
await _checker.CheckAsync("TestCompositePartialBlock", context);
});
exception.Result.RuleResults.ShouldNotBeNull();
exception.Result.RuleResults!.Count.ShouldBe(2);
// Rule0 is blocking
exception.Result.RuleResults[0].IsAllowed.ShouldBeFalse();
exception.Result.RuleResults[0].MaxCount.ShouldBe(1);
// Rule1 is still allowed (only 1/100 used), but is still present in results
exception.Result.RuleResults[1].IsAllowed.ShouldBeTrue();
exception.Result.RuleResults[1].MaxCount.ShouldBe(100);
exception.Result.RuleResults[1].RemainingCount.ShouldBe(99);
// The overall RetryAfter comes only from the blocking Rule0
exception.Result.RetryAfter.ShouldNotBeNull();
exception.Result.RetryAfter!.Value.TotalMinutes.ShouldBeLessThan(61); // ~1 hour from Rule0
}
}
/// <summary>
/// Tests for Fix #1: Phase 2 in CheckAsync now checks the result of AcquireAsync.
/// Uses a mock store that simulates a concurrent race condition:
/// GetAsync (Phase 1) always reports quota available, but IncrementAsync (Phase 2) returns denied.
/// </summary>
public class OperationRateLimitCheckerPhase2Race_Tests
: AbpIntegratedTest<AbpOperationRateLimitPhase2RaceTestModule>
{
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<IOperationRateLimitChecker>();
var context = new OperationRateLimitContext { Parameter = "race-test" };
var exception = await Assert.ThrowsAsync<AbpOperationRateLimitException>(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<IOperationRateLimitChecker>();
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();
}
}

731
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<IOperationRateLimitChecker>();
}
[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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(async () =>
{
await _checker.CheckAsync("TestSimple", context);
});
exception.Result.RetryAfter.ShouldNotBeNull();
exception.Result.RetryAfter!.Value.TotalSeconds.ShouldBeGreaterThan(0);
}
[Fact]
public async Task Should_Handle_Composite_Policy_All_Pass()
{
var userId = Guid.NewGuid();
using (var scope = ServiceProvider.CreateScope())
{
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>();
var claimsPrincipal = CreateClaimsPrincipal(userId);
using (principalAccessor.Change(claimsPrincipal))
{
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitChecker>();
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<ICurrentPrincipalAccessor>();
var claimsPrincipal = CreateClaimsPrincipal(userId);
using (principalAccessor.Change(claimsPrincipal))
{
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitChecker>();
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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(async () =>
{
await _checker.CheckAsync("TestCustomErrorCode", context);
});
exception.Code.ShouldBe("Test:CustomError");
}
[Fact]
public async Task Should_Throw_For_Unknown_Policy()
{
await Assert.ThrowsAsync<AbpException>(async () =>
{
await _checker.CheckAsync("NonExistentPolicy");
});
}
[Fact]
public async Task Should_Skip_When_Disabled()
{
var options = GetRequiredService<Microsoft.Extensions.Options.IOptions<AbpOperationRateLimitOptions>>();
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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(async () =>
{
await _checker.CheckAsync("TestEmailBased", context);
});
}
[Fact]
public async Task Should_Partition_By_Email_Via_CurrentUser_Fallback()
{
var userId = Guid.NewGuid();
using (var scope = ServiceProvider.CreateScope())
{
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>();
var claimsPrincipal = CreateClaimsPrincipal(userId);
using (principalAccessor.Change(claimsPrincipal))
{
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitChecker>();
// 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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(async () =>
{
await _checker.CheckAsync("TestPhoneNumberBased", context);
});
}
[Fact]
public async Task Should_Partition_By_PhoneNumber_Via_CurrentUser_Fallback()
{
var userId = Guid.NewGuid();
using (var scope = ServiceProvider.CreateScope())
{
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>();
var claimsPrincipal = CreateClaimsPrincipal(userId);
using (principalAccessor.Change(claimsPrincipal))
{
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitChecker>();
// 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<AbpOperationRateLimitException>(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<AbpException>(async () =>
{
await _checker.CheckAsync("TestEmailBased", context);
});
}
[Fact]
public async Task Should_Not_Waste_Rule1_Count_When_Rule2_Blocks()
{
// TestCompositeRule2First: Rule1 (Parameter, 5/hour), Rule2 (CurrentUser, 2/hour)
// Rule2 triggers at 2. Rule1 should NOT be incremented for blocked requests.
var userId = Guid.NewGuid();
using (var scope = ServiceProvider.CreateScope())
{
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>();
var claimsPrincipal = CreateClaimsPrincipal(userId);
using (principalAccessor.Change(claimsPrincipal))
{
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitChecker>();
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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(async () =>
{
await _checker.CheckAsync("TestCompositeParamIp", context2);
});
// param1 Rule1 should be at 2/5
var status1 = await _checker.GetStatusAsync("TestCompositeParamIp", context1);
status1.RuleResults![0].RemainingCount.ShouldBe(3); // Parameter: 2/5
status1.RuleResults[0].MaxCount.ShouldBe(5);
// param2 Rule1 should be at 1/5
var status2 = await _checker.GetStatusAsync("TestCompositeParamIp", context2);
status2.RuleResults![0].RemainingCount.ShouldBe(4); // Parameter: 1/5
status2.RuleResults[0].MaxCount.ShouldBe(5);
}
[Fact]
public async Task Should_Composite_Triple_Lowest_Limit_Triggers_First()
{
// TestCompositeTriple: Rule1 (Parameter, 5/hour), Rule2 (User, 4/hour), Rule3 (IP, 3/hour)
// IP limit (3) is lowest, should trigger first
var userId = Guid.NewGuid();
using (var scope = ServiceProvider.CreateScope())
{
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>();
var claimsPrincipal = CreateClaimsPrincipal(userId);
using (principalAccessor.Change(claimsPrincipal))
{
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitChecker>();
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<AbpOperationRateLimitException>(async () =>
{
await checker.CheckAsync("TestCompositeTriple", context);
});
// Verify all three rules
var status = await checker.GetStatusAsync("TestCompositeTriple", context);
status.RuleResults.ShouldNotBeNull();
status.RuleResults!.Count.ShouldBe(3);
status.RuleResults[0].RemainingCount.ShouldBe(2); // Parameter: 3/5
status.RuleResults[0].MaxCount.ShouldBe(5);
status.RuleResults[1].RemainingCount.ShouldBe(1); // User: 3/4
status.RuleResults[1].MaxCount.ShouldBe(4);
status.RuleResults[2].RemainingCount.ShouldBe(0); // IP: 3/3
status.RuleResults[2].MaxCount.ShouldBe(3);
}
}
}
[Fact]
public async Task Should_Composite_Triple_No_Wasted_Increment_On_Block()
{
// When IP (Rule3) blocks, Rule1 and Rule2 should NOT be incremented
var userId = Guid.NewGuid();
using (var scope = ServiceProvider.CreateScope())
{
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>();
var claimsPrincipal = CreateClaimsPrincipal(userId);
using (principalAccessor.Change(claimsPrincipal))
{
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitChecker>();
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<AbpOperationRateLimitException>(async () =>
{
await checker.CheckAsync("TestCompositeTriple", context);
});
}
// Verify Rule1 and Rule2 were NOT incremented beyond 3
var status = await checker.GetStatusAsync("TestCompositeTriple", context);
status.RuleResults![0].RemainingCount.ShouldBe(2); // Parameter: still 3/5
status.RuleResults[1].RemainingCount.ShouldBe(1); // User: still 3/4
status.RuleResults[2].RemainingCount.ShouldBe(0); // IP: still 3/3
}
}
}
[Fact]
public async Task Should_Composite_Reset_All_Rules()
{
// Verify reset clears all rules in a composite policy
var userId = Guid.NewGuid();
using (var scope = ServiceProvider.CreateScope())
{
var principalAccessor = scope.ServiceProvider.GetRequiredService<ICurrentPrincipalAccessor>();
var claimsPrincipal = CreateClaimsPrincipal(userId);
using (principalAccessor.Change(claimsPrincipal))
{
var checker = scope.ServiceProvider.GetRequiredService<IOperationRateLimitChecker>();
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<AbpOperationRateLimitException>(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<AbpException>(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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(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<AbpException>(() =>
{
options.AddPolicy("DuplicateRulePolicy", policy =>
{
policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter());
policy.AddRule(r => r.WithFixedWindow(TimeSpan.FromHours(1), 5).PartitionByParameter());
});
});
}
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"));
}
}

408
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<IOperationRateLimitChecker>();
_errorInfoConverter = GetRequiredService<IExceptionToErrorInfoConverter>();
_formatter = GetRequiredService<IOperationRateLimitFormatter>();
}
[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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(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<string>();
// WindowDurationSeconds: the configured window duration
var windowDurationSeconds = (int)exception.Data["WindowDurationSeconds"]!;
windowDurationSeconds.ShouldBe(3600); // 1 hour window
// WindowDescription: localized human-readable window description (e.g. "1 hour(s)")
exception.Data["WindowDescription"].ShouldNotBeNull();
exception.Data["WindowDescription"].ShouldBeOfType<string>();
// RuleDetails: complete rule information for frontend
var ruleDetails = exception.Data["RuleDetails"].ShouldBeOfType<List<Dictionary<string, object>>>();
ruleDetails.Count.ShouldBe(1);
ruleDetails[0]["RuleName"].ShouldBe("TestSimple:Rule[3600s,3,Parameter]");
ruleDetails[0]["MaxCount"].ShouldBe(3);
ruleDetails[0]["IsAllowed"].ShouldBe(false);
ruleDetails[0]["WindowDurationSeconds"].ShouldBe(3600);
((string)ruleDetails[0]["WindowDescription"]).ShouldNotBeNullOrEmpty();
((int)ruleDetails[0]["RetryAfterSeconds"]).ShouldBeGreaterThan(0);
((string)ruleDetails[0]["RetryAfter"]).ShouldNotBeNullOrEmpty();
// ExtraProperties passed through
exception.Data["Email"].ShouldBe("user@example.com");
}
[Fact]
public async Task GetStatusAsync_Should_Provide_Countdown_Data_For_Frontend()
{
var param = $"frontend-status-{Guid.NewGuid()}";
var context = new 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<AbpOperationRateLimitException>(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();
}
}

106
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;
/// <summary>
/// Verifies per-tenant isolation for tenant-scoped partition types and
/// global (cross-tenant) sharing for ClientIp partition type.
/// </summary>
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<ICurrentTenant>();
_checker = GetRequiredService<IOperationRateLimitChecker>();
}
[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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(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<AbpOperationRateLimitException>(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();
}
}
}

209
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<AbpException>(() =>
{
options.AddPolicy("EmptyPolicy", policy =>
{
// Intentionally not adding any rules
});
});
exception.Message.ShouldContain("no rules");
}
[Fact]
public void Should_Throw_When_WithFixedWindow_Without_PartitionBy()
{
var options = new AbpOperationRateLimitOptions();
var exception = Assert.Throws<AbpException>(() =>
{
options.AddPolicy("IncompletePolicy", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5);
// Missing PartitionBy*() call - rule never committed
});
});
exception.Message.ShouldContain("no rules");
}
[Fact]
public void Should_Throw_When_AddRule_Without_WithFixedWindow()
{
var options = new AbpOperationRateLimitOptions();
var exception = Assert.Throws<AbpException>(() =>
{
options.AddPolicy("NoWindowPolicy", policy =>
{
policy.AddRule(rule =>
{
// Missing WithFixedWindow call - duration is zero
});
});
});
exception.Message.ShouldContain("positive duration");
}
[Fact]
public void Should_Allow_MaxCount_Zero_For_Ban_Policy()
{
var options = new 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<AbpException>(() =>
{
options.AddPolicy("NoPartitionPolicy", policy =>
{
policy.AddRule(rule => rule
.WithFixedWindow(TimeSpan.FromHours(1), maxCount: 5));
// Missing PartitionBy*() call
});
});
exception.Message.ShouldContain("partition type");
}
[Fact]
public void Should_Throw_When_MaxCount_Is_Negative()
{
var options = new AbpOperationRateLimitOptions();
var exception = Assert.Throws<AbpException>(() =>
{
options.AddPolicy("NegativePolicy", policy =>
{
policy.WithFixedWindow(TimeSpan.FromHours(1), maxCount: -1)
.PartitionByParameter();
});
});
exception.Message.ShouldContain("maxCount >= 0");
}
}

11
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<AbpOperationRateLimitTestModule>
{
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
{
options.UseAutofac();
}
}

1
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",

Loading…
Cancel
Save