Skip to content

Rate limiting

Posthorn’s rate limiter is a token-bucket keyed by client IP, with Least Recently Used (LRU) eviction at a configurable cap.

[[endpoints]]
path = "/api/contact"
[endpoints.rate_limit]
count = 5
interval = "1m"
FieldTypeDescription
countintBucket capacity. Maximum burst of submissions allowed instantly.
intervaldurationRefill window. After interval, the bucket is fully refilled.

This means 5 submissions per minute, with a burst of 5. A client can submit 5 forms back-to-back, then has to wait for the bucket to refill at a rate of 5/60 = 0.083 tokens per second.

A token bucket has two parameters: capacity (count) and refill rate (count / interval). When a request arrives:

  1. Compute how much has refilled since the last check (capped at capacity).
  2. If the bucket has ≥ 1 token, consume one and allow the request.
  3. Otherwise, return 429.

For count = 5, interval = 1m:

  • Refill rate: 5 / 60s ≈ 0.083 tokens/sec
  • Bucket starts full at 5 tokens
  • 5 requests in 100ms → bucket drained to 0 → 6th request 429s
  • After 12 seconds idle → 1 token refilled → next request allowed
  • After 60 seconds idle → bucket back to 5

Why token bucket (not leaky bucket, fixed window)?

Section titled “Why token bucket (not leaky bucket, fixed window)?”

Token bucket allows bursts up to the capacity, which is what humans actually do — a person submitting a contact form might double-tap-click the submit button, retry after seeing a redirect, etc. Allowing a small burst before throttling produces fewer false positives.

Leaky bucket (or fixed-window counter) would 429 the second click. Token bucket lets it through, as long as the user hasn’t already submitted multiple recent times.

By default the bucket key is the request’s RemoteAddr. Behind a reverse proxy this is the proxy’s IP, not the real client’s — every request looks like it’s coming from the same IP, defeating the limiter.

Configure trusted_proxies to make Posthorn extract the real client IP from X-Forwarded-For:

trusted_proxies = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]

When RemoteAddr is in one of the trusted CIDRs, Posthorn reads X-Forwarded-For and uses the rightmost untrusted IP as the bucket key. Otherwise, it uses RemoteAddr directly.

The limiter tracks at most 10,000 IPs by default. When the cap is reached, the least-recently-used bucket is evicted to make room.

This bounds memory regardless of attack volume. A flood of submissions from a million different IPs won’t grow Posthorn’s memory unboundedly — older buckets get evicted as new ones come in. The eviction is per-endpoint, so a flood on /api/contact doesn’t affect /api/newsletter.

The 10K default is sized for typical homelab and indie-developer use. The cap is not currently configurable; a knob for larger deployments is a planned addition.

Posthorn returns HTTP 429 with a plaintext body:

HTTP/1.1 429 Too Many Requests
Content-Type: text/plain; charset=utf-8
rate limit exceeded

Or, if the request prefers text/html and redirect_error is configured, a 303 redirect to that URL.

Posthorn does not emit a Retry-After header. Clients should back off on their own (a small fixed delay or progressive backoff is fine for a form-submit retry). Computed Retry-After from the bucket’s refill schedule is a planned polish item — open a feature request if you’d find it useful.

Use caseSuggested countSuggested interval
Public contact form3-51m
Newsletter signup (one per visitor)21m
Internal API with known clients501m
Internal API with high-volume client100-5001m
API guarded against quota burn(whatever your provider allows)1m

The tightest constraint is your transport’s rate limit upstream. Postmark’s default per-server rate is generous (10/sec for transactional), but if you misconfigure Posthorn to allow 1000/min, you might blow through a daily quota in minutes. Set Posthorn’s limit conservatively below the upstream.

Every rate-limited request logs an event:

{
"time": "2026-05-17T20:01:23Z",
"level": "INFO",
"msg": "rate_limited",
"submission_id": "...",
"endpoint": "/api/contact",
"transport": "postmark",
"client_ip": "203.0.113.42",
"latency_ms": 1
}

(In API mode, client_ip is omitted — the matched API key is the bucket key, and Posthorn never logs key material. When strip_client_ip = true is set on a form-mode endpoint, client_ip is omitted there too.)

Use this to spot abuse patterns — a single IP that’s been 429’d a hundred times in an hour is a bot you might want to block at the firewall level.

  • Per-endpoint absolute limit. Only per-IP. A coordinated botnet from many low-rate IPs can still drive aggregate volume up. Mitigation lands later (proof-of-work / captcha).
  • Hot reload of rate-limit config. Restart Posthorn to change rate limits.
  • Distributed rate limiting across multiple Posthorn instances. Limits are per-instance. For multi-instance deployments behind a load balancer, the limit you configure applies to each instance independently — a 5/min config with 3 instances means up to 15/min in practice.