lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

error.rs (7904B)


      1 //! Safe public errors with separately preserved internal sources.
      2 
      3 use core::fmt;
      4 use std::error::Error;
      5 
      6 /// Maximum byte length of every public host-error message.
      7 pub const MAX_SAFE_ERROR_MESSAGE_BYTES: usize = 96;
      8 
      9 /// Stable public error codes for host-mechanism failures.
     10 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     11 pub enum HostErrorCode {
     12     ConfigDocument,
     13     Lifecycle,
     14     AdminTransport,
     15     OperationsBind,
     16     OperationsServe,
     17     PathContext,
     18     TaskFailure,
     19     InvalidHostContract,
     20 }
     21 
     22 impl HostErrorCode {
     23     /// Returns the stable lowercase wire representation of this code.
     24     #[must_use]
     25     pub const fn as_str(self) -> &'static str {
     26         match self {
     27             Self::ConfigDocument => "host_config_document",
     28             Self::Lifecycle => "host_lifecycle",
     29             Self::AdminTransport => "host_admin_transport",
     30             Self::OperationsBind => "host_operations_bind",
     31             Self::OperationsServe => "host_operations_serve",
     32             Self::PathContext => "host_path_context",
     33             Self::TaskFailure => "host_task_failure",
     34             Self::InvalidHostContract => "invalid_host_contract",
     35         }
     36     }
     37 }
     38 
     39 impl fmt::Display for HostErrorCode {
     40     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     41         formatter.write_str(self.as_str())
     42     }
     43 }
     44 
     45 /// Service-neutral failure categories accepted by the host boundary.
     46 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     47 pub enum HostErrorKind {
     48     ConfigDocument,
     49     Lifecycle,
     50     AdminTransport,
     51     OperationsBind,
     52     OperationsServe,
     53     PathContext,
     54     TaskFailure,
     55     InvalidHostContract,
     56 }
     57 
     58 impl HostErrorKind {
     59     /// Returns the bounded representation safe for public boundaries.
     60     #[must_use]
     61     pub const fn safe_error(self) -> SafeHostError {
     62         match self {
     63             Self::ConfigDocument => SafeHostError::new(
     64                 HostErrorCode::ConfigDocument,
     65                 "service configuration document is invalid",
     66             ),
     67             Self::Lifecycle => SafeHostError::new(
     68                 HostErrorCode::Lifecycle,
     69                 "service lifecycle operation failed",
     70             ),
     71             Self::AdminTransport => SafeHostError::new(
     72                 HostErrorCode::AdminTransport,
     73                 "local administration transport failed",
     74             ),
     75             Self::OperationsBind => SafeHostError::new(
     76                 HostErrorCode::OperationsBind,
     77                 "operations listener could not be bound",
     78             ),
     79             Self::OperationsServe => SafeHostError::new(
     80                 HostErrorCode::OperationsServe,
     81                 "operations listener stopped unexpectedly",
     82             ),
     83             Self::PathContext => SafeHostError::new(
     84                 HostErrorCode::PathContext,
     85                 "service path context is invalid",
     86             ),
     87             Self::TaskFailure => SafeHostError::new(
     88                 HostErrorCode::TaskFailure,
     89                 "authoritative service task failed",
     90             ),
     91             Self::InvalidHostContract => SafeHostError::new(
     92                 HostErrorCode::InvalidHostContract,
     93                 "service host contract is invalid",
     94             ),
     95         }
     96     }
     97 }
     98 
     99 /// Error information safe for logs, status, metrics, and wire envelopes.
    100 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
    101 pub struct SafeHostError {
    102     code: HostErrorCode,
    103     message: &'static str,
    104 }
    105 
    106 impl SafeHostError {
    107     const fn new(code: HostErrorCode, message: &'static str) -> Self {
    108         assert!(message.len() <= MAX_SAFE_ERROR_MESSAGE_BYTES);
    109         Self { code, message }
    110     }
    111 
    112     /// Returns the typed public code.
    113     #[must_use]
    114     pub const fn code(self) -> HostErrorCode {
    115         self.code
    116     }
    117 
    118     /// Returns the stable string used to serialize the public code.
    119     #[must_use]
    120     pub const fn code_str(self) -> &'static str {
    121         self.code.as_str()
    122     }
    123 
    124     /// Returns the bounded public message.
    125     #[must_use]
    126     pub const fn message(self) -> &'static str {
    127         self.message
    128     }
    129 }
    130 
    131 impl fmt::Display for SafeHostError {
    132     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    133         write!(formatter, "{}: {}", self.code, self.message)
    134     }
    135 }
    136 
    137 /// A typed host failure whose public display never includes its internal cause.
    138 #[derive(Debug)]
    139 pub struct HostError {
    140     kind: HostErrorKind,
    141     source: Option<Box<dyn Error + Send + Sync + 'static>>,
    142 }
    143 
    144 impl HostError {
    145     /// Creates a host failure without an upstream cause.
    146     #[must_use]
    147     pub const fn new(kind: HostErrorKind) -> Self {
    148         Self { kind, source: None }
    149     }
    150 
    151     /// Creates a host failure while retaining its cause for trusted inspection.
    152     pub fn with_source(kind: HostErrorKind, source: impl Error + Send + Sync + 'static) -> Self {
    153         Self {
    154             kind,
    155             source: Some(Box::new(source)),
    156         }
    157     }
    158 
    159     /// Returns the service-neutral failure category.
    160     #[must_use]
    161     pub const fn kind(&self) -> HostErrorKind {
    162         self.kind
    163     }
    164 
    165     /// Returns the bounded representation safe for public boundaries.
    166     #[must_use]
    167     pub const fn safe_error(&self) -> SafeHostError {
    168         self.kind.safe_error()
    169     }
    170 }
    171 
    172 impl fmt::Display for HostError {
    173     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    174         self.safe_error().fmt(formatter)
    175     }
    176 }
    177 
    178 impl Error for HostError {
    179     fn source(&self) -> Option<&(dyn Error + 'static)> {
    180         self.source
    181             .as_deref()
    182             .map(|source| source as &(dyn Error + 'static))
    183     }
    184 }
    185 
    186 #[cfg(test)]
    187 mod tests {
    188     use super::*;
    189 
    190     #[derive(Debug)]
    191     struct SensitiveCause;
    192 
    193     impl fmt::Display for SensitiveCause {
    194         fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    195             formatter.write_str("secret upstream detail")
    196         }
    197     }
    198 
    199     impl Error for SensitiveCause {}
    200 
    201     #[test]
    202     fn display_is_safe_while_source_is_preserved() {
    203         let error = HostError::with_source(HostErrorKind::ConfigDocument, SensitiveCause);
    204 
    205         assert_eq!(
    206             error.to_string(),
    207             "host_config_document: service configuration document is invalid"
    208         );
    209         assert!(!error.to_string().contains("secret"));
    210         assert_eq!(
    211             error.source().map(ToString::to_string).as_deref(),
    212             Some("secret upstream detail")
    213         );
    214     }
    215 
    216     #[test]
    217     fn safe_codes_have_stable_serialization_helpers() {
    218         let expected = [
    219             (HostErrorKind::ConfigDocument, "host_config_document"),
    220             (HostErrorKind::Lifecycle, "host_lifecycle"),
    221             (HostErrorKind::AdminTransport, "host_admin_transport"),
    222             (HostErrorKind::OperationsBind, "host_operations_bind"),
    223             (HostErrorKind::OperationsServe, "host_operations_serve"),
    224             (HostErrorKind::PathContext, "host_path_context"),
    225             (HostErrorKind::TaskFailure, "host_task_failure"),
    226             (HostErrorKind::InvalidHostContract, "invalid_host_contract"),
    227         ];
    228 
    229         for (kind, code) in expected {
    230             let safe = kind.safe_error();
    231             assert_eq!(safe.code_str(), code);
    232             assert_eq!(safe.code().to_string(), code);
    233         }
    234     }
    235 
    236     #[test]
    237     fn all_public_messages_are_nonempty_bounded_and_fixed() {
    238         for kind in [
    239             HostErrorKind::ConfigDocument,
    240             HostErrorKind::Lifecycle,
    241             HostErrorKind::AdminTransport,
    242             HostErrorKind::OperationsBind,
    243             HostErrorKind::OperationsServe,
    244             HostErrorKind::PathContext,
    245             HostErrorKind::TaskFailure,
    246             HostErrorKind::InvalidHostContract,
    247         ] {
    248             let safe = kind.safe_error();
    249             assert!(!safe.message().is_empty());
    250             assert!(safe.message().len() <= MAX_SAFE_ERROR_MESSAGE_BYTES);
    251             assert!(safe.message().is_ascii());
    252         }
    253     }
    254 }