Skip to main content

krabka_client_admin/
quotas.rs

1//! Client-quota admin RPCs (KIP-546): `DescribeClientQuotas` (`api_key` 48)
2//! and `AlterClientQuotas` (`api_key` 49), as Kafka's
3//! `Admin.describeClientQuotas` and `alterClientQuotas`.
4//!
5//! An entity is a map from entity type to entity name. Kafka's brokers accept
6//! the types [`ENTITY_USER`], [`ENTITY_CLIENT_ID`] and [`ENTITY_IP`], alone or
7//! as the pair `(user, client-id)`. A `None` name is the default entity of its
8//! type. As in Kafka's client, the broker validates the combination.
9//!
10//! The `KafkaUser` reconciler uses the per-user shortcuts
11//! [`AdminClientLike::describe_user_quotas`](crate::AdminClientLike::describe_user_quotas) and
12//! [`AdminClientLike::alter_user_quotas`](crate::AdminClientLike::alter_user_quotas).
13
14use std::{collections::BTreeMap, slice};
15
16use krabka_protocol::owned::{
17    alter_client_quotas_request::{
18        AlterClientQuotasRequest, EntityData as AlterEntity, EntryData as AlterEntry,
19        OpData as AlterOp,
20    },
21    alter_client_quotas_response::AlterClientQuotasResponse,
22    describe_client_quotas_request::{ComponentData, DescribeClientQuotasRequest},
23    describe_client_quotas_response::DescribeClientQuotasResponse,
24};
25
26use crate::{
27    AdminClient, AdminError, KafkaError, kafka_error_if, kafka_error_name, retry::ControllerRetry,
28};
29
30/// The entity type of a user principal, Kafka's `ClientQuotaEntity.USER`.
31pub const ENTITY_USER: &str = "user";
32/// The entity type of a client ID, Kafka's `ClientQuotaEntity.CLIENT_ID`.
33pub const ENTITY_CLIENT_ID: &str = "client-id";
34/// The entity type of a client IP address, Kafka's `ClientQuotaEntity.IP`.
35pub const ENTITY_IP: &str = "ip";
36
37/// Wire `match_type` constants of `DescribeClientQuotasRequest.json`.
38const MATCH_TYPE_EXACT: i8 = 0;
39const MATCH_TYPE_DEFAULT: i8 = 1;
40const MATCH_TYPE_SPECIFIED: i8 = 2;
41
42/// `UNKNOWN_SERVER_ERROR`.
43const UNKNOWN_SERVER_ERROR: i16 = -1;
44
45/// One mutation of a quota entity, as Kafka's `ClientQuotaAlteration.Op`.
46#[derive(Debug, Clone, PartialEq)]
47pub enum QuotaOp {
48    /// Upsert `key` → `value`. `value` must be finite and non-negative;
49    /// for `request_percentage` the broker also requires `value <= 100`.
50    Set { key: String, value: f64 },
51    /// Tombstone `key` for this entity. Kafka sends it for an `Op` with a
52    /// null value, as `remove=true`.
53    Remove { key: String },
54}
55
56/// Snapshot of the broker's quota state for a single user. An empty map means
57/// no per-user quotas are configured.
58pub type UserQuotaConfig = BTreeMap<String, f64>;
59
60/// A quota entity, as Kafka's `ClientQuotaEntity`: entity type → entity
61/// name, where `None` is the default entity of that type.
62pub type ClientQuotaEntity = BTreeMap<String, Option<String>>;
63
64/// The quota values of each entity, as Kafka's `DescribeClientQuotasResult`.
65pub type ClientQuotas = BTreeMap<ClientQuotaEntity, BTreeMap<String, f64>>;
66
67/// What one filter component matches, as the three constructors of Kafka's
68/// `ClientQuotaFilterComponent`.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum ClientQuotaMatch {
71    /// The entity with this name (`ofEntity`).
72    Exact(String),
73    /// The default entity of the type (`ofDefaultEntity`).
74    Default,
75    /// Any entity of the type, default or not (`ofEntityType`).
76    Specified,
77}
78
79/// One component of a [`ClientQuotaFilter`], as Kafka's
80/// `ClientQuotaFilterComponent`.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ClientQuotaFilterComponent {
83    /// The entity type, such as [`ENTITY_USER`].
84    pub entity_type: String,
85    /// Which entities of that type match.
86    pub matches: ClientQuotaMatch,
87}
88
89impl ClientQuotaFilterComponent {
90    /// The entity of `entity_type` named `name`.
91    #[must_use]
92    pub fn of_entity(entity_type: impl Into<String>, name: impl Into<String>) -> Self {
93        Self {
94            entity_type: entity_type.into(),
95            matches: ClientQuotaMatch::Exact(name.into()),
96        }
97    }
98
99    /// The default entity of `entity_type`.
100    #[must_use]
101    pub fn of_default_entity(entity_type: impl Into<String>) -> Self {
102        Self {
103            entity_type: entity_type.into(),
104            matches: ClientQuotaMatch::Default,
105        }
106    }
107
108    /// Any entity of `entity_type`.
109    #[must_use]
110    pub fn of_entity_type(entity_type: impl Into<String>) -> Self {
111        Self {
112            entity_type: entity_type.into(),
113            matches: ClientQuotaMatch::Specified,
114        }
115    }
116}
117
118/// Which entities a describe returns, as Kafka's `ClientQuotaFilter`.
119#[derive(Debug, Clone, Default, PartialEq, Eq, bon::Builder)]
120pub struct ClientQuotaFilter {
121    /// Each component that a matched entity must satisfy.
122    #[builder(default)]
123    pub components: Vec<ClientQuotaFilterComponent>,
124    /// Whether a matched entity may have only the component types
125    /// (`containsOnly`), not others as well (`contains`).
126    #[builder(default)]
127    pub strict: bool,
128}
129
130impl ClientQuotaFilter {
131    /// The entities that satisfy every component and may have other entity
132    /// types as well (`ClientQuotaFilter.contains`).
133    #[must_use]
134    pub const fn contains(components: Vec<ClientQuotaFilterComponent>) -> Self {
135        Self {
136            components,
137            strict: false,
138        }
139    }
140
141    /// The entities that satisfy every component and have no other entity
142    /// type (`ClientQuotaFilter.containsOnly`).
143    #[must_use]
144    pub const fn contains_only(components: Vec<ClientQuotaFilterComponent>) -> Self {
145        Self {
146            components,
147            strict: true,
148        }
149    }
150
151    /// Every entity (`ClientQuotaFilter.all`).
152    #[must_use]
153    pub const fn all() -> Self {
154        Self::contains(Vec::new())
155    }
156}
157
158/// The mutations of one entity, as Kafka's `ClientQuotaAlteration`.
159#[derive(Debug, Clone, PartialEq)]
160pub struct ClientQuotaAlteration {
161    /// The entity to change.
162    pub entity: ClientQuotaEntity,
163    /// The changes of its quota values.
164    pub ops: Vec<QuotaOp>,
165}
166
167/// The `DescribeClientQuotas` request of `filter`, as Kafka's
168/// `DescribeClientQuotasRequest.Builder` builds it.
169fn describe_request(filter: &ClientQuotaFilter) -> DescribeClientQuotasRequest {
170    DescribeClientQuotasRequest {
171        components: filter
172            .components
173            .iter()
174            .map(|component| {
175                let (match_type, match_) = match &component.matches {
176                    ClientQuotaMatch::Exact(name) => (MATCH_TYPE_EXACT, Some(name.clone())),
177                    ClientQuotaMatch::Default => (MATCH_TYPE_DEFAULT, None),
178                    ClientQuotaMatch::Specified => (MATCH_TYPE_SPECIFIED, None),
179                };
180                ComponentData {
181                    entity_type: component.entity_type.clone(),
182                    match_type,
183                    match_,
184                    ..Default::default()
185                }
186            })
187            .collect(),
188        strict: filter.strict,
189        ..Default::default()
190    }
191}
192
193/// The quotas of a `DescribeClientQuotas` answer, as Kafka's
194/// `DescribeClientQuotasResponse.complete` reads it. A top-level error fails
195/// the call.
196fn describe_result(response: DescribeClientQuotasResponse) -> Result<ClientQuotas, AdminError> {
197    if response.error_code != 0 {
198        return Err(AdminError::Broker {
199            api: "DescribeClientQuotas",
200            code: response.error_code,
201            name: kafka_error_name(response.error_code),
202            message: response.error_message,
203        });
204    }
205    Ok(response
206        .entries
207        .unwrap_or_default()
208        .into_iter()
209        .map(|entry| {
210            let entity = entry
211                .entity
212                .into_iter()
213                .map(|entity| (entity.entity_type, entity.entity_name))
214                .collect();
215            let values = entry
216                .values
217                .into_iter()
218                .map(|value| (value.key, value.value))
219                .collect();
220            (entity, values)
221        })
222        .collect())
223}
224
225/// The `AlterClientQuotas` request of `alterations`, as Kafka's
226/// `AlterClientQuotasRequest.Builder` builds it.
227fn alter_request(
228    alterations: &[ClientQuotaAlteration],
229    validate_only: bool,
230) -> AlterClientQuotasRequest {
231    AlterClientQuotasRequest {
232        entries: alterations
233            .iter()
234            .map(|alteration| AlterEntry {
235                entity: alteration
236                    .entity
237                    .iter()
238                    .map(|(entity_type, entity_name)| AlterEntity {
239                        entity_type: entity_type.clone(),
240                        entity_name: entity_name.clone(),
241                        ..Default::default()
242                    })
243                    .collect(),
244                ops: alteration.ops.iter().map(op_to_wire).collect(),
245                ..Default::default()
246            })
247            .collect(),
248        validate_only,
249        ..Default::default()
250    }
251}
252
253/// The result of each entity of `alterations`, as Kafka's
254/// `AlterClientQuotasResponse.complete` gives it. An entity that the answer
255/// does not name gets `UNKNOWN_SERVER_ERROR`, and an entity that nobody asked
256/// for is skipped.
257fn alter_results(
258    alterations: &[ClientQuotaAlteration],
259    response: AlterClientQuotasResponse,
260) -> BTreeMap<ClientQuotaEntity, Result<(), KafkaError>> {
261    let mut out = BTreeMap::new();
262    for entry in response.entries {
263        let entity: ClientQuotaEntity = entry
264            .entity
265            .into_iter()
266            .map(|entity| (entity.entity_type, entity.entity_name))
267            .collect();
268        if !alterations
269            .iter()
270            .any(|alteration| alteration.entity == entity)
271        {
272            tracing::warn!(
273                ?entity,
274                "the AlterClientQuotas response names an entity that is not in the request"
275            );
276            continue;
277        }
278        let result = kafka_error_if(entry.error_code, entry.error_message).map_or(Ok(()), Err);
279        out.insert(entity, result);
280    }
281    for alteration in alterations {
282        out.entry(alteration.entity.clone()).or_insert_with(|| {
283            Err(KafkaError {
284                code: UNKNOWN_SERVER_ERROR,
285                name: kafka_error_name(UNKNOWN_SERVER_ERROR),
286                message: Some(format!(
287                    "the AlterClientQuotas response did not contain a result for entity {:?}",
288                    alteration.entity
289                )),
290            })
291        });
292    }
293    out
294}
295
296/// The single-user entity `{user: name}`.
297fn user_entity(username: &str) -> ClientQuotaEntity {
298    maplit::btreemap! {
299        ENTITY_USER.to_owned() => Some(username.to_owned()),
300    }
301}
302
303impl AdminClient {
304    /// Describes the quotas of every entity that `filter` matches, as
305    /// Kafka's `describeClientQuotas` operation does.
306    ///
307    /// The client sends one `DescribeClientQuotas` request on its connection
308    /// and stops at `default.api.timeout.ms` (60 s).
309    ///
310    /// # Errors
311    /// Returns [`AdminError::Broker`] for a top-level error, such as
312    /// `CLUSTER_AUTHORIZATION_FAILED` (31) or `INVALID_REQUEST` (42) for a
313    /// filter the broker refuses, and with `REQUEST_TIMED_OUT` (7) at the
314    /// deadline, or a transport error.
315    pub async fn describe_client_quotas(
316        &self,
317        filter: &ClientQuotaFilter,
318    ) -> Result<ClientQuotas, AdminError> {
319        let retry = ControllerRetry::new("DescribeClientQuotas", self.retry);
320        let response = retry
321            .bounded(self.conn.send(describe_request(filter)))
322            .await?;
323        describe_result(response)
324    }
325
326    /// Changes the quotas of each entity of `alterations`, as Kafka's
327    /// `alterClientQuotas` operation does. With `validate_only` the broker
328    /// validates the changes and applies none.
329    ///
330    /// The result has one entry for each entity: `Ok(())`, or the error the
331    /// broker gave for it, such as `INVALID_REQUEST` (42) for an entity type
332    /// combination it does not accept.
333    ///
334    /// # Errors
335    /// Returns a transport error, and [`AdminError::Broker`] with
336    /// `REQUEST_TIMED_OUT` (7) at `default.api.timeout.ms` (60 s).
337    pub async fn alter_client_quotas(
338        &self,
339        alterations: &[ClientQuotaAlteration],
340        validate_only: bool,
341    ) -> Result<BTreeMap<ClientQuotaEntity, Result<(), KafkaError>>, AdminError> {
342        let retry = ControllerRetry::new("AlterClientQuotas", self.retry);
343        let response = retry
344            .bounded(self.conn.send(alter_request(alterations, validate_only)))
345            .await?;
346        Ok(alter_results(alterations, response))
347    }
348
349    /// The call behind [`AdminClientLike::describe_user_quotas`](crate::AdminClientLike::describe_user_quotas).
350    pub(crate) async fn describe_user_quotas_impl(
351        &self,
352        username: &str,
353    ) -> Result<UserQuotaConfig, AdminError> {
354        let filter = ClientQuotaFilter::contains_only(vec![ClientQuotaFilterComponent::of_entity(
355            ENTITY_USER,
356            username,
357        )]);
358        Ok(self
359            .describe_client_quotas(&filter)
360            .await?
361            .into_values()
362            .flatten()
363            .collect())
364    }
365
366    /// The call behind [`AdminClientLike::alter_user_quotas`](crate::AdminClientLike::alter_user_quotas).
367    pub(crate) async fn alter_user_quotas_impl(
368        &self,
369        username: &str,
370        ops: &[QuotaOp],
371        validate_only: bool,
372    ) -> Result<Option<KafkaError>, AdminError> {
373        if ops.is_empty() {
374            return Ok(None);
375        }
376        let alteration = ClientQuotaAlteration {
377            entity: user_entity(username),
378            ops: ops.to_vec(),
379        };
380        Ok(self
381            .alter_client_quotas(slice::from_ref(&alteration), validate_only)
382            .await?
383            .into_values()
384            .next()
385            .and_then(Result::err))
386    }
387}
388
389fn op_to_wire(op: &QuotaOp) -> AlterOp {
390    match op {
391        QuotaOp::Set { key, value } => AlterOp {
392            key: key.clone(),
393            value: *value,
394            ..Default::default()
395        },
396        QuotaOp::Remove { key } => AlterOp {
397            key: key.clone(),
398            remove: true,
399            ..Default::default()
400        },
401    }
402}
403
404/// Pure function.
405///
406/// It diffs the desired key-set against the current key-set and
407/// produces the minimal `(set, remove)` op stream. Floats compare bit-equal,
408/// so it does not re-issue a no-op `Set` with the same value.
409#[must_use]
410pub fn diff_user_quotas(current: &UserQuotaConfig, desired: &UserQuotaConfig) -> Vec<QuotaOp> {
411    let mut ops = Vec::new();
412    for (k, v) in desired {
413        match current.get(k) {
414            Some(cur) if cur.to_bits() == v.to_bits() => {}
415            _ => ops.push(QuotaOp::Set {
416                key: k.clone(),
417                value: *v,
418            }),
419        }
420    }
421    for k in current.keys() {
422        if !desired.contains_key(k) {
423            ops.push(QuotaOp::Remove { key: k.clone() });
424        }
425    }
426    ops
427}
428
429#[cfg(test)]
430mod tests {
431    use std::sync::{Arc, Mutex};
432
433    use bytes::{Buf, BytesMut};
434    use krabka_client_core::MockBroker;
435    use krabka_protocol::{
436        Decode, Encode, UnknownTaggedFields,
437        owned::{
438            alter_client_quotas_request,
439            alter_client_quotas_response::{AlterClientQuotasResponse, EntityData, EntryData},
440            api_versions_request,
441            api_versions_response::{ApiVersion, ApiVersionsResponse},
442            describe_client_quotas_request,
443            describe_client_quotas_response::{
444                DescribeClientQuotasResponse, EntityData as DescribeEntityData,
445                EntryData as DescribeEntryData, ValueData as DescribeValueData,
446            },
447        },
448    };
449
450    use super::*;
451    use crate::AdminClientLike as _;
452
453    fn encode_v0(resp: &impl Encode) -> Vec<u8> {
454        let mut buf = BytesMut::new();
455        resp.encode(&mut buf, 0).unwrap();
456        buf.to_vec()
457    }
458
459    fn api_versions_response(api_key: i16, version: i16) -> Vec<u8> {
460        encode_v0(&ApiVersionsResponse {
461            api_keys: vec![
462                ApiVersion {
463                    api_key: api_versions_request::API_KEY,
464                    min_version: 0,
465                    max_version: 0,
466                    ..Default::default()
467                },
468                ApiVersion {
469                    api_key,
470                    min_version: version,
471                    max_version: version,
472                    ..Default::default()
473                },
474            ],
475            ..Default::default()
476        })
477    }
478
479    fn request_body_after_header(mut body: &[u8], flexible_header: bool) -> &[u8] {
480        let client_id_len = body.get_i16();
481        assert2::assert!(client_id_len >= 0);
482        body.advance(usize::try_from(client_id_len).expect("client id length is non-negative"));
483        if flexible_header {
484            assert2::assert!(body.get_u8() == 0);
485        }
486        body
487    }
488
489    #[test]
490    fn diff_no_change_returns_empty() {
491        let mut c = UserQuotaConfig::new();
492        c.insert("producer_byte_rate".into(), 1_048_576.0);
493        let d = c.clone();
494        assert2::assert!(diff_user_quotas(&c, &d).is_empty());
495    }
496
497    #[test]
498    fn diff_set_added_keys() {
499        let c = UserQuotaConfig::new();
500        let mut d = UserQuotaConfig::new();
501        d.insert("producer_byte_rate".into(), 1_048_576.0);
502        d.insert("request_percentage".into(), 25.0);
503        let ops = diff_user_quotas(&c, &d);
504        // `desired` is a BTreeMap, so `Set` ops come out in key order.
505        assert2::assert!(
506            ops == vec![
507                QuotaOp::Set {
508                    key: "producer_byte_rate".to_owned(),
509                    value: 1_048_576.0,
510                },
511                QuotaOp::Set {
512                    key: "request_percentage".to_owned(),
513                    value: 25.0,
514                },
515            ]
516        );
517    }
518
519    #[test]
520    fn diff_remove_dropped_keys() {
521        let mut c = UserQuotaConfig::new();
522        c.insert("producer_byte_rate".into(), 1.0);
523        c.insert("consumer_byte_rate".into(), 2.0);
524        let mut d = UserQuotaConfig::new();
525        d.insert("producer_byte_rate".into(), 1.0);
526        let ops = diff_user_quotas(&c, &d);
527        assert2::assert!(
528            ops == vec![QuotaOp::Remove {
529                key: "consumer_byte_rate".into()
530            }]
531        );
532    }
533
534    #[test]
535    fn diff_value_change_is_a_set() {
536        let mut c = UserQuotaConfig::new();
537        c.insert("producer_byte_rate".into(), 1.0);
538        let mut d = UserQuotaConfig::new();
539        d.insert("producer_byte_rate".into(), 2.0);
540        let ops = diff_user_quotas(&c, &d);
541        assert2::assert!(
542            ops == vec![QuotaOp::Set {
543                key: "producer_byte_rate".into(),
544                value: 2.0,
545            }]
546        );
547    }
548
549    #[test]
550    fn diff_mixed_add_change_remove() {
551        let mut c = UserQuotaConfig::new();
552        c.insert("producer_byte_rate".into(), 1.0);
553        c.insert("consumer_byte_rate".into(), 2.0);
554        let mut d = UserQuotaConfig::new();
555        d.insert("producer_byte_rate".into(), 5.0); // change
556        d.insert("request_percentage".into(), 25.0); // add
557        // consumer_byte_rate dropped
558        let ops = diff_user_quotas(&c, &d);
559        // Sets come first (in `desired` key order), then removes (in
560        // `current` key order) — both maps are BTreeMaps.
561        assert2::assert!(
562            ops == vec![
563                QuotaOp::Set {
564                    key: "producer_byte_rate".to_owned(),
565                    value: 5.0,
566                },
567                QuotaOp::Set {
568                    key: "request_percentage".to_owned(),
569                    value: 25.0,
570                },
571                QuotaOp::Remove {
572                    key: "consumer_byte_rate".to_owned(),
573                },
574            ]
575        );
576    }
577
578    #[test]
579    fn op_to_wire_set() {
580        let op = QuotaOp::Set {
581            key: "producer_byte_rate".into(),
582            value: 1.0,
583        };
584        let w = op_to_wire(&op);
585        assert2::assert!(
586            w == AlterOp {
587                key: "producer_byte_rate".to_owned(),
588                value: 1.0,
589                remove: false,
590                unknown_tagged_fields: UnknownTaggedFields(vec![]),
591            }
592        );
593    }
594
595    #[test]
596    fn op_to_wire_remove_sends_zero_value_and_flag() {
597        let op = QuotaOp::Remove {
598            key: "producer_byte_rate".into(),
599        };
600        let w = op_to_wire(&op);
601        assert2::assert!(
602            w == AlterOp {
603                key: "producer_byte_rate".to_owned(),
604                value: 0.0,
605                remove: true,
606                unknown_tagged_fields: UnknownTaggedFields(vec![]),
607            }
608        );
609    }
610
611    fn entity(parts: &[(&str, Option<&str>)]) -> ClientQuotaEntity {
612        parts
613            .iter()
614            .map(|(entity_type, name)| ((*entity_type).to_owned(), name.map(str::to_owned)))
615            .collect()
616    }
617
618    fn component(entity_type: &str, match_type: i8, match_: Option<&str>) -> ComponentData {
619        ComponentData {
620            entity_type: entity_type.to_owned(),
621            match_type,
622            match_: match_.map(str::to_owned),
623            ..Default::default()
624        }
625    }
626
627    /// Kafka's `DescribeClientQuotasRequest.Builder` maps `ofEntity`,
628    /// `ofDefaultEntity` and `ofEntityType` to match types 0, 1 and 2, and
629    /// `containsOnly` to `strict`.
630    #[test]
631    fn describe_request_maps_each_filter_component() {
632        for (name, filter, expected) in [
633            (
634                "all entities",
635                ClientQuotaFilter::all(),
636                DescribeClientQuotasRequest::default(),
637            ),
638            (
639                "one user and any client id",
640                ClientQuotaFilter::contains_only(vec![
641                    ClientQuotaFilterComponent::of_entity(ENTITY_USER, "alice"),
642                    ClientQuotaFilterComponent::of_entity_type(ENTITY_CLIENT_ID),
643                ]),
644                DescribeClientQuotasRequest {
645                    components: vec![
646                        component("user", 0, Some("alice")),
647                        component("client-id", 2, None),
648                    ],
649                    strict: true,
650                    ..Default::default()
651                },
652            ),
653            (
654                "the default ip",
655                ClientQuotaFilter::contains(vec![ClientQuotaFilterComponent::of_default_entity(
656                    ENTITY_IP,
657                )]),
658                DescribeClientQuotasRequest {
659                    components: vec![component("ip", 1, None)],
660                    strict: false,
661                    ..Default::default()
662                },
663            ),
664        ] {
665            assert2::assert!(describe_request(&filter) == expected, "case {name}");
666        }
667    }
668
669    /// Kafka's `AlterClientQuotasRequest.Builder` sends each entity with its
670    /// entity types and ops, a default entity as a null name, and a removed
671    /// key as `remove=true`.
672    #[test]
673    fn alter_request_carries_each_entity_type() {
674        let alterations = [
675            ClientQuotaAlteration {
676                entity: entity(&[("user", Some("alice")), ("client-id", None)]),
677                ops: vec![QuotaOp::Set {
678                    key: "producer_byte_rate".to_owned(),
679                    value: 1024.0,
680                }],
681            },
682            ClientQuotaAlteration {
683                entity: entity(&[("ip", Some("10.0.0.1"))]),
684                ops: vec![QuotaOp::Remove {
685                    key: "connection_creation_rate".to_owned(),
686                }],
687            },
688        ];
689        let alter_entity = |entity_type: &str, name: Option<&str>| AlterEntity {
690            entity_type: entity_type.to_owned(),
691            entity_name: name.map(str::to_owned),
692            ..Default::default()
693        };
694        assert2::assert!(
695            alter_request(&alterations, true)
696                == AlterClientQuotasRequest {
697                    entries: vec![
698                        AlterEntry {
699                            entity: vec![
700                                alter_entity("client-id", None),
701                                alter_entity("user", Some("alice")),
702                            ],
703                            ops: vec![AlterOp {
704                                key: "producer_byte_rate".to_owned(),
705                                value: 1024.0,
706                                ..Default::default()
707                            }],
708                            ..Default::default()
709                        },
710                        AlterEntry {
711                            entity: vec![alter_entity("ip", Some("10.0.0.1"))],
712                            ops: vec![AlterOp {
713                                key: "connection_creation_rate".to_owned(),
714                                remove: true,
715                                ..Default::default()
716                            }],
717                            ..Default::default()
718                        },
719                    ],
720                    validate_only: true,
721                    ..Default::default()
722                }
723        );
724    }
725
726    /// Kafka's `describeClientQuotas` returns the values of each entity of
727    /// every type, retries a lost connection, and fails the call on a
728    /// top-level error.
729    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
730    async fn describe_client_quotas_maps_every_entity_type() {
731        use krabka_client_core::MockReply;
732
733        use crate::partition_leaders::test_support::{
734            ApiVersionRange, encode_response, scripted_broker,
735        };
736
737        let entries = vec![
738            DescribeEntryData {
739                entity: vec![
740                    DescribeEntityData {
741                        entity_type: "user".into(),
742                        entity_name: Some("alice".into()),
743                        ..Default::default()
744                    },
745                    DescribeEntityData {
746                        entity_type: "client-id".into(),
747                        entity_name: None,
748                        ..Default::default()
749                    },
750                ],
751                values: vec![DescribeValueData {
752                    key: "producer_byte_rate".into(),
753                    value: 1024.0,
754                    ..Default::default()
755                }],
756                ..Default::default()
757            },
758            DescribeEntryData {
759                entity: vec![DescribeEntityData {
760                    entity_type: "ip".into(),
761                    entity_name: Some("10.0.0.1".into()),
762                    ..Default::default()
763                }],
764                values: vec![DescribeValueData {
765                    key: "connection_creation_rate".into(),
766                    value: 5.0,
767                    ..Default::default()
768                }],
769                ..Default::default()
770            },
771        ];
772        let quotas = ClientQuotas::from([
773            (
774                entity(&[("user", Some("alice")), ("client-id", None)]),
775                maplit::btreemap! {
776                    "producer_byte_rate".to_owned() => 1024.0,
777                },
778            ),
779            (
780                entity(&[("ip", Some("10.0.0.1"))]),
781                maplit::btreemap! {
782                    "connection_creation_rate".to_owned() => 5.0,
783                },
784            ),
785        ]);
786        for (name, close_first, error_code, expected) in [
787            ("success", false, 0, (Ok(quotas.clone()), 1)),
788            (
789                "a lost connection is retried",
790                true,
791                0,
792                (Ok(quotas.clone()), 2),
793            ),
794            (
795                "a top-level error fails the call",
796                false,
797                31,
798                (Err(("DescribeClientQuotas", 31)), 1),
799            ),
800        ] {
801            let requests = Arc::new(Mutex::new(0_usize));
802            let handler_requests = Arc::clone(&requests);
803            let entries = entries.clone();
804            let broker = scripted_broker(
805                vec![
806                    ApiVersionRange::builder()
807                        .api_key(describe_client_quotas_request::API_KEY)
808                        .max_version(1)
809                        .build(),
810                ],
811                move |api_key, version, _, _| {
812                    if api_key != describe_client_quotas_request::API_KEY {
813                        return MockReply::Silent;
814                    }
815                    let mut requests = handler_requests.lock().expect("requests lock");
816                    *requests += 1;
817                    let first = *requests == 1;
818                    drop(requests);
819                    if close_first && first {
820                        return MockReply::Close;
821                    }
822                    MockReply::Respond(encode_response(
823                        &DescribeClientQuotasResponse {
824                            error_code,
825                            entries: (error_code == 0).then(|| entries.clone()),
826                            ..Default::default()
827                        },
828                        version,
829                        true,
830                    ))
831                },
832            )
833            .await;
834            let admin = AdminClient::connect(&[broker.addr.to_string()])
835                .await
836                .expect("admin connects");
837
838            let result = admin
839                .describe_client_quotas(&ClientQuotaFilter::all())
840                .await
841                .map_err(|error| match error {
842                    AdminError::Broker { api, code, .. } => (api, code),
843                    other => panic!("case {name}: unexpected error {other:?}"),
844                });
845
846            broker.stop();
847            assert2::assert!(
848                (result, *requests.lock().expect("requests lock")) == expected,
849                "case {name}"
850            );
851        }
852    }
853
854    /// Kafka's `AlterClientQuotasResponse.complete` gives each entity its own
855    /// result. An entity the answer leaves out gets `UNKNOWN_SERVER_ERROR`.
856    #[test]
857    fn alter_results_give_each_entity_its_result() {
858        let alice = entity(&[("user", Some("alice")), ("client-id", Some("app"))]);
859        let ip = entity(&[("ip", None)]);
860        let alterations = [
861            ClientQuotaAlteration {
862                entity: alice.clone(),
863                ops: Vec::new(),
864            },
865            ClientQuotaAlteration {
866                entity: ip.clone(),
867                ops: Vec::new(),
868            },
869        ];
870        let answer = |entity_type: &str, entity_name: Option<&str>, error_code| EntryData {
871            error_code,
872            error_message: (error_code != 0).then(|| "bad".to_owned()),
873            entity: vec![EntityData {
874                entity_type: entity_type.to_owned(),
875                entity_name: entity_name.map(str::to_owned),
876                ..Default::default()
877            }],
878            ..Default::default()
879        };
880        let mut alice_answer = answer("user", Some("alice"), 0);
881        alice_answer.entity.push(EntityData {
882            entity_type: "client-id".to_owned(),
883            entity_name: Some("app".to_owned()),
884            ..Default::default()
885        });
886        let unknown = Err(KafkaError {
887            code: -1,
888            name: "UNKNOWN_SERVER_ERROR",
889            message: Some(format!(
890                "the AlterClientQuotas response did not contain a result for entity {ip:?}"
891            )),
892        });
893        for (name, entries, expected) in [
894            (
895                "each entity has its result",
896                vec![alice_answer.clone(), answer("ip", None, 42)],
897                maplit::btreemap! {
898                    alice.clone() => Ok(()),
899                    ip.clone() => Err(KafkaError {
900                    code: 42,
901                    name: "INVALID_REQUEST",
902                    message: Some("bad".to_owned()),
903                }),
904                },
905            ),
906            (
907                "a missing entity and an unasked entity",
908                vec![alice_answer.clone(), answer("user", Some("bob"), 0)],
909                maplit::btreemap! {
910                    alice => Ok(()),
911                    ip => unknown,
912                },
913            ),
914        ] {
915            let response = AlterClientQuotasResponse {
916                entries,
917                ..Default::default()
918            };
919            assert2::assert!(
920                alter_results(&alterations, response) == expected,
921                "case {name}"
922            );
923        }
924    }
925
926    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
927    async fn describe_user_quotas_sends_strict_user_component() {
928        let seen_request = Arc::new(Mutex::new(None));
929        let captured_request = Arc::clone(&seen_request);
930        let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
931            if api_key == api_versions_request::API_KEY {
932                return Some(api_versions_response(
933                    describe_client_quotas_request::API_KEY,
934                    0,
935                ));
936            }
937            if api_key == describe_client_quotas_request::API_KEY {
938                let mut body = request_body_after_header(
939                    body,
940                    version >= describe_client_quotas_request::FLEXIBLE_MIN,
941                );
942                let request = DescribeClientQuotasRequest::decode(&mut body, version)
943                    .expect("describe quotas request decodes");
944                assert2::assert!(body.is_empty());
945                *captured_request.lock().expect("request capture lock") = Some(request);
946                return Some(encode_v0(&DescribeClientQuotasResponse {
947                    entries: Some(vec![DescribeEntryData {
948                        entity: vec![DescribeEntityData {
949                            entity_type: "user".into(),
950                            entity_name: Some("alice".into()),
951                            ..Default::default()
952                        }],
953                        values: vec![DescribeValueData {
954                            key: "producer_byte_rate".into(),
955                            value: 1024.0,
956                            ..Default::default()
957                        }],
958                        ..Default::default()
959                    }]),
960                    ..Default::default()
961                }));
962            }
963            None
964        })
965        .await
966        .unwrap();
967        let mut admin = AdminClient::connect(&[mock.addr.to_string()])
968            .await
969            .expect("admin connects to mock broker");
970
971        let quotas = admin
972            .describe_user_quotas("alice")
973            .await
974            .expect("describe quotas response maps");
975
976        assert2::assert!(quotas.get("producer_byte_rate") == Some(&1024.0));
977        let request = seen_request
978            .lock()
979            .expect("request capture lock")
980            .take()
981            .expect("describe quotas request was captured");
982        assert2::assert!(
983            request
984                == DescribeClientQuotasRequest {
985                    components: vec![ComponentData {
986                        entity_type: "user".into(),
987                        match_type: MATCH_TYPE_EXACT,
988                        match_: Some("alice".into()),
989                        ..Default::default()
990                    }],
991                    strict: true,
992                    ..Default::default()
993                }
994        );
995        mock.stop();
996    }
997
998    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
999    async fn alter_user_quotas_surfaces_broker_entry_error() {
1000        let seen_request = Arc::new(Mutex::new(None));
1001        let captured_request = Arc::clone(&seen_request);
1002        let mock = MockBroker::start(move |api_key, version, _corr_id, body| {
1003            if api_key == api_versions_request::API_KEY {
1004                return Some(api_versions_response(
1005                    alter_client_quotas_request::API_KEY,
1006                    0,
1007                ));
1008            }
1009            if api_key == alter_client_quotas_request::API_KEY {
1010                let mut body = request_body_after_header(
1011                    body,
1012                    version >= alter_client_quotas_request::FLEXIBLE_MIN,
1013                );
1014                let request = AlterClientQuotasRequest::decode(&mut body, version)
1015                    .expect("alter quotas request decodes");
1016                assert2::assert!(body.is_empty());
1017                *captured_request.lock().expect("request capture lock") = Some(request);
1018                return Some(encode_v0(&AlterClientQuotasResponse {
1019                    entries: vec![EntryData {
1020                        error_code: 40,
1021                        error_message: Some("invalid quota".into()),
1022                        entity: vec![EntityData {
1023                            entity_type: "user".into(),
1024                            entity_name: Some("alice".into()),
1025                            ..Default::default()
1026                        }],
1027                        ..Default::default()
1028                    }],
1029                    ..Default::default()
1030                }));
1031            }
1032            None
1033        })
1034        .await
1035        .unwrap();
1036        let mut admin = AdminClient::connect(&[mock.addr.to_string()])
1037            .await
1038            .expect("admin connects to mock broker");
1039
1040        let error = admin
1041            .alter_user_quotas(
1042                "alice",
1043                &[
1044                    QuotaOp::Set {
1045                        key: "producer_byte_rate".into(),
1046                        value: 1024.0,
1047                    },
1048                    QuotaOp::Remove {
1049                        key: "consumer_byte_rate".into(),
1050                    },
1051                ],
1052                true,
1053            )
1054            .await
1055            .expect("alter quotas maps broker entry")
1056            .expect("non-zero broker error is returned");
1057
1058        assert2::assert!((error.code, error.message.as_deref()) == (40, Some("invalid quota")));
1059        let request = seen_request
1060            .lock()
1061            .expect("request capture lock")
1062            .take()
1063            .expect("alter quotas request was captured");
1064        assert2::assert!(
1065            request
1066                == AlterClientQuotasRequest {
1067                    entries: vec![AlterEntry {
1068                        entity: vec![AlterEntity {
1069                            entity_type: "user".into(),
1070                            entity_name: Some("alice".into()),
1071                            ..Default::default()
1072                        }],
1073                        ops: vec![
1074                            AlterOp {
1075                                key: "producer_byte_rate".into(),
1076                                value: 1024.0,
1077                                ..Default::default()
1078                            },
1079                            AlterOp {
1080                                key: "consumer_byte_rate".into(),
1081                                remove: true,
1082                                ..Default::default()
1083                            },
1084                        ],
1085                        ..Default::default()
1086                    }],
1087                    validate_only: true,
1088                    ..Default::default()
1089                }
1090        );
1091        mock.stop();
1092    }
1093}