Implementing REST API Rate Limiting and Throttling in Magento
AI generated
M2
di.xml
Magento 2 · REST API · Security
REST API Rate Limiting
building throttling for Magento's web API the right way

Neither Magento Open Source nor Adobe Commerce On-Premise ship native rate limiting for the REST API, OAuth consumers and integration permissions only govern which resources a client may call, not how often. This article shows how to meaningfully combine the webserver and application layers, and walks through a token bucket implementation built as a plugin that counts consistently across multiple web nodes using Redis.

14 min read Token bucket · Redis Plugin, not a preference

1. Why Magento ships no native rate limiting

Magento's web API layer is consistently designed around access control rather than throughput control, OAuth consumers, integrations, and ACL roles decide which resources a client may call. None of these layers limits how often it may do so within a given time window, that is a deliberate gap in core, not an overlooked detail.

Throughput limiting depends heavily on infrastructure, traffic patterns, and the business model of a specific installation. A marketplace integrator processing thousands of orders per minute needs different values than a small B2B shop with a handful of partner systems, which is exactly why Magento deliberately delegates that responsibility to the webserver, infrastructure, or custom extensions instead of prescribing a one-size-fits-all solution.

On top of that, rate limiting can be tightly coupled to business logic, for example when a contract partner is guaranteed a specific API quota, or when different integrations carry very different criticality for ongoing operations. Such differentiated control would be hard to express generically in core while staying performant, without burdening every installation with unused complexity, which is another reason leaving this to extensions remains a defensible architectural choice.

2. Where rate limiting can sit: webserver layer versus application layer

At the webserver level, a rough guard against load spikes can be put in place with comparatively little effort. However, the webserver typically only knows an IP address or simple headers, no Magento-specific consumer identity, which makes it hard to cleanly express limits per integration or customer at that layer.

At the application level, the full set of Magento concepts is available instead, OAuth consumer identifier, integration name, customer group, which allows fine-grained, business-meaningful limits, at the cost of extra PHP execution time per request. In practice both layers usually complement each other rather than replacing one another.

A third, often overlooked layer is the database itself, even a cleanly limited REST access pattern can trigger expensive, slow database queries in the background whose cost no HTTP-level rate limiter captures. Anyone who genuinely wants to be robust against overload therefore combines request limits at the webserver and application layers with actual observation of database load per consumer, instead of relying on request count alone.

3. Nginx-level rate limiting with limit_req_zone

A limit_req_zone block defines a shared zone with a key, usually the client IP, and a rate. limit_req then applies that zone to a concrete location block such as /rest/, including an optional burst size to absorb short spikes.

The clear advantage is the low added latency, since rejected requests never even reach PHP-FPM. The downside remains the missing consumer identity, a NAT gateway with many users behind it, or a single integration client using multiple IPs, cannot be limited precisely this way.


# Rough guard at the webserver level, defined inside http {}
limit_req_zone $binary_remote_addr zone=magento_rest:10m rate=20r/s;

server {
    location /rest/ {
        limit_req zone=magento_rest burst=40 nodelay;
        try_files $uri $uri/ /index.php?$args;
    }
}

4. Application layer approach: a plugin on Rest::dispatch

The central entry point for every REST request is Magento\Webapi\Controller\Rest::dispatch. An around plugin at this point sees every request before the actual service contract method executes, and can abort processing when the limit is exceeded, without touching the rest of the webapi stack.

This solution is deliberately built as a plugin rather than a preference, following the same convention as the rest of the project, and slots cleanly before or after other webapi plugins through the regular module sequence.


<?php
declare(strict_types=1);

namespace Vendor\ApiRateLimit\Plugin;

use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\ResponseInterface;
use Magento\Webapi\Controller\Rest;
use Vendor\ApiRateLimit\Model\RateLimitDecision;
use Vendor\ApiRateLimit\Model\RateLimiterKeyResolver;
use Vendor\ApiRateLimit\Model\TokenBucketLimiter;

/**
 * Checks before every REST request whether the requesting client's rate
 * limit is already exhausted, and aborts processing in a controlled way if so.
 */
