Operation Rate Limiting in ABP Framework

cover

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 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:

abp add-package Volo.Abp.OperationRateLimiting

Operation Rate Limiting is available starting from ABP Framework 10.3. See the pull request 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".

Configure<AbpOperationRateLimitingOptions>(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:

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.

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.

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.

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.

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:

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 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:

public override void ConfigureServices(ServiceConfigurationContext context)
{
    var hostEnvironment = context.Services.GetHostingEnvironment();

    Configure<AbpOperationRateLimitingOptions>(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