fix(proxy): split rate limiting into page and static zones

The single server-scope limit_req (30r/s) treated a page load and a room
load as the same thing. Loading a Nitro room fires several hundred gamedata
icons in one burst, which that zone answered with 503s, so icons showed up
late in the client.

Add a separate static zone (1000r/s, burst 1000, nodelay) for the gamedata
and client asset locations, and apply the page-rate zone explicitly on the
main route instead of at server scope. Connection limit stays server-wide.

Measured: 900 icon requests in burst now all return 200, while 200 parallel
requests on / are still rejected.
This commit is contained in:
openhands committed 2026-10-01 17:49:41 +02:00
1 parent 4a1211a931
commit f0dcf440a7
2 files changed
+19 -8

No files matched your search

+9 -5
View File
@@ -192,11 +192,10 @@ server {
keepalive_timeout 30s;
send_timeout 10s;
# Abuse limits. Applied per server, not per location, so cached assets and
# proxied API routes are all covered by the same budget. nodelay keeps the
# 60-request burst responsive: allowed requests pass immediately, only the
# excess is rejected with 503 instead of being queued.
limit_req zone=cms_req_per_ip burst=60 nodelay;
# Abuse limits. Deliberately NOT set at server scope: a room load and a page
# load are not the same request profile, so each location picks its own zone.
# Locations without an explicit limit_req inherit nothing and are unlimited —
# the page/API routes below carry the budget instead.
limit_conn cms_conn_per_ip 30;
# Traefik health-check route herstellen
@@ -210,6 +209,7 @@ server {
location ^~ /client/ {
alias /var/www/Octane/dist/;
try_files $uri $uri/ =404;
limit_req zone=cms_static_per_ip burst=1000 nodelay;
location ~* \.(js|json|css|html|wasm|ttf|woff|woff2|gif|webp|png|jpg|jpeg|svg|dat)$ {
add_header Cache-Control "public, max-age=2592000";
@@ -223,6 +223,7 @@ server {
location ^~ /nitro-client/ {
alias /var/www/Octane/dist/;
try_files $uri $uri/ =404;
limit_req zone=cms_static_per_ip burst=1000 nodelay;
location ~* \.(js|json|css|html|wasm|ttf|woff|woff2|gif|webp|png|jpg|jpeg|svg|dat)$ {
add_header Cache-Control "public, max-age=2592000";
@@ -265,6 +266,7 @@ server {
add_header Cache-Control "public, max-age=300, must-revalidate";
access_log off;
add_header Cache-Tag "cms-gamedata";
limit_req zone=cms_static_per_ip burst=1000 nodelay;
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
@@ -280,6 +282,7 @@ server {
add_header Cache-Control "public, max-age=3600, must-revalidate";
access_log off;
add_header Cache-Tag "cms-gamedata";
limit_req zone=cms_static_per_ip burst=1000 nodelay;
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
@@ -454,6 +457,7 @@ server {
# ─── Hoofd-routering ───
location / {
proxy_pass http://cms_app;
limit_req zone=cms_req_per_ip burst=60 nodelay;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+10 -3
View File
@@ -44,10 +44,17 @@ http {
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# Rate limiting per client IP. The Nitro client fetches gamedata and icons in
# bursts when booting a room, so the burst is deliberately generous: it caps
# sustained floods without punishing a normal room load.
# Rate limiting per client IP.
#
# Two zones, because a room load and a page load are not the same thing.
# Loading a Nitro room fires several hundred gamedata icons in one burst;
# at the page rate that produced 503s on real players. Static assets
# therefore get their own, much higher allowance. These are small immutable
# files, so a request rate is not what protects them anyway — nginx already
# serves them with must-revalidate, and the CMS upstream stays behind
# cms_req_per_ip for the expensive routes.
limit_req_zone $binary_remote_addr zone=cms_req_per_ip:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=cms_static_per_ip:10m rate=1000r/s;
limit_conn_zone $binary_remote_addr zone=cms_conn_per_ip:10m;
# Blue/green cutover: ci-deploy.sh writes the active upstream here, and