Notes / System Design / 15 Complete Case Studies / 01 Telemetry Ingestion Pipeline

Rate Limiting Architecture: Token Bucket, Gossip, and Envoy Global Limits

Three ways to enforce rate limits across a replicated gateway fleet — centralized Redis token bucket, decentralized gossip-based estimation, and Envoy's sidecar-plus-global-service pattern — with the precision/SPOF/latency trade-offs between them.

Appears in: Telemetry Ingestion Pipeline §3.1 (ingestion frontier — rate limiting architecture).

The reason this needs a real architecture, and isn’t just “add a counter”: the ingestion gateway is a fleet of stateless, horizontally-scaled pods. Per-request rate limiting inside a single gateway pod does not work — each pod would enforce its own limit independently, so a tenant’s effective limit becomes configured_limit × pod_count, which drifts every time the fleet autoscales. Rate limiting at this scale is fundamentally a distributed-state problem before it’s a rate-limiting problem.

Wrong:  each pod counts locally → limit = configured_limit × replica_count (drifts with autoscaling)
Right:  pods share state (strongly or eventually consistent) → limit holds regardless of replica count

There are three architectures worth knowing, each trading off precision, latency, and single-point-of-failure risk differently.


Token Bucket in Redis

The algorithm: each tenant (or agent, or route) gets a bucket with a fixed capacity that refills at a steady rate. Every request consumes one token; if the bucket is empty, the request is rejected. This is preferred over a naive fixed-window counter because it smooths bursts — a bucket that’s been idle can absorb a short burst up to its capacity, rather than allowing a full quota in the first millisecond of every window and nothing for the rest of it.

Bucket: capacity=100, refill_rate=10/sec


Request arrives → bucket has tokens? → consume 1, allow
                                     → empty → reject (429)


Every 100ms: refill by (refill_rate × elapsed_time)

Why Redis specifically: Redis gives you a single shared, low-latency store that every gateway pod can check before accepting a request — this is what makes the limit hold regardless of how many pods are running. The check-and-decrement has to be atomic (a naive GET-then-SET from two pods racing on the same key both see “1 token left” and both allow the request), so this is typically implemented as a Lua script executed atomically inside Redis:

-- Simplified token bucket check, run atomically via EVAL
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local bucket = redis.call("HMGET", key, "tokens", "last_refill")
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

local elapsed = now - last_refill
tokens = math.min(capacity, tokens + elapsed * refill_rate)

if tokens < 1 then
  return 0  -- reject
else
  tokens = tokens - 1
  redis.call("HMSET", key, "tokens", tokens, "last_refill", now)
  redis.call("EXPIRE", key, 3600)
  return 1  -- allow
end

Trade-offs:

PropertyDetail
ConsistencyStrong — every pod sees the same bucket state immediately
Latency added~1ms intra-datacenter round trip per request (or per batch, if you locally cache and check periodically)
Scaling ceilingRedis is largely single-threaded for command execution; a single instance tops out around 100K ops/sec. Works comfortably at 100K agents; becomes the bottleneck approaching 10M
Mitigations at scaleShard tenants across multiple Redis instances (hash tenant_id → shard); use Redis Cluster; or accept a local short-TTL cache (e.g. 100ms) of the last decision to cut round trips at the cost of slight over-admission
Failure modeRedis down → explicit choice: fail open (allow everything, risk overload) or fail closed (reject everything, guarantees safety but a Redis blip now looks like a full outage to every tenant)

The fail-open/fail-closed choice is worth stating explicitly in an interview — it’s a real design decision with no universally correct answer, and depends on whether an accidental overload or an accidental full rejection is the worse outcome for your system.


Gossip-Based Distributed Rate Limiting

The idea: drop the central store entirely. Each gateway pod tracks its own local request count per tenant, and periodically exchanges that count with a subset of its peers (a gossip protocol — each round, tell a few random peers what you know; information propagates through the fleet in O(log N) rounds without any single coordinator). Each pod maintains a running estimate of the global rate by combining its own local count with the most recent counts it’s heard from peers.

flowchart LR
    P1["Pod 1\nlocal count: 40"] <-->|"gossip every ~1-3s"| P2["Pod 2\nlocal count: 35"]
    P2 <--> P3["Pod 3\nlocal count: 50"]
    P3 <--> P1
    P1 -.->|"estimate: ~125 total"| D1["Local decision"]
    P2 -.->|"estimate: ~125 total"| D2["Local decision"]
    P3 -.->|"estimate: ~125 total"| D3["Local decision"]

Trade-offs:

PropertyDetail
ConsistencyEventual — there’s a propagation delay (the gossip interval, typically 1–3s) before all pods agree on the global count
PrecisionImprecise by design — a burst can temporarily exceed the intended limit by roughly the amount that arrives within one gossip interval, spread across pods that haven’t yet heard about each other’s load
SPOFNone — no central store to lose. This is the entire point: it scales horizontally with the gateway fleet itself, with no separate piece of infrastructure to capacity-plan
Right scale for itWhere Redis becomes the bottleneck (approaching the 10M-agent end of the scale envelope) and slight over-admission during bursts is an acceptable trade for removing a SPOF

