Skip to content

Rate Limiting

Per-IP, per-route rate limiting using a Count-Min Sketch sliding-window estimator. The entire state fits in ~32 KB of memory, is fully thread-safe with no locks, and is enabled with a single directive in your Dwaarfile.


api.example.com {
reverse_proxy localhost:8080
rate_limit 100/s
}

Every IP address is limited to 100 requests per second on api.example.com. Requests over the limit receive a 429 Too Many Requests response immediately — no queuing, no delay.


The estimator uses a 1-second measurement window and the PROPORTIONAL_RATE_ESTIMATE_CALC_FN from pingora-limits. On every request, the plugin:

  1. Records a count of 1 for the composite key.
  2. Computes a weighted estimate that interpolates between the completed previous interval and the still-accumulating current interval, proportional to how far through the current second you are.

This avoids the step-function artifact of pure fixed windows: a burst of 200 requests at 00:00.999 and 200 more at 00:01.001 would not be rate-limited by a naive 1-second counter reset, but the sliding estimate sees ~400 rps and correctly rejects the excess.

The key {client_ip}:{route_domain} gives each (IP, site) pair its own counter. A single IP accessing two different virtual hosts on the same Dwaar instance is tracked independently — reaching the limit on api.example.com does not affect the same IP’s budget on www.example.com.

Keys up to 24 bytes are stored inline in a CompactString with no heap allocation. IPv4 + short domain names (e.g., 203.0.113.1:api.example.com = 25 bytes) fall just over the inline threshold and allocate once per request; this is a single small allocation and does not affect throughput in any measurable way.


rate_limit <requests_per_second>
TokenTypeRequiredDescription
requests_per_secondpositive integeryesMaximum requests per second per IP for this route. The /s suffix is required.

Place rate_limit inside any site block. It applies to all paths on that site. To apply different limits to different paths, use handle blocks:

api.example.com {
handle /public/* {
reverse_proxy localhost:8080
rate_limit 1000/s
}
handle /admin/* {
reverse_proxy localhost:8080
rate_limit 10/s
}
}

Start with a limit that is comfortably above your 99th-percentile legitimate traffic rate for a single IP. Common starting points:

Use caseSuggested limit
Public API (authenticated)100/s500/s
Public API (unauthenticated)20/s100/s
Login / auth endpoints5/s20/s
Webhook receivers50/s200/s
Static asset CDN fallback500/s2000/s

These are starting points. Tune against your actual traffic profile and check your analytics logs for false-positive 429s before tightening further.


Dwaar includes an Under Attack Mode plugin (UnderAttackPlugin, priority 15) for L7 DDoS mitigation. When enabled for a route, it intercepts every request from an unverified client and serves a JavaScript proof-of-work challenge page instead of forwarding to the upstream.

How the challenge works:

  1. A client without a valid clearance cookie receives a 200 response containing an HTML page with embedded JavaScript.
  2. The JavaScript computes SHA-256 hashes of challenge || nonce in a loop, incrementing the nonce until it finds a hash with 20 leading zero bits (~1 million iterations, ~200 ms on a modern browser).
  3. Once solved, the browser redirects itself to the original URL with _dwaar_solved=1, the challenge value, and the winning nonce as query parameters.
  4. Dwaar verifies the proof-of-work server-side and issues a signed _dwaar_clearance cookie (HMAC-SHA256 over timestamp and client IP, valid for 1 hour).
  5. Subsequent requests that present a valid, unexpired clearance cookie pass through to the upstream without re-solving.

Non-browser clients (curl, scrapers, bots) cannot execute JavaScript and are permanently stuck on the challenge page. They never reach your upstream.

Cookie security properties:

  • The HMAC binds the cookie to the client’s IP address. A cookie stolen from one IP is rejected on any other IP.
  • The timestamp prevents replay attacks after the TTL expires.
  • Cookie verification uses constant-time comparison to eliminate timing side-channels.
  • Cookies are issued with HttpOnly; SameSite=Lax and Secure when the connection is TLS.

Note: Under Attack Mode is currently an internal/programmatic feature. There is no Dwaarfile directive to enable it — it must be activated via the Dwaar admin API or control plane integration. A under_attack Dwaarfile directive is planned for a future release.


When a request is rate-limited, the client receives:

HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Length: 0

The body is empty. The Retry-After: 1 header tells standards-compliant clients to wait at least one second before retrying — which corresponds to the estimator’s 1-second window.

What the client should do:

  • HTTP clients that respect Retry-After will wait 1 second automatically.
  • Clients that hammer through 429s will continue to be rejected: the rate estimate includes the rejected requests.
  • A 429 is not a ban. A client that drops its rate below the limit will immediately start receiving 200s again on the next estimation window.

RateLimitPlugin runs at priority 20.

PriorityPluginWhat it does
10BotDetectionPluginSets ctx.is_bot flag
15UnderAttackPluginJS proof-of-work challenge
20RateLimitPluginPer-IP sliding-window limit
30+Other pluginsAuth, headers, etc.

Running after bot detection (priority 10) means the rate limiter can see ctx.is_bot if you build custom logic on top of the plugin chain. It runs before forward auth and other higher-priority plugins so that rate-limited requests are rejected before any upstream credential checks are made.


The estimator allocates a fixed 4 hashes × 1024 slots = 4096 counters table at startup. Each counter is an atomic value (8 bytes), giving:

4 × 1024 × 8 bytes = 32,768 bytes ≈ 32 KB

This is the total memory cost regardless of how many unique IPs are tracked. There is no per-IP allocation.

Collision behaviour: Count-Min Sketch is a probabilistic structure. With 1024 slots per hash row, collision rates remain low up to roughly 1000 distinct active keys. Above that threshold, estimates become slightly inflated — some IPs may be rate-limited slightly earlier than their true rate would warrant. No IP is ever permitted above the limit due to collisions; the sketch only over-counts, never under-counts.

For most deployments, 1000 simultaneously active IP:domain pairs per Dwaar worker is a generous ceiling. If your traffic profile regularly exceeds this (e.g., a very large public API), consider deploying multiple worker processes or increasing the sketch dimensions by modifying RateLimiter::new() to pass a custom Rate config.


# Global TLS — Dwaar handles certificate provisioning
{
email admin@example.com
}
# Public website — relaxed limit
www.example.com {
reverse_proxy localhost:3000
rate_limit 500/s
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options nosniff
}
}
# API — strict limit with per-path overrides
api.example.com {
handle /v1/* {
reverse_proxy localhost:8080
rate_limit 100/s
}
handle /admin/* {
ip_filter {
allow 10.0.0.0/8
allow 192.168.0.0/16
default deny
}
reverse_proxy localhost:8080
rate_limit 10/s
}
}
# Login endpoint — aggressive rate limiting to slow credential stuffing
auth.example.com {
handle /login {
rate_limit 5/s
reverse_proxy localhost:9000
}
handle {
reverse_proxy localhost:9000
}
}

  • Bot Detection — classify traffic as bot or human before rate-limiting (priority 10)
  • IP Filtering — block or allow specific IP ranges outright
  • Security Headers — add Strict-Transport-Security, CSP, and other response headers