commit 8188a96d890912c5787f4b674b83db1326b9f4f1
parent 6f21438079eb92228cde2157ee5358e90478c2a4
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 10:45:14 +0000
sdk: replace duplicated SDK error metadata
- generate native descriptors from one SDK catalog
- reuse protocol code class retry and recovery authority
- preserve native sources while redacting protocol reports
- verify metadata uniqueness exhaustiveness and conversion
Diffstat:
2 files changed, 336 insertions(+), 45 deletions(-)
diff --git a/crates/sdk/src/client.rs b/crates/sdk/src/client.rs
@@ -120,9 +120,9 @@ impl ClientBuilder {
/// Validates the selected capabilities and creates a client handle.
pub fn build(self) -> Result<Client> {
- let storage = self.storage.ok_or(Error::MissingStorage)?;
+ let storage = self.storage.ok_or_else(Error::missing_storage)?;
if self.signer.is_some() && self.sink.is_none() {
- return Err(Error::SignerWithoutSink);
+ return Err(Error::signer_without_sink());
}
Ok(Client {
inner: Arc::new(ClientInner {
@@ -210,7 +210,7 @@ impl Client {
loop {
match self.inner.lifecycle.load(Ordering::Acquire) {
CLOSED => return Ok(()),
- CLOSING => return Err(Error::CloseInProgress),
+ CLOSING => return Err(Error::close_in_progress()),
state @ (OPEN | CLOSE_RETRY_REQUIRED) => {
if self
.inner
@@ -221,7 +221,7 @@ impl Client {
break;
}
}
- _ => return Err(Error::ClientClosed),
+ _ => return Err(Error::client_closed()),
}
}
@@ -230,14 +230,14 @@ impl Client {
attempt.complete();
close_result
.map(|_| ())
- .map_err(|_| Error::StorageCloseFailed)
+ .map_err(Error::storage_close_failed)
}
fn require_open(&self) -> Result<()> {
match self.inner.lifecycle.load(Ordering::Acquire) {
OPEN => Ok(()),
- CLOSING | CLOSE_RETRY_REQUIRED => Err(Error::ClientClosing),
- _ => Err(Error::ClientClosed),
+ CLOSING | CLOSE_RETRY_REQUIRED => Err(Error::client_closing()),
+ _ => Err(Error::client_closed()),
}
}
@@ -373,13 +373,13 @@ mod tests {
fn missing_storage_and_signer_without_sink_fail_closed() {
assert!(matches!(
ClientBuilder::new().build(),
- Err(Error::MissingStorage)
+ Err(error) if error.kind() == crate::error::ErrorKind::MissingStorage
));
assert!(matches!(
ClientBuilder::memory(generation())
.signer(Arc::new(TestSigner))
.build(),
- Err(Error::SignerWithoutSink)
+ Err(error) if error.kind() == crate::error::ErrorKind::SignerWithoutSink
));
}
@@ -427,8 +427,14 @@ mod tests {
block_on(client.close()).expect("first close");
assert!(clone.is_closed());
block_on(clone.close()).expect("repeated close");
- assert!(matches!(clone.storage(), Err(Error::ClientClosed)));
- assert!(matches!(client.source(), Err(Error::ClientClosed)));
+ assert!(matches!(
+ clone.storage(),
+ Err(error) if error.kind() == crate::error::ErrorKind::ClientClosed
+ ));
+ assert!(matches!(
+ client.source(),
+ Err(error) if error.kind() == crate::error::ErrorKind::ClientClosed
+ ));
}
#[test]
@@ -441,7 +447,10 @@ mod tests {
client.inner.lifecycle.store(CLOSING, Ordering::Release);
let attempt = CloseAttempt::new(Arc::clone(&client.inner));
drop(attempt);
- assert!(matches!(client.storage(), Err(Error::ClientClosing)));
+ assert!(matches!(
+ client.storage(),
+ Err(error) if error.kind() == crate::error::ErrorKind::ClientClosing
+ ));
block_on(client.close()).expect("retry close");
assert!(client.is_closed());
}
@@ -459,11 +468,14 @@ mod tests {
second_close.join().expect("second thread"),
]
});
- assert!(
- outcomes.iter().all(|outcome| {
- outcome.is_ok() || matches!(outcome, Err(Error::CloseInProgress))
- })
- );
+ assert!(outcomes.iter().all(|outcome| {
+ outcome.is_ok()
+ || matches!(
+ outcome,
+ Err(error)
+ if error.kind() == crate::error::ErrorKind::CloseInProgress
+ )
+ }));
if !client.is_closed() {
block_on(client.close()).expect("finish close");
}
diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs
@@ -1,42 +1,321 @@
-//! SDK-owned error boundary.
+//! SDK-owned native errors and secret-safe protocol conversion.
use std::{error, fmt};
+use radroots_protocol::{
+ error::v1::{
+ CapabilityId as ProtocolCapabilityId, Class, ErrorReport, KnownCode, RecoveryAction,
+ SafeDetails, SafeMessage,
+ },
+ runtime::v1::OperationId,
+};
+
+use crate::capability::CapabilityId;
+
+macro_rules! error_catalog {
+ ($(
+ $kind:ident => {
+ code: $code:ident,
+ operation: $operation:expr,
+ capability: $capability:expr,
+ message: $message:literal,
+ safe_detail_keys: [$($detail_key:literal),* $(,)?]
+ }
+ ),+ $(,)?) => {
+ /// Stable native SDK error category.
+ #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+ #[non_exhaustive]
+ pub enum ErrorKind {
+ $($kind,)+
+ }
+
+ impl ErrorKind {
+ /// Every SDK error category in catalog order.
+ pub const ALL: &'static [Self] = &[$(Self::$kind),+];
+
+ /// Returns metadata generated from the single SDK authority.
+ #[must_use]
+ pub const fn descriptor(self) -> ErrorDescriptor {
+ match self {
+ $(Self::$kind => ErrorDescriptor {
+ kind: Self::$kind,
+ code: KnownCode::$code,
+ operation: $operation,
+ capability: $capability,
+ message: $message,
+ safe_detail_keys: &[$($detail_key),*],
+ },)+
+ }
+ }
+ }
+
+ /// Complete SDK-native error metadata catalog.
+ pub const CATALOG: &[ErrorDescriptor] = &[
+ $(ErrorDescriptor {
+ kind: ErrorKind::$kind,
+ code: KnownCode::$code,
+ operation: $operation,
+ capability: $capability,
+ message: $message,
+ safe_detail_keys: &[$($detail_key),*],
+ },)+
+ ];
+ };
+}
+
+error_catalog! {
+ MissingStorage => {
+ code: MissingStorage,
+ operation: None,
+ capability: Some(CapabilityId::CANONICAL_STORAGE),
+ message: "SDK storage capability is not configured",
+ safe_detail_keys: []
+ },
+ SignerWithoutSink => {
+ code: SignerWithoutSink,
+ operation: None,
+ capability: None,
+ message: "SDK signer requires an outbound event sink",
+ safe_detail_keys: []
+ },
+ CloseInProgress => {
+ code: ClientCloseInProgress,
+ operation: None,
+ capability: Some(CapabilityId::CANONICAL_STORAGE),
+ message: "SDK client close is in progress",
+ safe_detail_keys: []
+ },
+ ClientClosing => {
+ code: ClientClosing,
+ operation: None,
+ capability: Some(CapabilityId::CANONICAL_STORAGE),
+ message: "SDK client close requires completion or retry",
+ safe_detail_keys: []
+ },
+ ClientClosed => {
+ code: ClientClosed,
+ operation: None,
+ capability: Some(CapabilityId::CANONICAL_STORAGE),
+ message: "SDK client is closed",
+ safe_detail_keys: []
+ },
+ StorageCloseFailed => {
+ code: StorageCloseFailed,
+ operation: None,
+ capability: Some(CapabilityId::CANONICAL_STORAGE),
+ message: "SDK storage close failed",
+ safe_detail_keys: []
+ },
+}
+
+/// Stable metadata for one native SDK failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-#[non_exhaustive]
-pub enum Error {
- /// No storage capability was supplied.
- MissingStorage,
- /// A signer cannot be selected without an outbound event sink.
- SignerWithoutSink,
- /// Another clone is actively closing shared resources.
- CloseInProgress,
- /// Close was cancelled after beginning and requires an explicit retry.
- ClientClosing,
- /// The client has completed explicit shutdown.
- ClientClosed,
- /// The storage close operation failed after shutdown began.
- StorageCloseFailed,
+pub struct ErrorDescriptor {
+ kind: ErrorKind,
+ code: KnownCode,
+ operation: Option<OperationId>,
+ capability: Option<CapabilityId>,
+ message: &'static str,
+ safe_detail_keys: &'static [&'static str],
+}
+
+impl ErrorDescriptor {
+ /// Returns the native category.
+ #[must_use]
+ pub const fn kind(self) -> ErrorKind {
+ self.kind
+ }
+
+ /// Returns the stable protocol code.
+ #[must_use]
+ pub const fn code(self) -> KnownCode {
+ self.code
+ }
+
+ /// Returns the class from the generated protocol authority.
+ #[must_use]
+ pub const fn class(self) -> Class {
+ self.code.descriptor().class
+ }
+
+ /// Returns retryability from the generated protocol authority.
+ #[must_use]
+ pub const fn retryable(self) -> bool {
+ self.code.descriptor().retryable
+ }
+
+ /// Returns recovery actions from the generated protocol authority.
+ #[must_use]
+ pub const fn recovery_actions(self) -> &'static [RecoveryAction] {
+ self.code.descriptor().recovery_actions
+ }
+
+ /// Returns the related operation when the protocol catalog defines one.
+ #[must_use]
+ pub const fn operation(self) -> Option<OperationId> {
+ self.operation
+ }
+
+ /// Returns the related runtime capability.
+ #[must_use]
+ pub const fn capability(self) -> Option<CapabilityId> {
+ self.capability
+ }
+
+ /// Returns the secret-safe native display message.
+ #[must_use]
+ pub const fn message(self) -> &'static str {
+ self.message
+ }
+
+ /// Returns the only structured detail keys permitted for this category.
+ #[must_use]
+ pub const fn safe_detail_keys(self) -> &'static [&'static str] {
+ self.safe_detail_keys
+ }
+}
+
+/// Native SDK failure retaining an optional private source chain.
+pub struct Error {
+ kind: ErrorKind,
+ source: Option<Box<dyn error::Error + Send + Sync>>,
+}
+
+impl Error {
+ pub(crate) fn missing_storage() -> Self {
+ Self::without_source(ErrorKind::MissingStorage)
+ }
+
+ pub(crate) fn signer_without_sink() -> Self {
+ Self::without_source(ErrorKind::SignerWithoutSink)
+ }
+
+ pub(crate) fn close_in_progress() -> Self {
+ Self::without_source(ErrorKind::CloseInProgress)
+ }
+
+ pub(crate) fn client_closing() -> Self {
+ Self::without_source(ErrorKind::ClientClosing)
+ }
+
+ pub(crate) fn client_closed() -> Self {
+ Self::without_source(ErrorKind::ClientClosed)
+ }
+
+ pub(crate) fn storage_close_failed(source: radroots_storage::Error) -> Self {
+ Self {
+ kind: ErrorKind::StorageCloseFailed,
+ source: Some(Box::new(source)),
+ }
+ }
+
+ fn without_source(kind: ErrorKind) -> Self {
+ Self { kind, source: None }
+ }
+
+ /// Returns the stable native category.
+ #[must_use]
+ pub const fn kind(&self) -> ErrorKind {
+ self.kind
+ }
+
+ /// Returns metadata from the single SDK catalog.
+ #[must_use]
+ pub const fn descriptor(&self) -> ErrorDescriptor {
+ self.kind.descriptor()
+ }
+
+ /// Converts to the V1 secret-safe protocol boundary.
+ ///
+ /// Native source messages are deliberately excluded. Catalog validation
+ /// tests guarantee that static messages and capability IDs are valid; this
+ /// conversion still fails closed to redacted text or no capability if a
+ /// future catalog edit violates those invariants.
+ #[must_use]
+ pub fn to_report(&self) -> ErrorReport {
+ let descriptor = self.descriptor();
+ let capability = descriptor
+ .capability()
+ .and_then(|id| ProtocolCapabilityId::parse(id.as_str().to_owned()).ok());
+ let message = SafeMessage::parse(descriptor.message().to_owned())
+ .unwrap_or_else(|_| SafeMessage::redacted());
+ ErrorReport::known(
+ descriptor.code(),
+ descriptor.operation(),
+ capability,
+ message,
+ SafeDetails::default(),
+ )
+ }
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::MissingStorage => formatter.write_str("SDK storage capability is missing"),
- Self::SignerWithoutSink => {
- formatter.write_str("SDK signer requires an outbound event sink")
- }
- Self::CloseInProgress => formatter.write_str("SDK client close is in progress"),
- Self::ClientClosing => {
- formatter.write_str("SDK client close requires completion or retry")
- }
- Self::ClientClosed => formatter.write_str("SDK client is closed"),
- Self::StorageCloseFailed => formatter.write_str("SDK storage close failed"),
- }
+ formatter.write_str(self.descriptor().message())
}
}
-impl error::Error for Error {}
+impl fmt::Debug for Error {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("Error")
+ .field("kind", &self.kind)
+ .field("code", &self.descriptor().code())
+ .finish_non_exhaustive()
+ }
+}
+
+impl error::Error for Error {
+ fn source(&self) -> Option<&(dyn error::Error + 'static)> {
+ self.source
+ .as_deref()
+ .map(|source| source as &(dyn error::Error + 'static))
+ }
+}
/// SDK result alias.
pub type Result<T> = std::result::Result<T, Error>;
+
+#[cfg(test)]
+mod tests {
+ use std::{collections::BTreeSet, error::Error as _};
+
+ use super::*;
+
+ #[test]
+ fn catalog_is_exhaustive_unique_and_protocol_valid() {
+ assert_eq!(CATALOG.len(), ErrorKind::ALL.len());
+ let mut kinds = BTreeSet::new();
+ let mut codes = BTreeSet::new();
+ for (index, descriptor) in CATALOG.iter().copied().enumerate() {
+ assert!(kinds.insert(descriptor.kind()));
+ assert!(codes.insert(descriptor.code().as_str()));
+ assert_eq!(descriptor.kind(), ErrorKind::ALL[index]);
+ assert_eq!(descriptor, descriptor.kind().descriptor());
+ assert!(!descriptor.recovery_actions().is_empty());
+ assert!(SafeMessage::parse(descriptor.message()).is_ok());
+ if let Some(capability) = descriptor.capability() {
+ assert!(ProtocolCapabilityId::parse(capability.as_str()).is_ok());
+ }
+ assert!(descriptor.safe_detail_keys().is_empty());
+ Error::without_source(descriptor.kind())
+ .to_report()
+ .validate()
+ .expect("protocol report");
+ }
+ }
+
+ #[test]
+ fn native_source_is_preserved_but_protocol_report_is_redacted_from_it() {
+ let error = Error::storage_close_failed(radroots_storage::Error::BackendUnavailable);
+ assert!(error.source().is_some());
+ assert_eq!(error.kind(), ErrorKind::StorageCloseFailed);
+ assert_eq!(error.to_string(), "SDK storage close failed");
+ let report = error.to_report();
+ assert_eq!(report.code().as_str(), "storage_close_failed");
+ assert_eq!(report.message().as_str(), "SDK storage close failed");
+ assert!(!report.message().as_str().contains("backend"));
+ assert!(format!("{error:?}").contains("StorageCloseFailed"));
+ assert!(!format!("{error:?}").contains("BackendUnavailable"));
+ }
+}