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.

5 min readUpdated 6 August 2026rate-limit, throttle, redis, checkout, downloads

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_codes in 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.

Requests per IP per window on mutating routes. This one IS configurable, in .env.
The window in seconds. The legacy spelling 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-hour download bucket.
  • When two limiters cover one request, the smaller one decides. On checkout that is always the 5-per-15-minutes bucket, so raising RATE_LIMIT does 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.