Skip to main content

mongreldb_types/
errors.rs

1//! Stable cross-language error taxonomy (spec section 9.7, FND-007).
2//!
3//! Implemented in the Stage 0 foundation wave: `ErrorCategory` with exactly
4//! the twenty categories the spec lists, stable numeric codes that are never
5//! reused, and the canonical structural form every language binding maps.
6//!
7//! Retry policy is expressed per category through [`ErrorCategory::retry_class`];
8//! the routing rules of spec section 11.7 (leader hints, metadata refresh, and
9//! the ban on replaying ambiguous writes without a durable idempotency key)
10//! are layered on top of that classification by the gateway.
11
12/// One of the twenty stable error categories of spec section 9.7.
13///
14/// Stability contract (spec section 4.10):
15///
16/// - The numeric [`ErrorCategory::code`] of a category is never reused, even
17///   if a category is retired; new categories are only ever appended with
18///   fresh codes.
19/// - Declaration order is frozen: serde encodes enums by variant index in
20///   binary formats, so variants must never be reordered.
21/// - Variant names are the cross-language wire contract in text formats;
22///   they never change.
23#[derive(
24    Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, thiserror::Error,
25)]
26pub enum ErrorCategory {
27    /// The receiving replica is not the leader for the consensus group.
28    /// Retryable with the returned leader hint (spec section 11.7).
29    #[error("not leader")]
30    NotLeader,
31    /// No leader is currently known for the consensus group; retry after
32    /// re-running leader discovery and refreshing routing metadata.
33    #[error("leader unknown")]
34    LeaderUnknown,
35    /// The caller acted on stale routing or schema metadata; retry after a
36    /// metadata refresh.
37    #[error("stale metadata")]
38    StaleMetadata,
39    /// The target replica cannot serve the request; retry with backoff or
40    /// fail over to another replica.
41    #[error("replica unavailable")]
42    ReplicaUnavailable,
43    /// The consensus group lost quorum; retry with backoff until a quorum is
44    /// restored.
45    #[error("quorum unavailable")]
46    QuorumUnavailable,
47    /// The transaction conflicted with a concurrent transaction; retry the
48    /// whole transaction.
49    #[error("transaction conflict")]
50    TransactionConflict,
51    /// The transaction was aborted (by the system or by the caller); a fresh
52    /// transaction may be retried from the beginning.
53    #[error("transaction aborted")]
54    TransactionAborted,
55    /// A serializable transaction failed its certification; retry the whole
56    /// transaction.
57    #[error("serialization failure")]
58    SerializationFailure,
59    /// The transaction was chosen as a deadlock victim; retry the whole
60    /// transaction.
61    #[error("deadlock")]
62    Deadlock,
63    /// The caller-supplied deadline expired. Not automatically retryable:
64    /// issue a new request with a fresh deadline instead of replaying.
65    #[error("deadline exceeded")]
66    DeadlineExceeded,
67    /// The caller cancelled the operation. Never retried: cancellation is
68    /// caller intent.
69    #[error("cancelled")]
70    Cancelled,
71    /// The outcome of a commit is unknown (contact was lost after propose).
72    /// NEVER blindly retried (spec section 11.7): replay only with a durable
73    /// idempotency key or an unambiguous not-proposed status.
74    #[error("commit outcome unknown")]
75    CommitOutcomeUnknown,
76    /// The commit arrived after its window closed and was not applied. The
77    /// commit itself is final; only a fresh transaction may succeed.
78    #[error("commit too late")]
79    CommitTooLate,
80    /// The tablet moved to another node; retry after refreshing routing
81    /// metadata.
82    #[error("tablet moved")]
83    TabletMoved,
84    /// The tablet is mid-split; retry after the split completes and routing
85    /// metadata has been refreshed.
86    #[error("tablet splitting")]
87    TabletSplitting,
88    /// The request was built against a different schema version; retry after
89    /// refreshing the schema and re-preparing the request.
90    #[error("schema version mismatch")]
91    SchemaVersionMismatch,
92    /// Binary, protocol, or format versions disagree (spec section 11.8).
93    /// Not retryable: only upgrading or rolling back one side changes the
94    /// outcome.
95    #[error("cluster version mismatch")]
96    ClusterVersionMismatch,
97    /// A resource limit (memory, disk, budget, locks) was hit; retry with
98    /// backoff once capacity frees up, or with a smaller request.
99    #[error("resource exhausted")]
100    ResourceExhausted,
101    /// Credentials are missing or invalid. Not retryable until the caller
102    /// obtains different credentials — that is a new request, not a retry.
103    #[error("unauthenticated")]
104    Unauthenticated,
105    /// The authenticated principal lacks the required grant. Never retried:
106    /// replaying the same request as the same principal re-fails.
107    #[error("permission denied")]
108    PermissionDenied,
109}
110
111/// The retry discipline for an [`ErrorCategory`] (spec section 11.7).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
113pub enum RetryClass {
114    /// Never retried automatically: the same request would re-fail (or the
115    /// failure is caller intent), and only changing the request, the
116    /// principal, or a version changes the outcome.
117    Never,
118    /// Never blindly retried: the outcome is ambiguous, so a replay is only
119    /// permitted with a durable idempotency key or an unambiguous
120    /// not-proposed status (spec section 11.7).
121    IdempotencyKeyRequired,
122    /// Retryable after refreshing routing and/or schema metadata (leader
123    /// hint, endpoint list, tablet map, schema version).
124    AfterMetadataRefresh,
125    /// Retryable after a backoff delay once the transient condition
126    /// (availability, quorum, resource pressure) clears.
127    AfterBackoff,
128    /// Retryable by restarting the whole transaction from the beginning with
129    /// a fresh snapshot.
130    RetryTransaction,
131}
132
133impl ErrorCategory {
134    /// All twenty categories in declaration (and code) order.
135    pub const ALL: [Self; 20] = [
136        Self::NotLeader,
137        Self::LeaderUnknown,
138        Self::StaleMetadata,
139        Self::ReplicaUnavailable,
140        Self::QuorumUnavailable,
141        Self::TransactionConflict,
142        Self::TransactionAborted,
143        Self::SerializationFailure,
144        Self::Deadlock,
145        Self::DeadlineExceeded,
146        Self::Cancelled,
147        Self::CommitOutcomeUnknown,
148        Self::CommitTooLate,
149        Self::TabletMoved,
150        Self::TabletSplitting,
151        Self::SchemaVersionMismatch,
152        Self::ClusterVersionMismatch,
153        Self::ResourceExhausted,
154        Self::Unauthenticated,
155        Self::PermissionDenied,
156    ];
157
158    /// The stable numeric code of this category, in `1..=20`.
159    ///
160    /// Codes are never reused (spec section 4.10): a retired category keeps
161    /// its code forever and new categories are only ever appended, so a code
162    /// a peer emits is always interpretable.
163    pub const fn code(self) -> u32 {
164        match self {
165            Self::NotLeader => 1,
166            Self::LeaderUnknown => 2,
167            Self::StaleMetadata => 3,
168            Self::ReplicaUnavailable => 4,
169            Self::QuorumUnavailable => 5,
170            Self::TransactionConflict => 6,
171            Self::TransactionAborted => 7,
172            Self::SerializationFailure => 8,
173            Self::Deadlock => 9,
174            Self::DeadlineExceeded => 10,
175            Self::Cancelled => 11,
176            Self::CommitOutcomeUnknown => 12,
177            Self::CommitTooLate => 13,
178            Self::TabletMoved => 14,
179            Self::TabletSplitting => 15,
180            Self::SchemaVersionMismatch => 16,
181            Self::ClusterVersionMismatch => 17,
182            Self::ResourceExhausted => 18,
183            Self::Unauthenticated => 19,
184            Self::PermissionDenied => 20,
185        }
186    }
187
188    /// Stable snake-phrase name matching [`Display`] / the daemon HTTP
189    /// `error.category` field (e.g. `"permission denied"`).
190    ///
191    /// Prefer this (or [`Display`]) for cross-language surfaces; the Rust
192    /// variant identifiers remain the serde/text wire names.
193    pub const fn name(self) -> &'static str {
194        match self {
195            Self::NotLeader => "not leader",
196            Self::LeaderUnknown => "leader unknown",
197            Self::StaleMetadata => "stale metadata",
198            Self::ReplicaUnavailable => "replica unavailable",
199            Self::QuorumUnavailable => "quorum unavailable",
200            Self::TransactionConflict => "transaction conflict",
201            Self::TransactionAborted => "transaction aborted",
202            Self::SerializationFailure => "serialization failure",
203            Self::Deadlock => "deadlock",
204            Self::DeadlineExceeded => "deadline exceeded",
205            Self::Cancelled => "cancelled",
206            Self::CommitOutcomeUnknown => "commit outcome unknown",
207            Self::CommitTooLate => "commit too late",
208            Self::TabletMoved => "tablet moved",
209            Self::TabletSplitting => "tablet splitting",
210            Self::SchemaVersionMismatch => "schema version mismatch",
211            Self::ClusterVersionMismatch => "cluster version mismatch",
212            Self::ResourceExhausted => "resource exhausted",
213            Self::Unauthenticated => "unauthenticated",
214            Self::PermissionDenied => "permission denied",
215        }
216    }
217
218    /// Resolves a stable [`Self::code`] back to its category.
219    ///
220    /// Returns `None` for codes this build does not know (never emitted by
221    /// this build; possibly allocated by a newer one). Callers MUST treat an
222    /// unknown code as an unknown, non-retryable failure rather than
223    /// guessing.
224    pub const fn from_code(code: u32) -> Option<Self> {
225        match code {
226            1 => Some(Self::NotLeader),
227            2 => Some(Self::LeaderUnknown),
228            3 => Some(Self::StaleMetadata),
229            4 => Some(Self::ReplicaUnavailable),
230            5 => Some(Self::QuorumUnavailable),
231            6 => Some(Self::TransactionConflict),
232            7 => Some(Self::TransactionAborted),
233            8 => Some(Self::SerializationFailure),
234            9 => Some(Self::Deadlock),
235            10 => Some(Self::DeadlineExceeded),
236            11 => Some(Self::Cancelled),
237            12 => Some(Self::CommitOutcomeUnknown),
238            13 => Some(Self::CommitTooLate),
239            14 => Some(Self::TabletMoved),
240            15 => Some(Self::TabletSplitting),
241            16 => Some(Self::SchemaVersionMismatch),
242            17 => Some(Self::ClusterVersionMismatch),
243            18 => Some(Self::ResourceExhausted),
244            19 => Some(Self::Unauthenticated),
245            20 => Some(Self::PermissionDenied),
246            _ => None,
247        }
248    }
249
250    /// How a failure in this category may be retried (spec section 11.7).
251    pub const fn retry_class(self) -> RetryClass {
252        match self {
253            Self::NotLeader
254            | Self::LeaderUnknown
255            | Self::StaleMetadata
256            | Self::TabletMoved
257            | Self::TabletSplitting
258            | Self::SchemaVersionMismatch => RetryClass::AfterMetadataRefresh,
259            Self::ReplicaUnavailable | Self::QuorumUnavailable | Self::ResourceExhausted => {
260                RetryClass::AfterBackoff
261            }
262            Self::TransactionConflict
263            | Self::TransactionAborted
264            | Self::SerializationFailure
265            | Self::Deadlock
266            | Self::CommitTooLate => RetryClass::RetryTransaction,
267            Self::DeadlineExceeded
268            | Self::Cancelled
269            | Self::ClusterVersionMismatch
270            | Self::Unauthenticated
271            | Self::PermissionDenied => RetryClass::Never,
272            Self::CommitOutcomeUnknown => RetryClass::IdempotencyKeyRequired,
273        }
274    }
275
276    /// Whether a plain automatic retry — no fresh credentials and no durable
277    /// idempotency key — may succeed for this category.
278    ///
279    /// This is deliberately `false` for [`ErrorCategory::CommitOutcomeUnknown`]
280    /// even though a replay *with* a durable idempotency key is permitted
281    /// (spec section 11.7): "retryable" here means safe to retry blindly.
282    pub const fn is_retryable(self) -> bool {
283        matches!(
284            self.retry_class(),
285            RetryClass::AfterMetadataRefresh
286                | RetryClass::AfterBackoff
287                | RetryClass::RetryTransaction
288        )
289    }
290}
291
292/// The canonical structural error form of the taxonomy (spec section 9.7).
293///
294/// Every language binding — the Node addon, the C FFI, and the JNI binding,
295/// all built in later stages — maps exactly this structure: a stable
296/// [`ErrorCategory`] plus a human-readable message. The message is diagnostic
297/// text only; programmatic handling MUST key off `category` (or its stable
298/// [`CategoryError::code`]), never off the message.
299#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, thiserror::Error)]
300#[error("{category}: {message}")]
301pub struct CategoryError {
302    /// The stable category of the failure.
303    pub category: ErrorCategory,
304    /// Human-readable detail. Not part of the stable contract.
305    pub message: String,
306}
307
308impl CategoryError {
309    /// Builds the structural form for `category` with diagnostic `message`.
310    pub fn new(category: ErrorCategory, message: impl Into<String>) -> Self {
311        Self {
312            category,
313            message: message.into(),
314        }
315    }
316
317    /// The stable numeric code of [`Self::category`] (never reused, spec
318    /// section 4.10).
319    pub fn code(&self) -> u32 {
320        self.category.code()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use serde::de::value::{Error as ValueError, StrDeserializer};
328    use serde::de::{
329        DeserializeSeed, EnumAccess, IntoDeserializer, MapAccess, VariantAccess, Visitor,
330    };
331    use serde::ser::{Impossible, SerializeStruct};
332    use serde::{Deserialize, Deserializer, Serialize, Serializer};
333
334    // Minimal serde data-model format used to round-trip the taxonomy in
335    // tests without pulling a serialization crate into this crate's
336    // (frozen) dependency set: values become a `Value` tree and the
337    // deserializer replays it.
338    #[derive(Debug, Clone, PartialEq, Eq)]
339    enum Value {
340        Str(String),
341        UnitVariant(&'static str),
342        Struct(Vec<(&'static str, Value)>),
343    }
344
345    struct ValueSerializer;
346
347    macro_rules! serialize_unsupported {
348        ($($method:ident ( $($param:ident : $ty:ty),* ) -> $ret:ty ;)*) => {
349            $(
350                fn $method(self, $($param: $ty),*) -> Result<$ret, ValueError> {
351                    Err(serde::ser::Error::custom(concat!(
352                        stringify!($method),
353                        " is not supported by the in-test serde format"
354                    )))
355                }
356            )*
357        };
358    }
359
360    impl Serializer for ValueSerializer {
361        type Ok = Value;
362        type Error = ValueError;
363        type SerializeSeq = Impossible<Value, ValueError>;
364        type SerializeTuple = Impossible<Value, ValueError>;
365        type SerializeTupleStruct = Impossible<Value, ValueError>;
366        type SerializeTupleVariant = Impossible<Value, ValueError>;
367        type SerializeMap = Impossible<Value, ValueError>;
368        type SerializeStruct = StructSerializer;
369        type SerializeStructVariant = Impossible<Value, ValueError>;
370
371        fn serialize_str(self, v: &str) -> Result<Value, ValueError> {
372            Ok(Value::Str(v.to_owned()))
373        }
374
375        fn serialize_unit_variant(
376            self,
377            _name: &'static str,
378            _variant_index: u32,
379            variant: &'static str,
380        ) -> Result<Value, ValueError> {
381            Ok(Value::UnitVariant(variant))
382        }
383
384        fn serialize_struct(
385            self,
386            _name: &'static str,
387            len: usize,
388        ) -> Result<StructSerializer, ValueError> {
389            Ok(StructSerializer {
390                fields: Vec::with_capacity(len),
391            })
392        }
393
394        fn serialize_none(self) -> Result<Value, ValueError> {
395            Err(serde::ser::Error::custom(
396                "serialize_none is not supported by the in-test serde format",
397            ))
398        }
399
400        fn serialize_some<T: ?Sized + Serialize>(self, _value: &T) -> Result<Value, ValueError> {
401            Err(serde::ser::Error::custom(
402                "serialize_some is not supported by the in-test serde format",
403            ))
404        }
405
406        fn serialize_newtype_struct<T: ?Sized + Serialize>(
407            self,
408            _name: &'static str,
409            _value: &T,
410        ) -> Result<Value, ValueError> {
411            Err(serde::ser::Error::custom(
412                "serialize_newtype_struct is not supported by the in-test serde format",
413            ))
414        }
415
416        fn serialize_newtype_variant<T: ?Sized + Serialize>(
417            self,
418            _name: &'static str,
419            _variant_index: u32,
420            _variant: &'static str,
421            _value: &T,
422        ) -> Result<Value, ValueError> {
423            Err(serde::ser::Error::custom(
424                "serialize_newtype_variant is not supported by the in-test serde format",
425            ))
426        }
427
428        serialize_unsupported! {
429            serialize_bool(_v: bool) -> Value;
430            serialize_i8(_v: i8) -> Value;
431            serialize_i16(_v: i16) -> Value;
432            serialize_i32(_v: i32) -> Value;
433            serialize_i64(_v: i64) -> Value;
434            serialize_u8(_v: u8) -> Value;
435            serialize_u16(_v: u16) -> Value;
436            serialize_u32(_v: u32) -> Value;
437            serialize_u64(_v: u64) -> Value;
438            serialize_f32(_v: f32) -> Value;
439            serialize_f64(_v: f64) -> Value;
440            serialize_char(_v: char) -> Value;
441            serialize_bytes(_v: &[u8]) -> Value;
442            serialize_unit() -> Value;
443            serialize_unit_struct(_name: &'static str) -> Value;
444            serialize_seq(_len: Option<usize>) -> Self::SerializeSeq;
445            serialize_tuple(_len: usize) -> Self::SerializeTuple;
446            serialize_tuple_struct(_name: &'static str, _len: usize) -> Self::SerializeTupleStruct;
447            serialize_tuple_variant(_name: &'static str, _variant_index: u32, _variant: &'static str, _len: usize) -> Self::SerializeTupleVariant;
448            serialize_map(_len: Option<usize>) -> Self::SerializeMap;
449            serialize_struct_variant(_name: &'static str, _variant_index: u32, _variant: &'static str, _len: usize) -> Self::SerializeStructVariant;
450        }
451    }
452
453    struct StructSerializer {
454        fields: Vec<(&'static str, Value)>,
455    }
456
457    impl SerializeStruct for StructSerializer {
458        type Ok = Value;
459        type Error = ValueError;
460
461        fn serialize_field<T: ?Sized + Serialize>(
462            &mut self,
463            key: &'static str,
464            value: &T,
465        ) -> Result<(), ValueError> {
466            self.fields.push((key, value.serialize(ValueSerializer)?));
467            Ok(())
468        }
469
470        fn end(self) -> Result<Value, ValueError> {
471            Ok(Value::Struct(self.fields))
472        }
473    }
474
475    struct ValueDeserializer(Value);
476
477    impl<'de> Deserializer<'de> for ValueDeserializer {
478        type Error = ValueError;
479
480        fn deserialize_any<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, ValueError> {
481            match self.0 {
482                Value::Str(text) => visitor.visit_string(text),
483                Value::UnitVariant(variant) => visitor.visit_enum(UnitVariantAccess { variant }),
484                Value::Struct(fields) => visitor.visit_map(StructAccess {
485                    iter: fields.into_iter(),
486                    value: None,
487                }),
488            }
489        }
490
491        fn deserialize_struct<V: Visitor<'de>>(
492            self,
493            _name: &'static str,
494            _fields: &'static [&'static str],
495            visitor: V,
496        ) -> Result<V::Value, ValueError> {
497            self.deserialize_any(visitor)
498        }
499
500        fn deserialize_enum<V: Visitor<'de>>(
501            self,
502            _name: &'static str,
503            _variants: &'static [&'static str],
504            visitor: V,
505        ) -> Result<V::Value, ValueError> {
506            self.deserialize_any(visitor)
507        }
508
509        fn deserialize_str<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, ValueError> {
510            self.deserialize_any(visitor)
511        }
512
513        fn deserialize_string<V: Visitor<'de>>(self, visitor: V) -> Result<V::Value, ValueError> {
514            self.deserialize_any(visitor)
515        }
516
517        serde::forward_to_deserialize_any! {
518            bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char bytes
519            byte_buf option unit unit_struct newtype_struct seq tuple
520            tuple_struct map identifier ignored_any
521        }
522    }
523
524    struct StructAccess {
525        iter: std::vec::IntoIter<(&'static str, Value)>,
526        value: Option<Value>,
527    }
528
529    impl<'de> MapAccess<'de> for StructAccess {
530        type Error = ValueError;
531
532        fn next_key_seed<K: DeserializeSeed<'de>>(
533            &mut self,
534            seed: K,
535        ) -> Result<Option<K::Value>, ValueError> {
536            match self.iter.next() {
537                Some((key, value)) => {
538                    self.value = Some(value);
539                    let deserializer: StrDeserializer<'de, ValueError> = key.into_deserializer();
540                    seed.deserialize(deserializer).map(Some)
541                }
542                None => Ok(None),
543            }
544        }
545
546        fn next_value_seed<V: DeserializeSeed<'de>>(
547            &mut self,
548            seed: V,
549        ) -> Result<V::Value, ValueError> {
550            let value = self
551                .value
552                .take()
553                .ok_or_else(|| serde::de::Error::custom("struct field value missing"))?;
554            seed.deserialize(ValueDeserializer(value))
555        }
556    }
557
558    struct UnitVariantAccess {
559        variant: &'static str,
560    }
561
562    impl<'de> EnumAccess<'de> for UnitVariantAccess {
563        type Error = ValueError;
564        type Variant = Self;
565
566        fn variant_seed<V: DeserializeSeed<'de>>(
567            self,
568            seed: V,
569        ) -> Result<(V::Value, Self), ValueError> {
570            let deserializer: StrDeserializer<'de, ValueError> = self.variant.into_deserializer();
571            let value = seed.deserialize(deserializer)?;
572            Ok((value, self))
573        }
574    }
575
576    impl<'de> VariantAccess<'de> for UnitVariantAccess {
577        type Error = ValueError;
578
579        fn unit_variant(self) -> Result<(), ValueError> {
580            Ok(())
581        }
582
583        fn newtype_variant_seed<T: DeserializeSeed<'de>>(
584            self,
585            _seed: T,
586        ) -> Result<T::Value, ValueError> {
587            Err(serde::de::Error::custom("expected a unit variant"))
588        }
589
590        fn tuple_variant<V: Visitor<'de>>(
591            self,
592            _len: usize,
593            _visitor: V,
594        ) -> Result<V::Value, ValueError> {
595            Err(serde::de::Error::custom("expected a unit variant"))
596        }
597
598        fn struct_variant<V: Visitor<'de>>(
599            self,
600            _fields: &'static [&'static str],
601            _visitor: V,
602        ) -> Result<V::Value, ValueError> {
603            Err(serde::de::Error::custom("expected a unit variant"))
604        }
605    }
606
607    fn to_value<T: Serialize>(value: &T) -> Value {
608        value
609            .serialize(ValueSerializer)
610            .expect("test serializer failed")
611    }
612
613    fn from_value<T: for<'de> Deserialize<'de>>(value: Value) -> T {
614        T::deserialize(ValueDeserializer(value)).expect("test deserializer failed")
615    }
616
617    #[test]
618    fn codes_are_stable_unique_and_round_trippable() {
619        assert_eq!(ErrorCategory::ALL.len(), 20);
620        let codes: Vec<u32> = ErrorCategory::ALL
621            .iter()
622            .map(|category| category.code())
623            .collect();
624        // Declaration order must stay exactly 1..=20: stable, ordered, unique.
625        assert_eq!(codes, (1..=20).collect::<Vec<u32>>());
626        for category in ErrorCategory::ALL {
627            assert_eq!(ErrorCategory::from_code(category.code()), Some(category));
628        }
629        assert_eq!(ErrorCategory::from_code(0), None);
630        assert_eq!(ErrorCategory::from_code(21), None);
631    }
632
633    #[test]
634    fn serde_round_trip_preserves_every_category() {
635        for category in ErrorCategory::ALL {
636            let value = to_value(&category);
637            // Externally tagged unit variants serialize as their bare variant
638            // name; the names are the cross-language wire contract.
639            match &value {
640                Value::UnitVariant(name) => assert_eq!(*name, format!("{category:?}").as_str()),
641                other => panic!("expected unit variant, got {other:?}"),
642            }
643            assert_eq!(from_value::<ErrorCategory>(value), category);
644        }
645    }
646
647    #[test]
648    fn category_error_serde_round_trip() {
649        let original = CategoryError::new(
650            ErrorCategory::CommitOutcomeUnknown,
651            "commit epoch 42 lost contact after propose",
652        );
653        let value = to_value(&original);
654        match &value {
655            Value::Struct(fields) => {
656                assert_eq!(fields.len(), 2);
657                assert_eq!(fields[0].0, "category");
658                assert_eq!(fields[1].0, "message");
659            }
660            other => panic!("expected struct, got {other:?}"),
661        }
662        assert_eq!(from_value::<CategoryError>(value), original);
663    }
664
665    #[test]
666    fn display_is_human_readable() {
667        assert_eq!(ErrorCategory::NotLeader.to_string(), "not leader");
668        assert_eq!(
669            ErrorCategory::CommitOutcomeUnknown.to_string(),
670            "commit outcome unknown"
671        );
672        assert_eq!(
673            ErrorCategory::PermissionDenied.to_string(),
674            "permission denied"
675        );
676        let error = CategoryError::new(
677            ErrorCategory::PermissionDenied,
678            "principal \"alice\" lacks Admin",
679        );
680        assert_eq!(
681            error.to_string(),
682            "permission denied: principal \"alice\" lacks Admin"
683        );
684        assert_eq!(error.code(), ErrorCategory::PermissionDenied.code());
685    }
686
687    #[test]
688    fn name_matches_display_for_every_category() {
689        for category in ErrorCategory::ALL {
690            assert_eq!(category.name(), category.to_string());
691            assert!(!category.name().is_empty());
692            // Fixed-size FFI/JNI buffers (32) must fit every name + NUL.
693            assert!(category.name().len() < 32, "{category:?}");
694        }
695    }
696
697    #[test]
698    fn retry_classification_matches_spec_11_7() {
699        // Routing failures resolve through refreshed metadata (leader hints).
700        assert!(ErrorCategory::NotLeader.is_retryable());
701        assert_eq!(
702            ErrorCategory::NotLeader.retry_class(),
703            RetryClass::AfterMetadataRefresh
704        );
705        assert!(ErrorCategory::StaleMetadata.is_retryable());
706        assert_eq!(
707            ErrorCategory::StaleMetadata.retry_class(),
708            RetryClass::AfterMetadataRefresh
709        );
710        assert_eq!(
711            ErrorCategory::TabletMoved.retry_class(),
712            RetryClass::AfterMetadataRefresh
713        );
714        // Transient availability and resource pressure retry with backoff.
715        assert!(ErrorCategory::ReplicaUnavailable.is_retryable());
716        assert_eq!(
717            ErrorCategory::QuorumUnavailable.retry_class(),
718            RetryClass::AfterBackoff
719        );
720        assert_eq!(
721            ErrorCategory::ResourceExhausted.retry_class(),
722            RetryClass::AfterBackoff
723        );
724        // Transaction failures restart the whole transaction.
725        assert!(ErrorCategory::TransactionConflict.is_retryable());
726        assert_eq!(
727            ErrorCategory::Deadlock.retry_class(),
728            RetryClass::RetryTransaction
729        );
730        assert_eq!(
731            ErrorCategory::SerializationFailure.retry_class(),
732            RetryClass::RetryTransaction
733        );
734        // Section 11.7: never automatically replay an ambiguous write without
735        // a durable idempotency key.
736        assert!(!ErrorCategory::CommitOutcomeUnknown.is_retryable());
737        assert_eq!(
738            ErrorCategory::CommitOutcomeUnknown.retry_class(),
739            RetryClass::IdempotencyKeyRequired
740        );
741        // Terminal categories are never retried.
742        for category in [
743            ErrorCategory::DeadlineExceeded,
744            ErrorCategory::Cancelled,
745            ErrorCategory::ClusterVersionMismatch,
746            ErrorCategory::Unauthenticated,
747            ErrorCategory::PermissionDenied,
748        ] {
749            assert!(
750                !category.is_retryable(),
751                "{category:?} must not be retryable"
752            );
753            assert_eq!(category.retry_class(), RetryClass::Never);
754        }
755    }
756}