Layering Edge and Origin Rate Limits

Two limiters in the same request path is the normal production shape, and the only hard rule is that they must never disagree about who is in charge. Getting the ratio right is the whole job: too close together and clients receive rejections that contradict the quota headers they were just given; too far apart and the edge stops shielding anything. This page turns the tier model from edge and gateway enforcement into a specific number you can configure and test.

The problem in concrete numbers

Your API publishes 100 requests per minute per key. Your edge shield is per-IP because the edge cannot authenticate. A single office of 40 developers shares one outbound address, so their combined legitimate traffic is 4,000 requests per minute from one IP β€” forty times the per-key limit, and entirely legitimate. Set the edge at 200 per minute per IP to β€œmatch” the published limit and you have just blocked a customer’s entire engineering team while their per-key headers cheerfully report 87 remaining.

The inverse mistake is subtler. Set the edge at 100,000 per minute and it will never engage before your origin pods have already spent CPU rejecting the flood. The shield exists to be hit occasionally β€” by attackers, not by customers.

Three ratios between an edge shield and a published origin limit An edge limit set near the published limit rejects legitimate shared-address traffic and contradicts quota headers; a limit set far above it never engages; a limit at five to twenty times the published number stops floods while leaving the published contract authoritative. Published limit: 100 per minute per key edge = 2x shared offices blocked 429 with no quota headers client back-off cannot work support tickets too tight edge = 5x to 20x offices pass freely floods stopped at the edge origin headers authoritative rejections traceable by tier the working range edge = 1000x never engages origin absorbs the flood pods spend CPU rejecting shield exists on paper too loose the ratio must account for how many keys legitimately share one address measure that number; do not guess it

Deriving the ratio

The edge limit is not a multiple of the published limit by aesthetic preference β€” it is derived from how many identities share an address, and how much burst each of them legitimately produces.

edge_limit_per_ip β‰ˆ published_limit Γ— max_keys_per_ip Γ— burst_factor

Input How to measure it Typical value
published_limit Your documented per-key limit 100/min
max_keys_per_ip 99th percentile of distinct keys per source address over a week 5–50 (higher for enterprise NAT)
burst_factor p99 divided by mean request rate per key 1.5–3
Resulting edge_limit Product of the three, rounded up 1,000–10,000/min

Run the measurement before choosing. A public API with mostly individual developers might see max_keys_per_ip = 3; one selling to enterprises regularly sees 200 keys behind a single corporate egress address, and any edge limit derived from a smaller assumption will page you the day that customer onboards.

Where the edge can authenticate β€” a gateway that resolves a consumer, or a worker that validates a signed token β€” the calculation collapses: use the same identity as the origin and set the edge limit at 2–3Γ— the published number purely to absorb retries and clock differences.

Step-by-step: layering without contradictions

nginx
# Edge tier: derived, not guessed. 100/min published x 20 keys/IP x 2 burst = 4000/min.
limit_req_zone $binary_remote_addr zone=shield:32m rate=67r/s;   # ~4000/min
limit_req_status 429;

