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 <N>/<duration>

The rate is expressed as N events per duration. The duration accepts seconds (s), minutes (m), or hours (h). Examples:

ExpressionMeaning
100/s100 requests per second per IP
5000/10m5 000 requests per 10 minutes per IP
10000/h10 000 requests per hour per IP

If the expression cannot be parsed, Dwaar refuses the config at load time with an expected: hint pointing at the canonical format — as of 0.2.2, ParseErrorKind::InvalidValue carries an accepted_format: &'static str field so the parser error line directly tells you what to write:

error: invalid value for directive 'rate_limit' at line 6 col 5
got: "100 per second"
expected: N/duration, e.g., 100/s or 5000/10m

The same expected: hint applies to request_body (body-size) and timeouts directives — 100mb, 5s, 1h30m, etc. — so parser errors on any size- or duration-valued directive point at the canonical format instead of just saying “invalid”.

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.

As of 0.2.3, RateLimitPlugin runs at priority 15before UnderAttackPlugin at priority 20. The order was inverted in previous releases, which meant that a request over the per-IP limit would still be handed an under-attack challenge page, burn a proof-of-work on the client, and then be rejected. That produced noisy user-facing failures under flood and gave attackers free compute on legitimate clients.

PriorityPluginWhat it does
10BotDetectionPluginSets ctx.is_bot flag
15RateLimitPluginPer-IP sliding-window limit — runs first
20UnderAttackPluginJS proof-of-work challenge (only for requests the rate limiter allowed through)
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.

Rate-limit keys now canonicalize the client IP through Ipv6Addr::to_canonical() before they are inserted into the Count-Min Sketch. In practice this means ::ffff:127.0.0.1 (IPv4-mapped IPv6) and 127.0.0.1 produce the same composite key for the same route.

Before 0.2.3, a dual-stack listener could accept the same client under two distinct keys, so an attacker straddling the mapping could effectively double their per-IP budget by toggling address families between requests. The canonicalization step eliminates that drift.

The change is transparent to the Dwaarfile — no directive, no opt-in.


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