Kafka client quotas
Throttle Kafka producers and consumers per principal and per tenant, never cutting them, and read the listener's signals.
GuideOperationsSince version 1.6.0
On this page
Operations guide: configure, deploy and observe the gateway. What the feature does, product side: product guide; calling it: developer guide.
The Kafka listener's quotas are declared under listeners.kafka.quotas; its signals then say who is throttled, refused or closed, and why.
Steps
Quotas: KIP-219 throttling
Bytes produced, bytes fetched and requests per second, for each verified principal and for each
tenant (listeners.kafka.quotas). Never a cut: past a rate, the response
leaves at once with throttle_time_ms, then the gateway stops reading the client's connection for
that long (KIP-219). A client that ignores
the field - librdkafka never applied it (librdkafka#5592) -
is slowed all the same: what it sends waits in the socket. Without quotas, nothing is throttled.
| Key | Why | Default |
|---|---|---|
client.producer_byte_rate |
Bytes produced per second (Produce requests, headers included) by one principal: the token's subject, the API key or the certificate - never the client.id, which a client picks freely |
no bound |
client.consumer_byte_rate |
Bytes fetched per second (Fetch responses) by one principal |
no bound |
client.request_rate |
Requests per second of one principal, every API, once authenticated | no bound |
tenant.producer_byte_rate, tenant.consumer_byte_rate, tenant.request_rate |
The same for every principal of a tenant together: a tenant buys no more throughput by adding keys | no bound |
window |
The burst admitted at once: a bucket holds rate x window (Kafka's quota windows are about a second) |
1s (from 100ms to 10s) |
max_throttle |
The cap of throttle_time_ms - and of the silence after it: below the clients' request.timeout.ms (30 s by default in Kafka's clients), which a throttle must never exhaust |
10s (from 100ms to 30s; provisional default, to be set by performance measurements) |
quota |
The name of a quota policy (Setting quotas): the tenant's daily or monthly budget of Produce requests, counted under that name as for HTTP; spent, a Produce is refused POLICY_VIOLATION (44, not retriable) and nothing is written |
none |
Every key is reloadable: the rate in force applies from the next request, and what a principal spent is
kept. With server.counters, the buckets are shared across replicas like the HTTP rate limits: each
replica decides alone and reconciles every sync (a rate held to within a sync window; the gap is not
measured yet). Without it, each replica counts for itself.
| Case | What happens |
|---|---|
| A client that honours KIP-219 (Java, franz-go) | An immediate response with throttle_time_ms; it waits that long before its next request |
| A client that ignores the field (librdkafka: confluent-kafka-go, Python, .NET) | The same response; it sends again at once, but its next request is served only once the delay ends: the rate holds without its cooperation |
A Fetch of a principal or tenant already in debt |
An empty response at once with throttle_time_ms; nothing is asked of the broker |
| The broker also throttles the gateway (its own quotas on the gateway's principal) | throttle_time_ms = the larger of the two delays; the broker's silence is respected: nothing is sent to it on that connection before it ends |
| The tenant's daily or monthly quota is spent | A POLICY_VIOLATION refusal on Produce (cause quota), not throttling; reads (Fetch) are not yet counted in the budget: their refusal code must first be checked against the main client libraries (a Java consumer throws on an unexpected code) |
Per tenant
A tenant does not take all the brokers' bandwidth, whatever its number of keys:
listeners:
kafka:
quotas:
tenant: { producer_byte_rate: 10MiB, consumer_byte_rate: 40MiB, request_rate: 2000 }Per client
Each principal (token, key, certificate) has its bucket, under its tenant's:
listeners:
kafka:
quotas:
client: { producer_byte_rate: 1MiB, consumer_byte_rate: 4MiB, request_rate: 200 }
tenant: { producer_byte_rate: 10MiB, consumer_byte_rate: 40MiB }
window: 2s # bursts of two seconds of rate
max_throttle: 5s # clients with a short request.timeout.ms
server:
counters: { store: "${COUNTERS_URL}" } # the rate held across every replica togetherWith the broker's quotas
Nothing to declare: the gateway relays the larger of the two delays. The
broker sees the gateway's principal (upstreams.<name>.sasl.username) and the tenant's client id
(acme.app-1, Names of groups and transactions); a broker quota on that principal slows
every tenant together, the gateway's quotas each its own:
kafka-configs --bootstrap-server kafka-0.kafka:9093 --command-config admin.properties \
--alter --entity-type users --entity-name flexway --add-config producer_byte_rate=104857600Daily or monthly budget
The same plan as for HTTP, counted on Produce:
listeners:
kafka:
quotas: { quota: plans }
server:
counters: { store: "${COUNTERS_URL}" } # required by every quota policy
policies:
definitions:
plans: { type: quota, config: { limits: { day: 100000, month: 2000000 } } }Verify
A librdkafka client
Nothing to change on the client; what it sees past the quota:
# confluent-kafka-go, Python, .NET: ordinary properties
bootstrap.servers=bootstrap.shop.kafka.example.com:9093
security.protocol=SASL_SSL
sasl.mechanisms=PLAIN
sasl.username=acme-orders-producer
sasl.password=${FLEXWAY_KAFKA_KEY}
# past the quota: the producer moves at the declared rate; its "throttle" statistic (statistics.interval.ms)
# shows throttle_time_ms; request.timeout.ms (30 s) stays above max_throttleReading the signals
Prometheus names:
# share of throttled requests, per virtual cluster
sum by (cluster) (rate(flexway_kafka_requests_total{decision="throttled"}[5m]))
/ sum by (cluster) (rate(flexway_kafka_requests_total[5m]))
# refusals by cause (the daily or monthly quota aside)
sum by (cause) (rate(flexway_kafka_requests_total{decision="refused", cause!="quota"}[5m]))
# connections refused by bound; normal closures apart
sum by (bound) (rate(flexway_kafka_connections_refused_total[5m]))
sum by (cause) (rate(flexway_kafka_connections_closed_total[5m]))
# p95 wait for a tenant's share
histogram_quantile(0.95, sum by (le, tenant) (rate(flexway_kafka_tenant_budget_wait_seconds_bucket[5m])))Signals: metrics, traces, audit
The flexway.kafka.* metrics leave over OTLP with the others (one path, service.instance.id per
replica); every dimension is closed: the contract's clusters, the API table's names, the sets below,
and tenant under the rule of every tenant dimension - 200 tenants keep their series, the next share
_other. Never a subject, a key, a certificate, a client.id, a physical Kafka topic or an address.
| Metric | Dimensions | What it says |
|---|---|---|
flexway.kafka.connections (gauge) |
cluster, tenant, mechanism (oauthbearer, api_key, mtls, none before authentication; _none and _anonymous before the SNI) |
Open connections: on a replica, the occupation of max_connections - which refuses nothing, one more connection waits in the kernel's queue |
flexway.kafka.requests |
cluster, tenant, api, decision (allowed, throttled, refused), cause (acl, admin_api, quota, unknown_topic, authentication, kms_unavailable, unsupported, key_destroyed; none) |
Requests judged; refused as soon as the gateway refused any part |
flexway.kafka.bytes |
cluster, tenant, direction (produce, fetch) |
The bytes the quotas count |
flexway.kafka.throttle (histogram, s) |
cluster, tenant, quota (producer_byte_rate, consumer_byte_rate, request_rate, broker), scope (client, tenant, broker) |
throttle_time_ms of each throttled response |
flexway.kafka.connections.refused |
bound (max_unauthenticated_connections, max_connections_per_ip, max_connections_per_tenant, max_connection_rate_per_tenant), tenant for the two tenant bounds only |
Connections a bound refused; never a tenant before authentication, never an address; a refusal by max_connections_per_tenant is not counted as authentication too |
flexway.kafka.connections.closed |
cause (auth_timeout without tenant, idle_timeout, max_session_lifetime), tenant |
Normal closures, never mixed with refusals; max_session_lifetime: a session that reached its end (the bound, exp or NotAfter) without re-authenticating |
flexway.kafka.tenant_budget |
cluster, tenant, outcome (waited, closed) |
Responses that waited for the tenant's share (max_tenant_in_flight_bytes, wait mode); connections closed at it (close mode) |
flexway.kafka.tenant_budget.wait (histogram, s) |
cluster, tenant |
How long a response waited for that share |
flexway.kafka.encryption |
cluster, tenant, op (encrypt, decrypt), outcome (ok, kms_unavailable, key_destroyed) |
Field encryption |
flexway.kafka.upstream.connections |
cluster, broker (node id, or bootstrap), outcome (ok, failed) |
The gateway's connections to each broker: health per broker |
| Key | Why | Default |
|---|---|---|
observability.kafka.budget_wait_buckets |
Boundaries of flexway.kafka.tenant_budget.wait, increasing, 20 at most: to tighten when a fleet's normal waits stay under a millisecond. Not reloadable (a histogram's boundaries are set when it is created) |
1ms, 5ms, 10ms, 50ms, 100ms, 500ms, 1s, 5s, 10s, 30s (the write timeout) |
observability.kafka.closures |
Count the normal closures: false on a fleet of short-lived clients where they say nothing. Reloadable |
true |
observability:
kafka:
budget_wait_buckets: [100us, 1ms, 10ms, 100ms, 1s, 10s, 30s]
closures: falseTraces
One span per sampled authenticated request (kafka <API>, attributes kafka.cluster,
tenant, auth.mechanism, kafka.decision, kafka.cause, kafka.throttle_time_ms) - never a subject nor
a Kafka topic name.
Audit
Every refused request and every administration
request (allowed or refused), in the same chain as HTTP - host = the virtual cluster, method =
KAFKA, operation = the API, code = the cause, the subject hashed; allowed data requests are counted,
not logged one by one.