Rate Limiting and Hardening Custom Endpoints
Rate Limiting and Hardening Custom Endpoints
~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Chapters 80-83 opened six access points onto the same business logic: two REST routes for the points balance, one write-capable REST route for redeeming, two GraphQL fields for the points balance and reward catalog, and one GraphQL mutation for redeeming. Each of them is already protected against the wrong caller via ACL or self/force. A different question remains open: what stops an authorized customer - or a leaked token, or a buggy mobile app stuck in a loop - from calling POST /V1/loyalty/rewards/:rewardId/redeem a hundred times a second?
What ACL doesn't cover: who vs. how often
Mironsoft_Loyalty::points_view (chapter 80) and force="true" (also chapter 80) only ever answer "is this caller allowed to access this at all, and if so, only their own data?". A fully authorized, logged-in customer passes that check again on every single call - ACL has no notion of "too often". That's exactly the gap rate limiting closes, and in this series it mainly concerns the single write-capable route: RewardRedemptionManagementInterface::redeem() genuinely changes the points balance and writes a ledger entry (chapter 6) on every call - unlike the purely read-only fields from chapters 80/82.
What Magento Open Source ships with - and what it doesn't
Contrary to what one might expect, Magento Open Source ships with no generic mechanism that automatically throttles a custom webapi.xml or schema.graphqls endpoint. Magento's own customer login lockout (Magento\Customer\Model\Authentication) does throttle failed login attempts - but through failures_num/first_failure columns directly on the customer entity, not through a reusable caching service, and exclusively for the login flow itself. Adobe Commerce Cloud offers a genuine API rate-limiting layer at the infrastructure level via Fastly - unavailable for this open-source project running on a Mark Shust setup.
An Nginx-level limit_req would be the obvious alternative, but it fails on granularity here: Nginx only sees the URL /rest/en/V1/loyalty/rewards/123/redeem, or the single shared URL /graphql for every GraphQL field this shop exposes - it knows neither the authenticated customer behind the token nor the difference between loyaltyRewards (read-only, uncritical) and redeemLoyaltyReward (write-capable, sensitive) within the very same GraphQL request. Only the application itself knows that distinction - which is exactly why the solution belongs in PHP, not the web server configuration.
The shared hook point: RewardRedemptionManagementInterface::redeem()
Chapter 81 deliberately extracted the redemption logic into its own service class, and chapter 83 showed that both the REST route and the GraphQL mutation call that exact same method. The same reuse pays off a second time here: instead of two separate checks - one in the webapi layer, one in the GraphQL resolver - a single plugin on the interface method itself is enough:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\RateLimiter;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\Serializer\Json;
/**
* Cache-backed fixed-window rate limiter for reward redemption attempts.
* Deliberately the generic application cache instead of chapter 8's
* LoyaltyCatalog cache type - that type exists for tag-based catalog
* invalidation, and reusing it here would mean an admin flushing the
* catalog cache accidentally resets every customer's attempt counter, and
* vice versa. A rate-limit counter has no invalidation tag of its own; it
* only needs a short, self-expiring lifetime, which a plain cache entry
* already provides.
*/
class RedemptionRateLimiter
{
private const CACHE_ID_PREFIX = 'mironsoft_loyalty_redeem_attempts_';
private const MAX_ATTEMPTS = 5;
private const WINDOW_SECONDS = 60;
/**
* @param CacheInterface $cache Generic application cache frontend.
* @param Json $json Serializes the attempt counter into the cache entry.
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly Json $json,
) {
}
/**
* Whether the given customer has already exhausted the redemption quota
* for the currently active time window.
*
* @param int $customerId Customer entity ID attempting a redemption.
* @return bool
*/
public function isExceeded(int $customerId): bool
{
return $this->getAttemptCount($customerId) >= self::MAX_ATTEMPTS;
}
/**
* Registers one more redemption attempt for the given customer. A cache
* miss starts a fresh window with a full WINDOW_SECONDS lifetime; an
* existing entry keeps counting within its already-shrinking expiry.
*
* @param int $customerId Customer entity ID attempting a redemption.
* @return void
*/
public function registerAttempt(int $customerId): void
{
$count = $this->getAttemptCount($customerId) + 1;
$this->cache->save(
$this->json->serialize(['count' => $count]),
self::CACHE_ID_PREFIX . $customerId,
[],
self::WINDOW_SECONDS
);
}
/**
* Reads the current attempt count for the given customer's active
* window, or zero if no window is active (cache miss or expired entry).
*
* @param int $customerId Customer entity ID.
* @return int
*/
private function getAttemptCount(int $customerId): int
{
$cached = $this->cache->load(self::CACHE_ID_PREFIX . $customerId);
if ($cached === false) {
return 0;
}
/** @var array{count: int} $data */
$data = $this->json->unserialize($cached);
return (int) $data['count'];
}
}
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Plugin\Api;
use Magento\Framework\Webapi\Exception as WebapiException;
use Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface;
use Mironsoft\Loyalty\Model\RateLimiter\RedemptionRateLimiter;
/**
* Throttles reward redemption attempts before RewardRedemptionManagement::redeem()
* runs. Placed on the shared service contract (chapter 79) rather than on
* either transport controller, so both the REST route (chapter 81) and the
* GraphQL mutation (chapter 83) are covered by a single check instead of two
* duplicated ones - a client cannot dodge the limit by simply switching
* transports.
*/
class ThrottleRewardRedemptionPlugin
{
/**
* @param RedemptionRateLimiter $rateLimiter Cache-backed attempt counter.
*/
public function __construct(
private readonly RedemptionRateLimiter $rateLimiter,
) {
}
/**
* @param RewardRedemptionManagementInterface $subject Intercepted service contract.
* @param int $rewardId Reward entity ID to redeem.
* @param int $customerId Customer entity ID redeeming the reward.
* @return array{0: int, 1: int}
* @throws WebapiException If the customer has exceeded the redemption quota.
*/
public function beforeRedeem(
RewardRedemptionManagementInterface $subject,
int $rewardId,
int $customerId
): array {
if ($this->rateLimiter->isExceeded($customerId)) {
throw new WebapiException(
__('Too many redemption attempts. Please wait a moment before trying again.'),
0,
WebapiException::HTTP_TOO_MANY_REQUESTS
);
}
$this->rateLimiter->registerAttempt($customerId);
return [$rewardId, $customerId];
}
}
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Mironsoft\Loyalty\Api\RewardRedemptionManagementInterface">
<plugin name="mironsoft_loyalty_throttle_reward_redemption"
type="Mironsoft\Loyalty\Plugin\Api\ThrottleRewardRedemptionPlugin"/>
</type>
</config>
Achtung: The same known, deliberately unresolved pattern as RewardRedemptionManagement::redeem() itself (chapter 81): load() followed by save() isn't atomic. Two nearly simultaneous requests from the same customer could both read the same, not-yet-updated counter value and both slip through - the counter then slightly undercounts. That's tolerable for an abuse brake; anyone needing a strict guarantee replaces CacheInterface::save()/load() with an atomic INCR/EXPIRE straight against Redis.
HTTP 429 over REST - and what survives the trip to GraphQL
\Magento\Framework\Webapi\Exception is chosen deliberately instead of a plain LocalizedException: Webapi\ErrorProcessor::maskException() passes an exception that's already of type Webapi\Exception through unchanged, instead of blanket-mapping it to HTTP 400 like every other LocalizedException. Over REST, the client genuinely receives HTTP 429 Too Many Requests, not the generic 400 from chapter 81.
Over GraphQL, something interesting happens: Webapi\Exception extends LocalizedException - the exact class the resolver from chapter 83 already catches in its catch (LocalizedException $exception) block and remaps to GraphQlInputException. Without any further change to RedeemLoyaltyReward, the rate-limit message therefore reaches the client over GraphQL too - just without the 429 semantics, since GraphQL has no per-field HTTP status concept anyway. The message stays identical, but extensions.category simply reads graphql-input, not "too many requests".
Testing the rate limiter
# Six redemption attempts in a row - the sixth must return HTTP 429:
for i in {1..6}; do
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
https://mironsoft.test/rest/V1/loyalty/rewards/42/redeem \
-H 'Authorization: Bearer <token>'
doneThe public loyaltyRewards query needs a different lever
RedemptionRateLimiter deliberately keys off the customer ID - a good fit for redeemLoyaltyReward//redeem, since both paths require an authenticated customer. loyaltyRewards (chapter 82), by contrast, is deliberately guest-accessible and has no customer ID for a counter to key off at all. For that case, the infrastructure lever dismissed earlier is actually the right one: an IP-based limit_req at the Nginx level, coarse enough for a public, read-only catalog endpoint, but without the per-customer granularity the redemption endpoint needs. Two different threat models, two different, deliberately separate solutions - no single mechanism covers both equally well.
With abuse fended off, chapter 87 wraps up block 10: how does an authorized client - a real mobile app, a partner system - even discover these six endpoints without reading this module's source code?