class RateLimitPlugin
{
    /**
     * @param TokenBucketLimiter $limiter
     * @param RateLimiterKeyResolver $keyResolver
     */
    public function __construct(
        private readonly TokenBucketLimiter $limiter,
        private readonly RateLimiterKeyResolver $keyResolver
    ) {
    }

    /**
     * Wraps Rest::dispatch and throws a webapi exception with status 429
     * when the limit is exceeded, before the request gets processed.
     *
     * @param Rest $subject
     * @param callable $proceed
     * @param RequestInterface $request
     * @return ResponseInterface
     * @throws \Magento\Framework\Webapi\Exception
     */
    public function aroundDispatch(Rest $subject, callable $proceed, RequestInterface $request): ResponseInterface
    {
        $key = $this->keyResolver->resolve($request);
        $decision = $this->limiter->consume($key);

        if (!$decision->isAllowed()) {
            throw new \Magento\Framework\Webapi\Exception(
                __('Rate limit exceeded, retry in %1 seconds.', $decision->getRetryAfterSeconds()),
                0,
                429
            );
        }

        return $proceed($request);
    }
}

5. Identifying the rate limit key

For authenticated requests, the request carries the OAuth consumer identifier or the integration token, both work considerably better than the IP address, since an integration behind changing IPs or multiple servers should still be limited consistently.

For unauthenticated or malformed requests, the IP address remains a reasonable fallback, though with a considerably stricter quota, since unauthenticated endpoints are typically the primary target of automated attacks.

6. The token bucket algorithm as a concept

A token bucket has a fixed capacity and a refill rate. Every request consumes one token, if the bucket is empty the request gets rejected, otherwise the fill level drops by one, and over time the bucket refills up to its capacity again.

The decisive advantage over a rigid fixed-window counter is that short bursts stay allowed up to the bucket's capacity, while the average throughput is still strictly bounded by the refill rate, which comes considerably closer to real user behavior than a hard per-second window.

7. Token bucket implementation with Redis and a Lua script

Because reading, decrementing, and writing the fill level must happen atomically, a plain cache access is not enough. Under load, race conditions would otherwise occur where multiple parallel requests read the same, already exhausted fill level and all get incorrectly allowed through.

Redis executes a Lua script as a single, indivisible operation, which guarantees that reading and writing the fill level can never be interrupted by a parallel request. This works correctly even when several Magento web nodes operate against the same Redis counter simultaneously.


-- KEYS[1] = bucket key, ARGV[1] = capacity, ARGV[2] = refill rate per second,
-- ARGV[3] = current unix timestamp, ARGV[4] = requested tokens (usually 1)
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local bucket = redis.call("HMGET", KEYS[1], "tokens", "updated_at")
local tokens = tonumber(bucket[1]) or capacity
local updated_at = tonumber(bucket[2]) or now

local elapsed = math.max(0, now - updated_at)
tokens = math.min(capacity, tokens + elapsed * refill_rate)

if tokens < requested then
    redis.call("HMSET", KEYS[1], "tokens", tokens, "updated_at", now)
    redis.call("EXPIRE", KEYS[1], 3600)
    return {0, math.ceil((requested - tokens) / refill_rate)}
end

tokens = tokens - requested
redis.call("HMSET", KEYS[1], "tokens", tokens, "updated_at", now)
redis.call("EXPIRE", KEYS[1], 3600)
return {1, 0}

8. Delivering a correct 429 response including Retry-After

Magento\Framework\Webapi\Exception carries both a message and an explicit HTTP status code. For a rate limit violation, status 429 combined with a Retry-After header stating the seconds until the next reasonable attempt fits well.

A well-behaved client can read that header and automatically retry after an appropriate delay. Silently dropping the request without a meaningful status instead forces every client into its own, often unsuitable retry behavior.


<?php
declare(strict_types=1);

namespace Vendor\ApiRateLimit\Model;

/**
 * Represents the outcome of a token bucket check for a rate limit key.
 */
