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.
Table of Contents
- 1. Why Magento ships no native rate limiting
- 2. Where rate limiting can sit: webserver layer versus application layer
- 3. Nginx-level rate limiting with limit_req_zone
- 4. Application layer approach: a plugin on Rest::dispatch
- 5. Identifying the rate limit key
- 6. The token bucket algorithm as a concept
- 7. Token bucket implementation with Redis and a Lua script
- 8. Delivering a correct 429 response including Retry-After
- 9. Difference from OAuth consumer limits, and monitoring
- 10. Summary
- 11. FAQ
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.