Throttles, and what happens when Redis is down
The four rate limits guarding the storefront with their exact numbers and keys, which one refuses checkout rather than degrading, and how to tell a throttle from a real failure.
Four named throttles guard the store, plus the platform's baseline per-IP cap.
None of the four is configurable — they are constants in
backend/src/handler/Middleware.ts and changing them means editing and
rebuilding the backend. Knowing the numbers is therefore the whole job: a
customer complaint that "checkout says I have tried too many times" is not a
bug, and a support agent who does not know the figure will look for one.
The four store throttles
| Bucket | Limit | Window | Redis key prefix | On store failure | Applies to |
|---|---|---|---|---|---|
orderCreation |
5 | 15 minutes | order_create |
Refuses | POST /api/ecommerce/cart/checkout and POST /api/ecommerce/order |
download |
10 | 1 hour | download |
Allows | GET /api/ecommerce/download/{orderItemId} and GET /api/ecommerce/download/{orderItemId}/file |
discountValidation |
20 | 60 seconds | discount_check |
Allows | POST /api/ecommerce/discount/validate |
moderate (shared) |
30 | 60 seconds | moderate |
Allows | POST /api/ecommerce/review/{productId}, plus unrelated core endpoints |
Each message is fixed:
| Bucket | Message the customer sees |
|---|---|
orderCreation |
Too many order attempts. Please wait before placing another order. |
download |
Download limit exceeded. Please try again later. |
discountValidation |
Too many discount validation attempts. Please try again later. |
moderate |
Too many requests. Please slow down. |
All four are per signed-in user — every one of these routes requires
authentication, so the counter key is <prefix>:user:<userId>. Rotating IP
addresses buys a customer nothing, and a shared office IP costs them nothing.
Checkout refuses rather than degrades
orderCreation is the only store throttle marked fail-closed. If the
limiter cannot read or write its counter, it answers no instead of waving the
request through.
Every other throttle on this list fails open — a counter it cannot read is treated as "not exhausted yet" and the request proceeds. Checkout does the opposite, deliberately, because an attacker who can knock the counter store over must not thereby get unlimited attempts at a money-moving endpoint.
The consequence is that outside MySQL, the limiter's backing store is the single highest-impact dependency the store has. When it goes, browsing works, carts work, downloads work — and no order can be placed. If the storefront is refusing every checkout with the "too many order attempts" message while your own test account has clearly not placed five orders, look at Redis before you look at the store.
Five orders per fifteen minutes is a checkout-shaped budget, not a trading one: one cart is one request no matter how many lines it contains, and the limiter runs once per checkout, not once per item. A customer who genuinely wants six separate purchases in a quarter of an hour will be stopped — that is the intended trade-off, and there is no setting to widen it.
What "Redis is down" actually means now
Redis is a hard dependency of this backend, not a cache it can do without. There is no embedded fallback store any more; an earlier build had one and it was removed, because it made a platform that could not coordinate anything look healthy.
The behaviour splits in two:
- At boot, if Redis does not answer within 30 seconds the backend prints an
actionable message and exits with code 78. That code is listed in
stop_exit_codesin every PM2 production config, so the process is stopped with the message still on screen rather than crash-looping until it scrolls away. Restarting will not fix it; only starting Redis will. - At runtime, a serving process does not exit on a blip. Commands issued during a reconnect wait for the socket instead of failing instantly, bounded by a 5-second per-command timeout and three retries. So a reconnect measured in hundreds of milliseconds is invisible to customers; an outage measured in seconds produces real errors at the call site — and that is where fail-closed decides.
In practice: a brief flap costs nobody an order. A sustained outage takes checkout down first and hardest, while downloads, discount checks and review posting keep working unthrottled.
The baseline cap underneath all of this
Every POST, PUT, PATCH and DELETE on the platform passes a per-IP cap
before it reaches the route's own limiter. GET requests skip it entirely.
.env.RATE_LIMIT_EXPIRY is still honoured, but this name wins.Both are documented with the rest of the platform's environment in Environment variables.
Two consequences for the store:
- Both download routes are
GET, so the baseline never touches them. Their only bound is the 10-per-hourdownloadbucket. - When two limiters cover one request, the smaller one decides. On checkout
that is always the 5-per-15-minutes bucket, so raising
RATE_LIMITdoes nothing for it.
Two gaps worth knowing about
Applying a code is not throttled. POST /api/ecommerce/discount/validate
carries the 20-per-minute discount_check bucket. POST /api/ecommerce/discount/{productId}, which applies a code by its text, carries
no named limiter — only the baseline 100 per minute per IP. If you are
worried about someone guessing codes, that is the endpoint to watch, and the
defence is the code itself: keep them long and do not use guessable patterns.
See Discount codes.
Review posting shares a bucket with money endpoints. Review submission runs
on the platform's shared moderate limiter. Its 30-per-60-seconds allowance is
not the store's: on an install that also runs Ecosystem or Swap it is consumed by
ecosystem withdrawals, ecosystem wallet transfers, the cancel-all-orders endpoint
and swap quote refreshes as well. The counter is per user, so a customer who is
actively trading can spend the budget elsewhere and then be told to "slow down"
when they try to leave a product review. It is a shared bucket, not a
review-specific one.
Telling a throttle from a real failure
This platform pins the status line at 200 on nearly every response and puts
the real code in the body. A throttle refusal carries statusCode: 429 inside
the JSON, alongside the limiter's own message. A tool that branches on the
HTTP status will read a throttled checkout as a successful one.
So identify a throttle by its message, not its status:
| What you are looking at | What it is |
|---|---|
| One of the four messages above, verbatim | A throttle. The customer waits out the window |
Insufficient balance in <CURRENCY> wallet, Insufficient inventory, Product is not available |
A real refusal — see Checkout and download errors |
Rate Limit Exceeded, Try Again Later |
The baseline per-IP cap, not a store throttle. Usually a script or a proxy collapsing many users onto one address |
A refused request also stages Retry-After, X-RateLimit-Limit and
X-RateLimit-Remaining headers, so the exact seconds remaining are visible in
the browser's network tab if you need them.
Clearing a throttle for one customer
There is no admin screen for this. The counter is a plain Redis key, and deleting it resets that customer's window immediately:
# The user's own checkout counter
redis-cli DEL order_create:user:<userId>
# Their download counter
redis-cli DEL download:user:<userId>
# See what is left on it first
redis-cli TTL order_create:user:<userId>The 5-per-15-minutes cap on order_create exists because that endpoint moves
money out of a wallet. Clearing it hands one customer unlimited checkout attempts
for the next window. Do it for a support case you have verified, not as routine
housekeeping, and never in a loop.
Restarting the backend does not clear these counters — they live in Redis with a TTL, not in the process.
Related
- Checkout and download errors — every other refusal message and what caused it.
- Orders and fulfilment — what checkout does once it gets past the throttle.
- Digital delivery — why a customer might be hitting the download endpoint repeatedly in the first place.