Context
prom-analytics-proxy currently has no per-route or global rate limiting at the HTTP layer. A misbehaving or runaway client (typically an operator with too many concurrent reconcile workers, or a client without exponential backoff after errors) can drive request rate well past what the underlying postgres can sustain.
When that happens the proxy doesn't reject; it simply queues — postgres saturates, queries serialize through a small connection pool, individual request latency climbs past client-side deadlines, and the resulting client cancellations create the pq: canceling statement storm pattern (see related issue on statement_timeout).
The system has no proactive shedding behaviour. The only feedback to the client is "your request was too slow and you cancelled it" — which the client may interpret as transient and retry.
Proposal
Add a per-route, per-client-IP token-bucket rate limit, off by default but easy to enable. Conservative defaults so it doesn't surprise existing deployments.
Options:
- A simple in-process limiter (e.g.
golang.org/x/time/rate.Limiter keyed on remote IP) per route or per route-group. Stateless, no external dependency, sheds load with 429 responses. Good enough as a backstop.
- For multi-replica deployments, a distributed limiter (Redis-backed) — but only worth doing once option 1 is proven insufficient. Same Redis the catalog
seenCache already uses could be reused.
Sensible defaults to start:
seriesMetadata read endpoints: 50 req/s per source IP.
- Ingester
POST /api/v1/metrics: 1000 req/s per source IP (writes are batched).
- Configurable per-route via flag or config block;
0 = unlimited.
On exceeding the limit, return 429 Too Many Requests with Retry-After set to the bucket-refill window. Emit a metric (http_requests_rate_limited_total{route,reason}) for visibility.
Impact
This is defensive, not corrective — it doesn't make slow queries faster. But it converts "saturated, no signal" into "saturated, client gets a clear 429" which:
- Lets well-behaved clients back off rather than continuing to push.
- Gives operators a metric signal (
429 rate) that lights up before postgres saturation does.
- Bounds the worst case from a single client.
This complements rather than replaces the structural improvements (statement timeout, response cache, partial indexes).
Acceptance criteria
- Configurable limiter, off by default.
- 429 responses with
Retry-After.
http_requests_rate_limited_total{route,client} metric emitted.
- Documented in README with the recommended default per route.
Context
prom-analytics-proxy currently has no per-route or global rate limiting at the HTTP layer. A misbehaving or runaway client (typically an operator with too many concurrent reconcile workers, or a client without exponential backoff after errors) can drive request rate well past what the underlying postgres can sustain.
When that happens the proxy doesn't reject; it simply queues — postgres saturates, queries serialize through a small connection pool, individual request latency climbs past client-side deadlines, and the resulting client cancellations create the
pq: canceling statementstorm pattern (see related issue onstatement_timeout).The system has no proactive shedding behaviour. The only feedback to the client is "your request was too slow and you cancelled it" — which the client may interpret as transient and retry.
Proposal
Add a per-route, per-client-IP token-bucket rate limit, off by default but easy to enable. Conservative defaults so it doesn't surprise existing deployments.
Options:
golang.org/x/time/rate.Limiterkeyed on remote IP) per route or per route-group. Stateless, no external dependency, sheds load with 429 responses. Good enough as a backstop.seenCachealready uses could be reused.Sensible defaults to start:
seriesMetadataread endpoints: 50 req/s per source IP.POST /api/v1/metrics: 1000 req/s per source IP (writes are batched).0= unlimited.On exceeding the limit, return
429 Too Many RequestswithRetry-Afterset to the bucket-refill window. Emit a metric (http_requests_rate_limited_total{route,reason}) for visibility.Impact
This is defensive, not corrective — it doesn't make slow queries faster. But it converts "saturated, no signal" into "saturated, client gets a clear 429" which:
429 rate) that lights up before postgres saturation does.This complements rather than replaces the structural improvements (statement timeout, response cache, partial indexes).
Acceptance criteria
Retry-After.http_requests_rate_limited_total{route,client}metric emitted.