commit 273b3a38a576c1aa53e64c9f88cbce095b9cdd75
parent 11fd92c5b56d89bb9fb82d298e08fa8a67756a02
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 23:29:40 +0000
ffi: define stable structured wire errors
- replace debug-formatted codes with explicit wire enums
- classify failures by category and retryability
- attach typed recovery actions and correlation fields
- preserve sanitized messages across native and Kotlin boundaries
Diffstat:
4 files changed, 178 insertions(+), 10 deletions(-)
diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs
@@ -14,7 +14,10 @@ use radroots_studio_application::{
use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp};
use radroots_studio_storage::{OsKeyringSecretStore, RuntimeActorHandle};
-use crate::{AccountDto, AppSnapshotDto};
+use crate::{
+ AccountDto, AppSnapshotDto, WireErrorCategory, WireErrorCode, WireRecoveryAction,
+ dto::error_policy,
+};
const DATABASE_QUALIFIER: &str = "org";
const DATABASE_ORGANIZATION: &str = "radroots";
@@ -56,7 +59,14 @@ pub fn compatibility_descriptor() -> CompatibilityDescriptor {
#[derive(Debug, uniffi::Error)]
pub enum StudioError {
- Failure { code: String, safe_message: String },
+ Failure {
+ code: WireErrorCode,
+ category: WireErrorCategory,
+ retryable: bool,
+ recovery_action: WireRecoveryAction,
+ correlation_id: Option<String>,
+ safe_message: String,
+ },
}
impl Display for StudioError {
@@ -71,8 +81,13 @@ impl std::error::Error for StudioError {}
impl From<SafeError> for StudioError {
fn from(error: SafeError) -> Self {
+ let (category, retryable, recovery_action) = error_policy(error.code());
Self::Failure {
- code: format!("{:?}", error.code()),
+ code: error.code().into(),
+ category,
+ retryable,
+ recovery_action,
+ correlation_id: None,
safe_message: error.message().as_str().to_owned(),
}
}
@@ -399,21 +414,33 @@ pub(crate) fn runtime() -> &'static tokio::runtime::Runtime {
fn path_unavailable() -> StudioError {
StudioError::Failure {
- code: "StorageUnavailable".to_owned(),
+ code: WireErrorCode::StorageUnavailable,
+ category: WireErrorCategory::Storage,
+ retryable: true,
+ recovery_action: WireRecoveryAction::RestartApplication,
+ correlation_id: None,
safe_message: "The application data directory is unavailable.".to_owned(),
}
}
fn confirmation_expired() -> StudioError {
StudioError::Failure {
- code: "InvalidApplicationState".to_owned(),
+ code: WireErrorCode::InvalidApplicationState,
+ category: WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: WireRecoveryAction::None,
+ correlation_id: None,
safe_message: "The account removal confirmation is no longer valid.".to_owned(),
}
}
fn compatibility_mismatch() -> StudioError {
StudioError::Failure {
- code: "CompatibilityMismatch".to_owned(),
+ code: WireErrorCode::CompatibilityMismatch,
+ category: WireErrorCategory::Compatibility,
+ retryable: false,
+ recovery_action: WireRecoveryAction::UpdateApplication,
+ correlation_id: None,
safe_message: "The application and native runtime are incompatible.".to_owned(),
}
}
diff --git a/crates/studio_ffi/src/dto.rs b/crates/studio_ffi/src/dto.rs
@@ -2,11 +2,61 @@ use radroots_studio_application::{
ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState,
SessionState,
};
-use radroots_studio_domain::{AccountSummary, BindingAvailability, ProfileMetadata, SafeError};
+use radroots_studio_domain::{
+ AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode,
+};
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum WireErrorCode {
+ InvalidPublicKey,
+ InvalidSecretKey,
+ InvalidAccountMetadata,
+ InvalidProfileMetadata,
+ InvalidApplicationState,
+ AccountAlreadyExists,
+ AccountNotFound,
+ KeyringUnavailable,
+ CredentialMissing,
+ StorageUnavailable,
+ StorageCorrupt,
+ PendingOperationRecoveryRequired,
+ InvalidRelayConfiguration,
+ RelayConnectionFailed,
+ ProfileRefreshFailed,
+ ObserverRegistrationFailed,
+ NativeLibraryLoadFailed,
+ CompatibilityMismatch,
+ Internal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum WireErrorCategory {
+ Input,
+ Conflict,
+ Credential,
+ Storage,
+ Network,
+ Lifecycle,
+ Compatibility,
+ Internal,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
+pub enum WireRecoveryAction {
+ None,
+ Retry,
+ RepairCredential,
+ CheckConfiguration,
+ RestartApplication,
+ UpdateApplication,
+}
#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)]
pub struct SafeErrorDto {
- pub code: String,
+ pub code: WireErrorCode,
+ pub category: WireErrorCategory,
+ pub retryable: bool,
+ pub recovery_action: WireRecoveryAction,
pub message: String,
}
@@ -182,13 +232,99 @@ impl From<&ProfileMetadata> for ProfileDto {
impl From<SafeError> for SafeErrorDto {
fn from(error: SafeError) -> Self {
+ let (category, retryable, recovery_action) = error_policy(error.code());
Self {
- code: format!("{:?}", error.code()),
+ code: error.code().into(),
+ category,
+ retryable,
+ recovery_action,
message: error.message().as_str().to_owned(),
}
}
}
+impl From<SafeErrorCode> for WireErrorCode {
+ fn from(code: SafeErrorCode) -> Self {
+ match code {
+ SafeErrorCode::InvalidPublicKey => Self::InvalidPublicKey,
+ SafeErrorCode::InvalidSecretKey => Self::InvalidSecretKey,
+ SafeErrorCode::InvalidAccountMetadata => Self::InvalidAccountMetadata,
+ SafeErrorCode::InvalidProfileMetadata => Self::InvalidProfileMetadata,
+ SafeErrorCode::InvalidApplicationState => Self::InvalidApplicationState,
+ SafeErrorCode::AccountAlreadyExists => Self::AccountAlreadyExists,
+ SafeErrorCode::AccountNotFound => Self::AccountNotFound,
+ SafeErrorCode::KeyringUnavailable => Self::KeyringUnavailable,
+ SafeErrorCode::CredentialMissing => Self::CredentialMissing,
+ SafeErrorCode::StorageUnavailable => Self::StorageUnavailable,
+ SafeErrorCode::StorageCorrupt => Self::StorageCorrupt,
+ SafeErrorCode::PendingOperationRecoveryRequired => {
+ Self::PendingOperationRecoveryRequired
+ }
+ SafeErrorCode::InvalidRelayConfiguration => Self::InvalidRelayConfiguration,
+ SafeErrorCode::RelayConnectionFailed => Self::RelayConnectionFailed,
+ SafeErrorCode::ProfileRefreshFailed => Self::ProfileRefreshFailed,
+ SafeErrorCode::ObserverRegistrationFailed => Self::ObserverRegistrationFailed,
+ SafeErrorCode::NativeLibraryLoadFailed => Self::NativeLibraryLoadFailed,
+ _ => Self::Internal,
+ }
+ }
+}
+
+pub(crate) const fn error_policy(
+ code: SafeErrorCode,
+) -> (WireErrorCategory, bool, WireRecoveryAction) {
+ match code {
+ SafeErrorCode::InvalidPublicKey
+ | SafeErrorCode::InvalidSecretKey
+ | SafeErrorCode::InvalidAccountMetadata
+ | SafeErrorCode::InvalidProfileMetadata => {
+ (WireErrorCategory::Input, false, WireRecoveryAction::None)
+ }
+ SafeErrorCode::AccountAlreadyExists | SafeErrorCode::AccountNotFound => {
+ (WireErrorCategory::Conflict, false, WireRecoveryAction::None)
+ }
+ SafeErrorCode::KeyringUnavailable => (
+ WireErrorCategory::Credential,
+ true,
+ WireRecoveryAction::Retry,
+ ),
+ SafeErrorCode::CredentialMissing => (
+ WireErrorCategory::Credential,
+ false,
+ WireRecoveryAction::RepairCredential,
+ ),
+ SafeErrorCode::StorageUnavailable => (
+ WireErrorCategory::Storage,
+ true,
+ WireRecoveryAction::RestartApplication,
+ ),
+ SafeErrorCode::StorageCorrupt | SafeErrorCode::PendingOperationRecoveryRequired => (
+ WireErrorCategory::Storage,
+ false,
+ WireRecoveryAction::RestartApplication,
+ ),
+ SafeErrorCode::InvalidRelayConfiguration => (
+ WireErrorCategory::Network,
+ false,
+ WireRecoveryAction::CheckConfiguration,
+ ),
+ SafeErrorCode::RelayConnectionFailed | SafeErrorCode::ProfileRefreshFailed => {
+ (WireErrorCategory::Network, true, WireRecoveryAction::Retry)
+ }
+ SafeErrorCode::InvalidApplicationState | SafeErrorCode::ObserverRegistrationFailed => (
+ WireErrorCategory::Lifecycle,
+ true,
+ WireRecoveryAction::Retry,
+ ),
+ SafeErrorCode::NativeLibraryLoadFailed => (
+ WireErrorCategory::Internal,
+ false,
+ WireRecoveryAction::RestartApplication,
+ ),
+ _ => (WireErrorCategory::Internal, false, WireRecoveryAction::None),
+ }
+}
+
impl From<BindingAvailability> for KeyAvailabilityDto {
fn from(value: BindingAvailability) -> Self {
match value {
diff --git a/crates/studio_ffi/src/lib.rs b/crates/studio_ffi/src/lib.rs
@@ -8,6 +8,7 @@ pub use commands::{GeneratedAccountDto, RemovalRequest, StudioAppCore, StudioErr
pub use dto::{
AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto,
ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto,
+ WireErrorCategory, WireErrorCode, WireRecoveryAction,
};
pub use observer::{ObserverSubscription, StudioObserver};
diff --git a/crates/studio_ffi/src/observer.rs b/crates/studio_ffi/src/observer.rs
@@ -116,7 +116,11 @@ impl StudioAppCore {
fn closed_error() -> StudioError {
StudioError::Failure {
- code: "InvalidApplicationState".to_owned(),
+ code: crate::WireErrorCode::InvalidApplicationState,
+ category: crate::WireErrorCategory::Lifecycle,
+ retryable: false,
+ recovery_action: crate::WireRecoveryAction::None,
+ correlation_id: None,
safe_message: "The application runtime is closed.".to_owned(),
}
}