diff --git a/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md b/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md new file mode 100644 index 0000000000..91a9e26aff --- /dev/null +++ b/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/POST.md @@ -0,0 +1,188 @@ +# Operation Rate Limiting in ABP Framework + +Almost every user-facing system eventually runs into the same problem: **some operations cannot be allowed to run without limits**. + +Sometimes it's a cost issue — sending an SMS costs money, and generating a report hammers the database. Sometimes it's security — a login endpoint with no attempt limit is an open invitation for brute-force attacks. And sometimes it's a matter of fairness — your paid plan says "up to 100 data exports per month," and you need to actually enforce that. + +What all these cases have in common is that the thing being limited isn't an HTTP request — it's a *business operation*, performed by a specific *who*, doing a specific *what*, against a specific *resource*. + +ASP.NET Core ships with a built-in [rate limiting middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) that sits in the HTTP pipeline. It's excellent for broad API protection — throttling requests per IP to fend off bots or DDoS traffic. But it only sees HTTP requests. It can tell you how many requests came from an IP address; it cannot tell you: + +- **"How many verification codes has this phone number received today?"** The moment the user switches networks, the counter resets — completely useless +- **"How many reports has this user exported today?"** Switching from mobile to desktop gives them a fresh counter +- **"How many times has someone tried to log in as `alice`?"** An attacker rotating through dozens of IPs will never hit the per-IP limit + +There's another gap: some rate-limiting logic has no corresponding HTTP endpoint at all — it lives inside an application service method called by multiple endpoints, or triggered by a background job. HTTP middleware has no place to hook in. + +Real-world requirements tend to look like this: + +- The same phone number can receive at most 3 verification codes per hour, regardless of which device or IP the request comes from +- Each user can generate at most 2 monthly sales reports per day, because a single report query scans millions of records +- Login attempts are limited to 5 failures per username per 5 minutes, *and* 20 failures per IP per hour — two independent counters, both enforced simultaneously +- Free-tier users get 50 AI calls per month, paid users get 500 — this is a product-defined quota, not a security measure +- Your system integrates with an LLM provider (OpenAI, Azure OpenAI, etc.) where every call has a real dollar cost. Without per-user or per-tenant limits, a single user can exhaust your monthly budget overnight + +The pattern is clear: the identity being throttled is a **business identity** — a user, a phone number, a resource ID — not an IP address. And the action being throttled is a **business operation**, not an HTTP request. + +ABP Framework's **Operation Rate Limiting** module is built for exactly this. It lets you enforce limits directly in your application or domain layer, with full awareness of who is doing what. + +Add the package to your project: + +```bash +abp add-package Volo.Abp.OperationRateLimiting +``` + +> Operation Rate Limiting is available starting from **ABP Framework 10.3**. See the [pull request](https://github.com/abpframework/abp/pull/25024) for details. + +## Defining a Policy + +The model is straightforward: define a named policy in `ConfigureServices`, then call `CheckAsync` wherever you need to enforce it. + +Name your policies after the business action they protect — `"SendSmsCode"`, `"GenerateReport"`, `"CallAI"`. A clear name makes the intent obvious at the call site, and avoids the mystery of something like `"policy1"`. + +```csharp +Configure(options => +{ + options.AddPolicy("SendSmsCode", policy => + { + policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter(); + }); +}); +``` + +- `WithFixedWindow` sets the time window and maximum count — here, at most 1 call per minute +- `PartitionByParameter` means each distinct value you pass at call time (such as a phone number) gets its own independent counter + +Then inject `IOperationRateLimitingChecker` and call `CheckAsync` at the top of the method you want to protect: + +```csharp +public class SmsAppService : ApplicationService +{ + private readonly IOperationRateLimitingChecker _rateLimitChecker; + + public SmsAppService(IOperationRateLimitingChecker rateLimitChecker) + { + _rateLimitChecker = rateLimitChecker; + } + + public async Task SendCodeAsync(string phoneNumber) + { + await _rateLimitChecker.CheckAsync("SendSmsCode", phoneNumber); + + // Limit not exceeded — proceed with sending the SMS + } +} +``` + +`CheckAsync` checks the current usage against the limit and throws `AbpOperationRateLimitingException` (HTTP 429) if the limit is already exceeded. If the check passes, it then increments the counter and proceeds. ABP's exception pipeline catches this automatically and returns a standard error response. Put `CheckAsync` first — the rate limit check is the gate, and everything else only runs if it passes. + +## Choosing a Partition Type + +The partition type controls **how counters are isolated from each other** — it's the most important decision when setting up a policy, because it determines *what dimension you're counting across*. + +Getting this wrong can make your rate limiting completely ineffective. Using `PartitionByClientIp` for SMS verification? An attacker just needs to switch networks. Using `PartitionByCurrentUser` for a login endpoint? There's no current user before login, so the counter has nowhere to land. + +- **`PartitionByParameter`** — uses the value you explicitly pass as the partition key. This is the most flexible option. Pass a phone number, an email address, a resource ID, or any business identifier you have at hand. It's the right choice whenever you know exactly what the "who" is. +- **`PartitionByCurrentUser`** — uses the authenticated user's ID, with no value to pass. Perfect for "each user gets N per day" scenarios where user identity is all you need. +- **`PartitionByClientIp`** — uses the client's IP address. Don't rely on this alone — it's too easy to rotate. Use it as a secondary layer alongside another partition type, as in the login example below. +- **`PartitionByEmail`** and **`PartitionByPhoneNumber`** — designed for pre-authentication flows where the user isn't logged in yet. They prefer the `Parameter` value you explicitly pass, and fall back to the current user's email or phone number if none is provided. +- **`PartitionBy`** — a custom async delegate that can produce any partition key you need. When the built-in options don't fit, you're free to implement whatever logic makes sense: look up a resource's owner in the database, derive a key from the user's subscription tier, partition by tenant — anything that returns a string. + +> The rule of thumb: partition by the identity of whoever's behavior you're trying to limit. + +## Combining Rules in One Policy + +A single rule covers most cases, but sometimes you need to enforce limits across multiple dimensions simultaneously. Login protection is the textbook example: throttling by username alone doesn't stop an attacker from targeting many accounts; throttling by IP alone doesn't stop an attacker with a botnet. You need both, at the same time. + +```csharp +options.AddPolicy("Login", policy => +{ + // Rule 1: at most 5 attempts per username per 5-minute window + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromMinutes(5), maxCount: 5) + .PartitionByParameter()); + + // Rule 2: at most 20 attempts per IP per hour, counted independently + policy.AddRule(rule => rule + .WithFixedWindow(TimeSpan.FromHours(1), maxCount: 20) + .PartitionByClientIp()); +}); +``` + +The two counters are completely independent. If `alice` fails 5 times, her account is locked — but other accounts from the same IP are unaffected. If an IP accumulates 20 failures, it's blocked — but `alice` can still be targeted from other IPs until their own counters fill up. + +When multiple rules are present, the module uses a two-phase approach: it checks all rules first, and only increments counters if every rule passes. This prevents a rule from consuming quota on a request that would have been rejected by another rule anyway. + +## Beyond Just Checking + +Not every scenario calls for throwing an exception. `IOperationRateLimitingChecker` provides three additional methods for more nuanced control. + +**`IsAllowedAsync`** performs a read-only check — it returns `true` or `false` without touching any counter. The most common use case is UI pre-checking: when a user opens the "send verification code" page, check the limit first. If they've already hit it, disable the button and show a countdown immediately, rather than making them click and get an error. That's a meaningfully better experience. + +```csharp +var isAllowed = await _rateLimitChecker.IsAllowedAsync("SendSmsCode", phoneNumber); +``` + +**`GetStatusAsync`** also reads without incrementing, but returns richer data: `RemainingCount`, `RetryAfter`, and `CurrentCount`. This is what you need to build quota displays — "You have 2 exports remaining today" or "Please try again in 47 seconds" — which are far friendlier than a raw 429. + +```csharp +var status = await _rateLimitChecker.GetStatusAsync("SendSmsCode", phoneNumber); +// status.RemainingCount, status.RetryAfter, status.IsAllowed ... +``` + +**`ResetAsync`** clears the counter for a given policy and context. Useful in admin panels where support staff can manually unblock a user, or in test environments where you need to reset state between runs. + +```csharp +await _rateLimitChecker.ResetAsync("SendSmsCode", phoneNumber); +``` + +## When the Limit Is Hit + +When `CheckAsync` triggers, it throws `AbpOperationRateLimitingException`, which: + +- Inherits from `BusinessException` and maps to HTTP **429 Too Many Requests** +- Is handled automatically by ABP's exception pipeline +- Carries useful metadata: `RetryAfterSeconds`, `RemainingCount`, `MaxCount`, `CurrentCount` + +By default, the error code sent to the client is a generic one from the module. If you want each operation to produce its own localized message — "Too many verification code requests, please wait before trying again" instead of a generic error — assign a custom error code to the policy: + +```csharp +options.AddPolicy("SendSmsCode", policy => +{ + policy.WithFixedWindow(TimeSpan.FromMinutes(1), maxCount: 1) + .PartitionByParameter() + .WithErrorCode("App:SmsCodeLimit"); +}); +``` + +> For details on mapping error codes to localized messages, see [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) in the ABP docs. + +## Turning It Off in Development + +Rate limiting and local development don't mix well. When you're iterating quickly and calling the same endpoint a dozen times to test something, getting blocked by a 429 every few seconds is genuinely painful. Disable the module in your development environment: + +```csharp +public override void ConfigureServices(ServiceConfigurationContext context) +{ + var hostEnvironment = context.Services.GetHostingEnvironment(); + + Configure(options => + { + if (hostEnvironment.IsDevelopment()) + { + options.IsEnabled = false; + } + }); +} +``` + +## Summary + +ABP's Operation Rate Limiting fills the gap that ASP.NET Core's HTTP middleware can't: rate limiting with real awareness of *who* is doing *what*. Define a named policy, pick a time window, a max count, and a partition type. Call `CheckAsync` wherever you need it. Counter storage, distributed locking, and exception handling are all taken care of. + +## References + +- [Operation Rate Limiting](https://abp.io/docs/latest/framework/infrastructure/operation-rate-limiting) +- [ASP.NET Core Rate Limiting Middleware](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit) +- [Exception Handling](https://abp.io/docs/latest/framework/fundamentals/exception-handling) diff --git a/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/cover.jpeg b/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/cover.jpeg new file mode 100644 index 0000000000..c9deca9026 Binary files /dev/null and b/docs/en/Community-Articles/2026-03-10-Operation-Rate-Limiting-in-ABP-Framework/cover.jpeg differ