final class RateLimitDecision
{
    /**
     * @param bool $allowed
     * @param int $retryAfterSeconds
     */
    public function __construct(private readonly bool $allowed, private readonly int $retryAfterSeconds)
    {
    }

    /**
     * @return bool
     */
    public function isAllowed(): bool
    {
        return $this->allowed;
    }

    /**
     * @return int
     */
    public function getRetryAfterSeconds(): int
    {
        return $this->retryAfterSeconds;
    }
}

9. Difference from OAuth consumer limits, and monitoring

OAuth consumers and integration permissions answer the question of which resources a client may call at all, a rate limiter answers the independent question of how often it may do so within a given time window. Both mechanisms complement each other, neither replaces the other.

Rejected requests should be logged with consumer identifier, endpoint, and timestamp. A rising rejection rate after an integration release usually points to a limit set too tight, while a sudden spike from many different, unauthenticated sources more likely points to an actual attack attempt.

Layer Tool Aware of OAuth consumer? Typical use
Webserver Nginx limit_req_zone No, only IP or headers Rough protection before the application
Application (plugin) Around plugin on Rest::dispatch Yes, via request header or consumer ID Fine-grained limit per customer or integration
Application (token bucket) Redis plus Lua script Yes Precise, atomic counting across multiple web nodes
Infrastructure or CDN Fastly or cloud edge rules Partially, usually only via header pass-through Protection against layer 7 load spikes
OAuth integration (admin) Integration permissions and ACL Yes, but no rate limit Access control, not throughput limiting

Mironsoft

Magento development, module consulting, and system architecture

A Magento project that needs a second opinion or experienced execution?

We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.

Architecture Consulting

Have module and system architecture thought through properly before you build.

Custom Module Development

Build custom Magento modules cleanly, following best practices.

Code Review & Audit

Have existing modules reviewed for performance, security, and maintainability.

10. Summary

REST API Rate Limiting in Magento: The Essentials

Starting point

Magento ships no native rate limiting for REST, OAuth consumers only govern access rights, not throughput.

Recommended layer

An application layer plugin on the webapi dispatch, complemented by rough nginx limiting in front of it.

Algorithm

Token bucket with an atomic Redis Lua script for consistent counting across multiple web nodes.

Response behavior

HTTP 429 with a Retry-After header when exceeded, instead of silently dropping the request.

11. FAQ: REST API Rate Limiting in Magento: The Essentials

1Does Magento ship built-in rate limiting for the REST API?
No, neither Community Edition nor On-Premise Adobe Commerce contain a native throughput limit, OAuth and integration permissions only govern what a consumer may do, not how often.
2Is nginx-level rate limiting enough on its own?
For rough protection, yes, but nginx usually only knows the IP address or simple headers, no Magento-specific consumer identity, fine-grained limits per integration need the application layer.
3Why Redis instead of a plain cache counter?
Because a token bucket counter must be read, decremented, and written atomically, Redis allows that through a Lua script in a single step, a normal cache read-write is not race safe under load.
4Where exactly does the plugin hook in?
At Magento\Webapi\Controller\Rest::dispatch, the central entry point for every REST request, as an around plugin before the actual processing.
5What HTTP status should I return when the limit is exceeded?
429 Too Many Requests, complemented by a Retry-After header, so well-behaved clients know when a retry makes sense.
6How do I identify the rate limit key?
Preferably through the OAuth consumer identifier or the integration token, with the IP address as a fallback for unauthenticated or malformed requests.
7Do limits differ per integration?
Yes, sensibly every integration gets its own quota depending on its expected load, a bulk import process needs different values than a storefront application.
8Does the token bucket approach work with multiple web servers?
Yes, as long as the counter lives centrally in Redis rather than locally per PHP process, otherwise each node counts independently and the effective limit gets multiplied by the number of nodes.
9What is the difference from OAuth consumer limits?
OAuth consumers and integration permissions control which resources a client may call at all, a rate limiter independently controls how often it may do so within a given time window.
10Should I enable rate limiting for internal, trusted integrations too?
Usually yes, with a more generous quota, since even a misconfigured internal client can unintentionally become a load source through an infinite loop.