A clarification worth making explicitly: the main design’s shorthand groups “Netflix Concurrency Limits” in with gossip-based rate limiting, but they solve a related, not identical, problem. Netflix’s concurrency-limits library is an adaptive concurrency limiter — it runs locally per instance, with no gossip or peer exchange at all, and dynamically adjusts how many concurrent in-flight requests that one instance will accept based on observed latency (using TCP congestion-control-inspired algorithms like Vegas/gradient). It answers “how much concurrent load can I handle right now,” not “what’s our shared global rate limit.” The two techniques compose well — gossip-based limiting can set the shared tenant-level budget, while each pod additionally runs an adaptive concurrency limiter to protect itself from its own local resource exhaustion — but they are not the same mechanism, and conflating them is worth catching yourself on in an interview.


Envoy Sidecar with Global Rate Limit Service

The idea: rather than every service implementing rate-limit logic (Redis calls, gossip, whatever), push the decision into the network layer. Envoy’s rate limit filter intercepts each request and makes a gRPC call to a separate rate limit service before deciding whether to forward it.

flowchart LR
    C["Client request"] --> E["Envoy\n(rate limit filter, xDS-configured)"]
    E -->|"gRPC: ShouldRateLimit(descriptors)"| RLS["Rate Limit Service\n(e.g. envoyproxy/ratelimit)"]
    RLS -->|"backed by"| REDIS[("Redis")]
    RLS -->|"OK / OVER_LIMIT"| E
    E -->|"forward"| UP["Upstream service"]
    E -->|"429 / RESOURCE_EXHAUSTED"| C

The request is classified into descriptors — key/value pairs like {tenant_id: "acme"} or {route: "/v1/traces", tenant_id: "acme"} — and the rate limit service (commonly the open-source envoyproxy/ratelimit, which is itself usually Redis-backed) evaluates the configured limit for that descriptor combination.

Trade-offs:

PropertyDetail
Where the logic livesEntirely out of application code — configured declaratively via xDS, enforced uniformly across every service the mesh fronts
Latency added~2ms for the extra gRPC hop per request (or per connection, if you rate-limit at connection-establishment instead of per-request)
ConsistencyAs strong as the rate limit service’s own backing store — typically Redis, so this is really the token-bucket-in-Redis pattern, just relocated behind a shared service and a proxy filter instead of called directly from application code
Operational winCentralized configuration and central enforcement means every service gets consistent rate-limit behavior automatically — no risk of one team’s service “forgetting” to implement it

This is worth recognizing as not actually a fourth distinct algorithm — under the hood it’s usually still a centralized store (Redis), the same as the first pattern. What’s different is where the enforcement point lives: instead of each application calling Redis directly, Envoy does it uniformly for every service behind it, via a shared, purpose-built service rather than per-application client code. See What is Envoy for how the sidecar pattern this relies on works more generally.


Choosing an approach

The main design’s answer, stated directly: coarse-grained enforcement at the load balancer (simple token bucket), fine-grained enforcement at the gateway with eventually-consistent distributed counters. In practice this usually means:

ScaleRecommended approach
Up to ~100K agentsToken bucket in Redis — simplest to operate, strong consistency, latency is negligible
Approaching 10M agentsGossip-based (or sharded Redis) — Redis alone becomes the bottleneck; accept eventual consistency for horizontal scalability
Service-mesh-wide, many servicesEnvoy + global rate limit service — the win is uniform enforcement across every service without every team reimplementing the logic, independent of which underlying store backs it

These aren’t mutually exclusive — a real deployment commonly layers coarse LB-level limiting (cheap, catches the worst abuse early) with fine-grained gateway-level limiting (precise, per-tenant) on top.


Local graph

Full graph →

Linked from 9 notes

3.1 Layer 1: Ingestion Frontier

Layer 1 of the telemetry ingestion pipeline: the ingestion frontier — responsibilities, fan-in at 100K+ agents, protocol negotiation, batching, backpressure, and rate limiting.

gRPC

What gRPC actually is underneath the shorthand this design uses it for — call shapes, status-code backpressure, deadline propagation, and the connection-level load-balancing gotcha at 100K+ agent fan-in.

What is Envoy

CNCF-graduated L7 proxy built at Lyft — the de facto data plane for service mesh (Istio, Linkerd's predecessor lineage) — now extending into AI traffic via Envoy AI Gateway, which reached v1.0 with a native MCP Gateway in 2026.

7 — Multi-Tenancy

Two separate guarantees hiding under one name — data isolation and performance fairness — and the tenant identification, quota enforcement, and selective backpressure that make both hold under shared infrastructure.

Protocol Termination at the Ingestion Frontier

What actually happens where the wire protocol ends — TCP/TLS handoff, HTTP/2 frame demux, gRPC message decode, protobuf deserialization — and why L4 vs L7 termination and connection-lifecycle tuning are the load-bearing decisions here, not the crypto itself.

Tenant Identification and Routing at the Ingestion Frontier

How the gateway decides whose data this is — cert/API-key-derived tenant identity, propagation as a Kafka header and X-Scope-OrgID, and the shared-topic-with-filter vs per-tenant-topic vs shuffle-sharded-pool routing trade-off.

Protocol Inventory

Every protocol referenced across the telemetry ingestion pipeline design, plus a general L7-termination reference table for the broader 'design an API gateway / load balancer' interview question.

9 — OTel Collector Pipeline Design

Receivers, processors, and exporters chained into a pipeline; why a platform runs more than one; and the agent/gateway topology that tail sampling specifically forces on that design.

System Design

Principal/Staff-level system design reference collection for MAANG interview preparation — observability pipelines, distributed systems, reliability engineering, and beyond.