reason.rs (12450B)
1 //! Stable, bounded reason codes for status and health surfaces. 2 3 use core::{fmt, str::FromStr}; 4 5 use serde::{Deserialize, Deserializer, Serialize, Serializer}; 6 7 use super::StatusContractError; 8 9 pub const REASON_CODE_MAX_BYTES: usize = 64; 10 pub const REASON_CODES_MAX_ITEMS: usize = 32; 11 12 /// Shared reason codes whose meanings are service-neutral. 13 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 14 pub enum CommonReasonCode { 15 IdentityUnavailable, 16 DatabaseSchemaMismatch, 17 DatabaseReadOnly, 18 DatabaseLowDisk, 19 RequiredRelayUnavailable, 20 SubscriberNotActive, 21 SignerProviderUnavailable, 22 OutboxInvariantFailed, 23 PublicationBacklogExceeded, 24 AdminListenerFailed, 25 OperationsListenerFailed, 26 ShutdownInProgress, 27 } 28 29 impl CommonReasonCode { 30 #[must_use] 31 pub const fn as_str(self) -> &'static str { 32 match self { 33 Self::IdentityUnavailable => "identity_unavailable", 34 Self::DatabaseSchemaMismatch => "database_schema_mismatch", 35 Self::DatabaseReadOnly => "database_read_only", 36 Self::DatabaseLowDisk => "database_low_disk", 37 Self::RequiredRelayUnavailable => "required_relay_unavailable", 38 Self::SubscriberNotActive => "subscriber_not_active", 39 Self::SignerProviderUnavailable => "signer_provider_unavailable", 40 Self::OutboxInvariantFailed => "outbox_invariant_failed", 41 Self::PublicationBacklogExceeded => "publication_backlog_exceeded", 42 Self::AdminListenerFailed => "admin_listener_failed", 43 Self::OperationsListenerFailed => "operations_listener_failed", 44 Self::ShutdownInProgress => "shutdown_in_progress", 45 } 46 } 47 } 48 49 /// A validated stable status reason code. 50 #[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] 51 pub struct ReasonCode(String); 52 53 impl ReasonCode { 54 pub fn new(value: impl AsRef<str>) -> Result<Self, StatusContractError> { 55 let value = value.as_ref(); 56 if !valid_reason_code(value) { 57 return Err(StatusContractError::InvalidReasonCode); 58 } 59 Ok(Self(value.to_owned())) 60 } 61 62 fn from_string(value: String) -> Result<Self, StatusContractError> { 63 if !valid_reason_code(&value) { 64 return Err(StatusContractError::InvalidReasonCode); 65 } 66 Ok(Self(value)) 67 } 68 69 #[must_use] 70 pub fn as_str(&self) -> &str { 71 &self.0 72 } 73 } 74 75 impl From<CommonReasonCode> for ReasonCode { 76 fn from(value: CommonReasonCode) -> Self { 77 Self(value.as_str().to_owned()) 78 } 79 } 80 81 impl fmt::Display for ReasonCode { 82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 83 formatter.write_str(self.as_str()) 84 } 85 } 86 87 impl FromStr for ReasonCode { 88 type Err = StatusContractError; 89 90 fn from_str(value: &str) -> Result<Self, Self::Err> { 91 Self::new(value) 92 } 93 } 94 95 impl Serialize for ReasonCode { 96 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 97 where 98 S: Serializer, 99 { 100 serializer.serialize_str(self.as_str()) 101 } 102 } 103 104 impl<'de> Deserialize<'de> for ReasonCode { 105 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 106 where 107 D: Deserializer<'de>, 108 { 109 struct ReasonCodeVisitor; 110 111 impl<'de> serde::de::Visitor<'de> for ReasonCodeVisitor { 112 type Value = ReasonCode; 113 114 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 115 formatter.write_str("a bounded canonical reason code") 116 } 117 118 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> 119 where 120 E: serde::de::Error, 121 { 122 ReasonCode::new(value).map_err(E::custom) 123 } 124 125 fn visit_string<E>(self, value: String) -> Result<Self::Value, E> 126 where 127 E: serde::de::Error, 128 { 129 ReasonCode::from_string(value).map_err(E::custom) 130 } 131 } 132 133 deserializer.deserialize_str(ReasonCodeVisitor) 134 } 135 } 136 137 /// A unique, canonically sorted, bounded reason-code collection. 138 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] 139 #[serde(transparent)] 140 pub struct ReasonCodes(Vec<ReasonCode>); 141 142 impl ReasonCodes { 143 #[must_use] 144 pub const fn empty() -> Self { 145 Self(Vec::new()) 146 } 147 148 pub fn new(values: impl IntoIterator<Item = ReasonCode>) -> Result<Self, StatusContractError> { 149 let mut bounded = Vec::with_capacity(REASON_CODES_MAX_ITEMS); 150 for value in values.into_iter().take(REASON_CODES_MAX_ITEMS + 1) { 151 if bounded.len() == REASON_CODES_MAX_ITEMS { 152 return Err(StatusContractError::TooManyReasonCodes { 153 maximum: REASON_CODES_MAX_ITEMS, 154 }); 155 } 156 bounded.push(value); 157 } 158 bounded.sort_unstable(); 159 bounded.dedup(); 160 Ok(Self(bounded)) 161 } 162 163 #[must_use] 164 pub fn as_slice(&self) -> &[ReasonCode] { 165 &self.0 166 } 167 168 #[must_use] 169 pub fn is_empty(&self) -> bool { 170 self.0.is_empty() 171 } 172 } 173 174 impl<'de> Deserialize<'de> for ReasonCodes { 175 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 176 where 177 D: Deserializer<'de>, 178 { 179 struct ReasonCodesVisitor; 180 181 impl<'de> serde::de::Visitor<'de> for ReasonCodesVisitor { 182 type Value = ReasonCodes; 183 184 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 185 formatter.write_str("a bounded canonically sorted reason-code array") 186 } 187 188 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error> 189 where 190 A: serde::de::SeqAccess<'de>, 191 { 192 let mut values = Vec::with_capacity( 193 sequence 194 .size_hint() 195 .unwrap_or(0) 196 .min(REASON_CODES_MAX_ITEMS), 197 ); 198 while let Some(value) = sequence.next_element::<ReasonCode>()? { 199 if values.len() == REASON_CODES_MAX_ITEMS { 200 return Err(serde::de::Error::custom( 201 "reason-code collection exceeds its item limit", 202 )); 203 } 204 if values.last().is_some_and(|previous| previous >= &value) { 205 return Err(serde::de::Error::custom( 206 "reason codes must be unique and canonically sorted", 207 )); 208 } 209 values.push(value); 210 } 211 Ok(ReasonCodes(values)) 212 } 213 } 214 215 deserializer.deserialize_seq(ReasonCodesVisitor) 216 } 217 } 218 219 fn valid_reason_code(value: &str) -> bool { 220 let mut bytes = value.bytes(); 221 let Some(first) = bytes.next() else { 222 return false; 223 }; 224 value.len() <= REASON_CODE_MAX_BYTES 225 && first.is_ascii_lowercase() 226 && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') 227 } 228 229 #[cfg(test)] 230 mod tests { 231 use super::*; 232 233 #[test] 234 fn common_codes_are_stable_valid_and_unique() { 235 let codes = [ 236 CommonReasonCode::IdentityUnavailable, 237 CommonReasonCode::DatabaseSchemaMismatch, 238 CommonReasonCode::DatabaseReadOnly, 239 CommonReasonCode::DatabaseLowDisk, 240 CommonReasonCode::RequiredRelayUnavailable, 241 CommonReasonCode::SubscriberNotActive, 242 CommonReasonCode::SignerProviderUnavailable, 243 CommonReasonCode::OutboxInvariantFailed, 244 CommonReasonCode::PublicationBacklogExceeded, 245 CommonReasonCode::AdminListenerFailed, 246 CommonReasonCode::OperationsListenerFailed, 247 CommonReasonCode::ShutdownInProgress, 248 ]; 249 let reasons = ReasonCodes::new(codes.map(ReasonCode::from)).expect("common reasons"); 250 assert_eq!(reasons.as_slice().len(), codes.len()); 251 assert!(reasons.as_slice().windows(2).all(|pair| pair[0] < pair[1])); 252 } 253 254 #[test] 255 fn reason_code_validation_matches_frozen_contract() { 256 for valid in ["a", "database_low_disk", "myc_reason_01"] { 257 assert_eq!(ReasonCode::new(valid).unwrap().as_str(), valid); 258 } 259 assert!(ReasonCode::new("a".repeat(REASON_CODE_MAX_BYTES)).is_ok()); 260 261 for invalid in ["", "Upper", "1reason", "a-b", "a.b", "a b", "café"] { 262 assert_eq!( 263 ReasonCode::new(invalid), 264 Err(StatusContractError::InvalidReasonCode) 265 ); 266 } 267 assert!(ReasonCode::new("a".repeat(REASON_CODE_MAX_BYTES + 1)).is_err()); 268 269 let very_large = "a".repeat(4 * 1024 * 1024); 270 assert_eq!( 271 ReasonCode::new(&very_large), 272 Err(StatusContractError::InvalidReasonCode) 273 ); 274 let encoded = serde_json::to_string(&very_large).expect("large reason JSON"); 275 assert!(serde_json::from_str::<ReasonCode>(&encoded).is_err()); 276 } 277 278 #[test] 279 fn owned_text_traits_empty_state_and_deserializer_expectations_are_bound() { 280 let owned = ReasonCode::from_string("owned_reason".to_owned()).expect("owned reason"); 281 assert_eq!(owned.to_string(), "owned_reason"); 282 assert_eq!( 283 "parsed_reason".parse::<ReasonCode>().unwrap().as_str(), 284 "parsed_reason" 285 ); 286 assert_eq!( 287 ReasonCode::from_string("Invalid".to_owned()), 288 Err(StatusContractError::InvalidReasonCode) 289 ); 290 291 assert!(ReasonCodes::empty().is_empty()); 292 assert!(!ReasonCodes::new([owned]).unwrap().is_empty()); 293 assert!(serde_json::from_str::<ReasonCode>("42").is_err()); 294 assert!(serde_json::from_str::<ReasonCodes>(r#"{"reason":"owned_reason"}"#).is_err()); 295 } 296 297 #[test] 298 fn collections_sort_deduplicate_bound_and_serialize_canonically() { 299 let reasons = ReasonCodes::new([ 300 ReasonCode::new("z_reason").unwrap(), 301 ReasonCode::new("a_reason").unwrap(), 302 ReasonCode::new("z_reason").unwrap(), 303 ]) 304 .expect("bounded reasons"); 305 assert_eq!( 306 serde_json::to_string(&reasons).unwrap(), 307 r#"["a_reason","z_reason"]"# 308 ); 309 310 let over = (0..=REASON_CODES_MAX_ITEMS) 311 .map(|index| ReasonCode::new(format!("reason_{index:02}")).unwrap()); 312 assert_eq!( 313 ReasonCodes::new(over), 314 Err(StatusContractError::TooManyReasonCodes { 315 maximum: REASON_CODES_MAX_ITEMS 316 }) 317 ); 318 assert!(serde_json::from_str::<ReasonCodes>(r#"["z_reason","a_reason"]"#).is_err()); 319 assert!(serde_json::from_str::<ReasonCodes>(r#"["a_reason","a_reason"]"#).is_err()); 320 } 321 322 #[test] 323 fn collection_ingestion_stops_at_maximum_plus_one() { 324 use core::cell::Cell; 325 326 struct CountedInfinite<'a> { 327 calls: &'a Cell<usize>, 328 value: ReasonCode, 329 } 330 331 impl Iterator for CountedInfinite<'_> { 332 type Item = ReasonCode; 333 334 fn next(&mut self) -> Option<Self::Item> { 335 self.calls.set(self.calls.get() + 1); 336 Some(self.value.clone()) 337 } 338 } 339 340 let calls = Cell::new(0); 341 assert_eq!( 342 ReasonCodes::new(CountedInfinite { 343 calls: &calls, 344 value: ReasonCode::new("same_reason").unwrap(), 345 }), 346 Err(StatusContractError::TooManyReasonCodes { 347 maximum: REASON_CODES_MAX_ITEMS, 348 }) 349 ); 350 assert_eq!(calls.get(), REASON_CODES_MAX_ITEMS + 1); 351 352 let maximum = (0..REASON_CODES_MAX_ITEMS) 353 .map(|index| format!("reason_{index:02}")) 354 .collect::<Vec<_>>(); 355 let maximum_json = serde_json::to_string(&maximum).unwrap(); 356 assert_eq!( 357 serde_json::from_str::<ReasonCodes>(&maximum_json) 358 .unwrap() 359 .as_slice() 360 .len(), 361 REASON_CODES_MAX_ITEMS 362 ); 363 let over_maximum = (0..=REASON_CODES_MAX_ITEMS) 364 .map(|index| format!("reason_{index:02}")) 365 .collect::<Vec<_>>(); 366 assert!( 367 serde_json::from_str::<ReasonCodes>(&serde_json::to_string(&over_maximum).unwrap()) 368 .is_err() 369 ); 370 } 371 }