server {
    listen 443 ssl http2;
    server_name api.example.com;
    limit_req zone=shield burst=200 nodelay;

    location /v1/ {
        proxy_pass http://origin;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
javascript
// Origin tier: the published contract, and the only tier that emits quota headers.
app.use(async (req, res, next) => {
  const key = req.apiKey;                       // set by the auth middleware
  const plan = await plans.get(key);            // free: 100/min, pro: 1000/min
  const v = await limiter.consume(`rl:${key}`, plan.perMinute, 60);

  res.set("X-RateLimit-Limit", String(plan.perMinute));
  res.set("X-RateLimit-Remaining", String(v.remaining));
  res.set("X-RateLimit-Reset", String(v.resetSeconds));
  res.set("X-RateLimit-Tier", "origin");        // strip at the public boundary if you prefer

  if (v.allowed) return next();
  res.set("Retry-After", String(Math.max(1, v.resetSeconds)));
  res.status(429).json({ error: "rate_limited", retry_after: v.resetSeconds });
});

What each tier must not do

Division of responsibility between the two tiers The edge owns volumetric protection by address and emits only a status and Retry-After; the origin owns the published per-key quota, the headers, and the metrics that drive alerting. Neither should take on the other's job. Two tiers, two jobs, no overlap Edge owns volumetric protection by address cheap rejection before TLS work status 429 + Retry-After only must NOT emit quota headers must NOT be tighter than published Origin owns the published per-key quota plan and billing awareness all X-RateLimit headers the metrics alerts are built on must NOT rely on the edge for correctness if the edge disappeared tomorrow, the published limit would still hold

That last line is the test for a healthy layering: the origin limiter must be correct on its own. An architecture where the published limit is only correct because the edge is also counting has two single points of failure and no way to reason about either.

Healthy and unhealthy ratios of edge to origin rejections In a healthy layering the origin produces most rejections and the edge produces a thin slice; when edge rejections dominate under normal traffic the shield has become tighter than the published limit. Monitor the split, not just the total healthy origin rejections: 94% edge: 6% (abuse only) inverted origin rejections: 15% edge: 85% — shield too tight alert when the edge share of rejections crosses a quarter during normal traffic

Gotchas and edge cases

  • Both tiers emitting quota headers. Clients then see two contradictory X-RateLimit-Remaining values depending on which layer answered. Only the tier that owns the published number should emit them.
  • Retry storms crossing tiers. A mass edge rejection sends thousands of clients into synchronised back-off; when they return together the origin absorbs the spike. Jitter Retry-After at the edge by a few seconds per client.
  • Different units. One tier emitting Retry-After in seconds and the other in an HTTP date is legal and awful. Standardise on delta-seconds; see parsing Retry-After for what clients must otherwise handle.
  • Health checks caught by the shield. Monitoring traffic from a single address is exactly the pattern an IP limiter punishes. Exempt monitors by path or shared secret.
  • Edge limit tuned for today’s customer mix. The ratio drifts as enterprise customers arrive. Re-derive it quarterly from fresh keys-per-address data.
  • Silent divergence after an autoscale. Per-node edge limits multiply with node count; a scale-up quietly loosens the shield. Prefer a shared edge counter, or recompute the per-node number as part of the scaling policy.

Verification and testing

bash
# A synthetic client just under the published limit must NEVER see an edge rejection.
# 95 requests per minute against a 100/min published limit, from one address.
for i in $(seq 1 95); do
  curl -s -o /dev/null -w "%{http_code} %{header_json}\n" \
    -H "X-API-Key: layer_test" https://api.example.com/v1/ping
  sleep 0.6
done | grep -c '"x-ratelimit-remaining"'
# expect 95 β€” every response carries origin headers, so the edge never intervened

# Confirm the shield still bites when it should: 200 requests in 2 seconds.
seq 1 200 | xargs -P50 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
  https://api.example.com/v1/ping | sort | uniq -c
# expect a mix of 200 and 429; the 429s should lack X-RateLimit headers (edge origin)

Automate the first check. It encodes the invariant that matters β€” a compliant client never meets the shield β€” and it will fail loudly the day someone tightens the edge limit without recomputing the ratio.

Frequently Asked Questions

What multiple should the edge limit be?

Derive it rather than pick it: published limit Γ— the 99th-percentile number of keys sharing one address Γ— a burst factor of about two. That commonly lands between five and twenty times the published per-key number for IP-keyed shields, and two to three times when the edge can authenticate the same identity as the origin.

Should the edge send X-RateLimit headers too?

No. Those headers describe the published quota, which only the origin knows. An edge rejection should carry a status and a Retry-After, plus an internal marker identifying the tier. Two tiers reporting different remaining counts is worse than one tier reporting none.

Can I drop the origin limiter if the edge is exact?

Only if the edge can resolve the same identity, read plan state, and count exactly across locations β€” which in practice means it has become an application limiter. Otherwise keep the origin limiter authoritative, so the published limit holds even if the edge is bypassed or misconfigured.

How do I tell which tier rejected a request in production?

Add a tier marker header at each layer and log it. Failing that, use the presence of quota headers as the signal: an origin rejection carries them, an edge rejection does not. Make that distinction an automated check so an inverted configuration is caught before customers report it.