error.rs (22019B)
1 use tera_core::runtime::product_surface::{ 2 Phase1DraftError, Phase1InboundMediaError, ProfileMetadataError, SettingsError, TodayError, 3 }; 4 use thiserror::Error; 5 6 use crate::MOBILE_FFI_SCHEMA_VERSION; 7 8 #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] 9 pub struct TeraErrorRecord { 10 pub schema_version: u16, 11 pub code: String, 12 pub category: String, 13 pub retryable: bool, 14 pub recovery_actions: Vec<String>, 15 pub operation_id: Option<String>, 16 pub capability_id: Option<String>, 17 pub safe_message: String, 18 } 19 20 /// The only error envelope exported across the native language boundary. 21 #[derive(Debug, Error, uniffi::Error)] 22 pub enum TeraAppError { 23 #[error("radroots operation failed: {report:?}")] 24 Failure { report: TeraErrorRecord }, 25 } 26 27 impl TeraAppError { 28 pub(crate) fn initialization(_message: impl Into<String>) -> Self { 29 Self::failure( 30 "initialization_failed", 31 "initialization", 32 true, 33 &["retry"], 34 "The Tera runtime could not be initialized.", 35 ) 36 } 37 38 pub(crate) fn invalid_argument(code: impl Into<String>) -> Self { 39 Self::Failure { 40 report: TeraErrorRecord { 41 schema_version: MOBILE_FFI_SCHEMA_VERSION, 42 code: code.into(), 43 category: "validation".to_owned(), 44 retryable: false, 45 recovery_actions: vec!["correct_input".to_owned()], 46 operation_id: None, 47 capability_id: None, 48 safe_message: "The request is invalid.".to_owned(), 49 }, 50 } 51 } 52 53 pub(crate) fn failure( 54 code: &str, 55 category: &str, 56 retryable: bool, 57 recovery_actions: &[&str], 58 safe_message: &str, 59 ) -> Self { 60 Self::Failure { 61 report: TeraErrorRecord { 62 schema_version: MOBILE_FFI_SCHEMA_VERSION, 63 code: code.to_owned(), 64 category: category.to_owned(), 65 retryable, 66 recovery_actions: recovery_actions 67 .iter() 68 .map(|action| (*action).to_owned()) 69 .collect(), 70 operation_id: None, 71 capability_id: None, 72 safe_message: safe_message.to_owned(), 73 }, 74 } 75 } 76 77 pub fn report(&self) -> &TeraErrorRecord { 78 match self { 79 Self::Failure { report } => report, 80 } 81 } 82 83 pub(crate) fn with_operation_id(mut self, operation_id: String) -> Self { 84 match &mut self { 85 Self::Failure { report } => report.operation_id = Some(operation_id), 86 } 87 self 88 } 89 } 90 91 impl From<tera_core::TeraAppError> for TeraAppError { 92 fn from(error: tera_core::TeraAppError) -> Self { 93 match error { 94 tera_core::TeraAppError::Sdk { report } => Self::Failure { 95 report: TeraErrorRecord { 96 schema_version: report.schema_version, 97 code: report.code, 98 category: report.class, 99 retryable: report.retryable, 100 recovery_actions: report.recovery_actions, 101 operation_id: report.operation_id, 102 capability_id: report.capability_id, 103 safe_message: report.message, 104 }, 105 }, 106 tera_core::TeraAppError::Store { report } => Self::Failure { 107 report: TeraErrorRecord { 108 schema_version: report.schema_version, 109 code: report.code, 110 category: report.class, 111 retryable: report.retryable, 112 recovery_actions: report.recovery_actions, 113 operation_id: None, 114 capability_id: None, 115 safe_message: report.message, 116 }, 117 }, 118 tera_core::TeraAppError::Initialization(_) => Self::initialization("redacted"), 119 tera_core::TeraAppError::Runtime(_) => Self::failure( 120 "runtime_failed", 121 "runtime", 122 true, 123 &["retry"], 124 "The runtime operation failed.", 125 ), 126 tera_core::TeraAppError::Unsupported(_) => Self::failure( 127 "unsupported", 128 "capability", 129 false, 130 &[], 131 "The requested capability is unsupported.", 132 ), 133 tera_core::TeraAppError::Internal(_) => Self::failure( 134 "internal_failure", 135 "internal", 136 false, 137 &["restart"], 138 "An internal Tera error occurred.", 139 ), 140 } 141 } 142 } 143 144 impl From<TodayError> for TeraAppError { 145 fn from(error: TodayError) -> Self { 146 let (code, retryable, actions) = match error { 147 TodayError::Lifecycle(error) => return tera_core::TeraAppError::from(error).into(), 148 TodayError::InvalidRequest | TodayError::EventNotVisible => { 149 ("today_invalid_request", false, &["correct_input"][..]) 150 } 151 TodayError::ProjectionMissing | TodayError::SnapshotMissing => { 152 ("today_refresh_required", true, &["refresh"][..]) 153 } 154 TodayError::CursorPositionMissing | TodayError::Cursor(_) => { 155 ("today_cursor_invalid", true, &["restart_pagination"][..]) 156 } 157 TodayError::UnsupportedProjectionVersion => ( 158 "today_reader_unsupported", 159 false, 160 &["upgrade_application"][..], 161 ), 162 TodayError::RuntimeUnavailable => ("today_runtime_unavailable", true, &["retry"][..]), 163 TodayError::InboundMedia(Phase1InboundMediaError::SpaceInsufficient) => { 164 return Self::needs_space("today"); 165 } 166 TodayError::InboundMedia(error) => ( 167 tera_core::error::recovery::inbound_media_code(&error), 168 false, 169 &["review_media"][..], 170 ), 171 TodayError::InboundRetrieval(error) => { 172 if error.retryable() { 173 (error.code(), true, &["retry_media"][..]) 174 } else { 175 (error.code(), false, &["review_media"][..]) 176 } 177 } 178 TodayError::Storage(radroots_storage::Error::SpaceInsufficient) => { 179 return Self::needs_space("today"); 180 } 181 TodayError::Storage(_) => ("today_storage_failed", true, &["inspect_local_stores"][..]), 182 TodayError::CorruptProjection | TodayError::Serialization => { 183 ("today_state_failed", true, &["rebuild", "retry"][..]) 184 } 185 }; 186 Self::failure( 187 code, 188 "today", 189 retryable, 190 actions, 191 "The Today operation could not be completed.", 192 ) 193 } 194 } 195 196 impl From<Phase1DraftError> for TeraAppError { 197 fn from(error: Phase1DraftError) -> Self { 198 let (code, retryable, actions) = match error { 199 Phase1DraftError::Lifecycle(error) => { 200 return tera_core::TeraAppError::from(error).into(); 201 } 202 Phase1DraftError::IdentityUnavailable => ( 203 "identity_unavailable", 204 true, 205 &["unlock_identity", "retry"][..], 206 ), 207 Phase1DraftError::InvalidDraft => ("draft_invalid", false, &["correct_input"][..]), 208 Phase1DraftError::InvalidInventoryRequest => { 209 ("draft_inventory_invalid", false, &["correct_input"][..]) 210 } 211 Phase1DraftError::InvalidInventoryCursor => ( 212 "draft_inventory_cursor_invalid", 213 false, 214 &["reload_drafts"][..], 215 ), 216 Phase1DraftError::InvalidMedia => { 217 ("draft_media_invalid", false, &["replace_media"][..]) 218 } 219 Phase1DraftError::InvalidQueuePolicy => { 220 ("draft_queue_policy_invalid", false, &["correct_input"][..]) 221 } 222 Phase1DraftError::RevisionConflict => { 223 ("draft_revision_conflict", true, &["refresh"][..]) 224 } 225 Phase1DraftError::NotFound => ("draft_not_found", false, &[][..]), 226 Phase1DraftError::Terminal => ("draft_terminal", false, &[][..]), 227 Phase1DraftError::MediaNotReady => { 228 ("draft_media_not_ready", true, &["retry_media"][..]) 229 } 230 Phase1DraftError::OperationUnavailable => { 231 ("authoring_unavailable", true, &["retry"][..]) 232 } 233 Phase1DraftError::OperationInProgress => { 234 return Self::failure( 235 "operation_in_progress", 236 "operation", 237 true, 238 &["retry_operation_with_same_idempotency_key"], 239 "An authored transition is already in progress for this operation.", 240 ); 241 } 242 Phase1DraftError::SpaceInsufficient => return Self::needs_space("authoring"), 243 Phase1DraftError::Storage => ( 244 "authoring_storage_failed", 245 true, 246 &["inspect_local_stores"][..], 247 ), 248 Phase1DraftError::Overlay => ("authoring_overlay_failed", true, &["refresh"][..]), 249 Phase1DraftError::Operation => { 250 ("authoring_failed", true, &["retry", "inspect_outbox"][..]) 251 } 252 Phase1DraftError::Corrupt => ("draft_corrupt", false, &["recover_draft"][..]), 253 Phase1DraftError::ClockUnavailable => { 254 ("operation_clock_unavailable", true, &["retry"][..]) 255 } 256 Phase1DraftError::DeadlineOverflow => ("operation_deadline_overflow", false, &[][..]), 257 Phase1DraftError::NoWritableRelay => ( 258 "writable_relay_unavailable", 259 true, 260 &["configure_relay", "retry"][..], 261 ), 262 Phase1DraftError::InvalidRevision => { 263 ("revision_invalid", false, &["review_revision"][..]) 264 } 265 Phase1DraftError::Restore(error) => (error.code(), false, &["review_restore"][..]), 266 }; 267 Self::failure( 268 code, 269 "authoring", 270 retryable, 271 actions, 272 "The authored operation could not be completed.", 273 ) 274 } 275 } 276 277 impl From<SettingsError> for TeraAppError { 278 fn from(error: SettingsError) -> Self { 279 if let SettingsError::Lifecycle(error) = error { 280 return tera_core::TeraAppError::from(error).into(); 281 } 282 if matches!(error, SettingsError::SpaceInsufficient) { 283 return Self::needs_space("settings"); 284 } 285 let retryable = matches!( 286 error, 287 SettingsError::RevisionConflict | SettingsError::Storage 288 ); 289 let actions = if matches!(error, SettingsError::RevisionConflict) { 290 &["refresh"] as &[&str] 291 } else if retryable { 292 &["retry"] as &[&str] 293 } else { 294 &["correct_input"] as &[&str] 295 }; 296 Self::failure( 297 error.code(), 298 "settings", 299 retryable, 300 actions, 301 "The settings operation could not be completed.", 302 ) 303 } 304 } 305 306 impl From<ProfileMetadataError> for TeraAppError { 307 fn from(error: ProfileMetadataError) -> Self { 308 Self::failure( 309 error.code(), 310 "profile", 311 false, 312 &["correct_input"], 313 "The profile metadata is invalid.", 314 ) 315 } 316 } 317 318 impl TeraAppError { 319 pub(crate) fn needs_space(domain: &str) -> Self { 320 Self::failure( 321 "storage_space_insufficient", 322 domain, 323 false, 324 &[ 325 "free_reconstructable_cache", 326 "preserve_local_work", 327 "reconcile_original_operation", 328 ], 329 "Local storage space is insufficient. Preserve the original work and reconcile its state before retrying.", 330 ) 331 } 332 } 333 334 #[cfg(test)] 335 mod tests { 336 use super::*; 337 338 #[test] 339 fn mutation_admission_reports_the_existing_typed_recovery_contract() { 340 let error = TeraAppError::from(Phase1DraftError::OperationInProgress); 341 let report = error.report(); 342 assert_eq!(report.schema_version, MOBILE_FFI_SCHEMA_VERSION); 343 assert_eq!(report.code, "operation_in_progress"); 344 assert_eq!(report.category, "operation"); 345 assert!(report.retryable); 346 assert_eq!( 347 report.recovery_actions, 348 ["retry_operation_with_same_idempotency_key"] 349 ); 350 let recovery = tera_core::error::recovery::classify(report.schema_version, &report.code); 351 assert_eq!( 352 recovery.disposition, 353 tera_core::error::recovery::RecoveryDisposition::OutcomeUnknown 354 ); 355 } 356 357 #[test] 358 fn internal_core_messages_are_not_copied_to_the_ffi_record() { 359 let error = TeraAppError::from(tera_core::TeraAppError::internal( 360 "private/path/secret-value", 361 )); 362 assert_eq!(error.report().code, "internal_failure"); 363 assert!(!format!("{error:?}").contains("secret-value")); 364 assert!(!error.report().safe_message.contains("secret-value")); 365 } 366 367 #[test] 368 fn every_core_error_class_maps_to_a_versioned_redacted_record() { 369 let sdk = tera_core::TeraAppError::Sdk { 370 report: tera_core::SdkErrorRecord { 371 schema_version: 1, 372 code: "relay_unavailable".to_owned(), 373 class: "transport".to_owned(), 374 retryable: true, 375 recovery_actions: vec!["retry".to_owned()], 376 operation_id: Some("operation".to_owned()), 377 capability_id: Some("relay".to_owned()), 378 message: "Safe relay failure".to_owned(), 379 }, 380 }; 381 let sdk = TeraAppError::from(sdk); 382 assert_eq!(sdk.report().code, "relay_unavailable"); 383 assert_eq!(sdk.report().operation_id.as_deref(), Some("operation")); 384 385 let store = tera_core::TeraAppError::Store { 386 report: tera_core::StoreErrorRecord { 387 schema_version: 1, 388 code: "store_locked".to_owned(), 389 class: "storage".to_owned(), 390 retryable: true, 391 recovery_actions: vec!["unlock".to_owned()], 392 message: "Safe store failure".to_owned(), 393 }, 394 }; 395 let store = TeraAppError::from(store); 396 assert_eq!(store.report().code, "store_locked"); 397 assert!(store.report().operation_id.is_none()); 398 399 for (core, code, category) in [ 400 ( 401 tera_core::TeraAppError::initialization("secret"), 402 "initialization_failed", 403 "initialization", 404 ), 405 ( 406 tera_core::TeraAppError::runtime("secret"), 407 "runtime_failed", 408 "runtime", 409 ), 410 ( 411 tera_core::TeraAppError::unsupported("secret"), 412 "unsupported", 413 "capability", 414 ), 415 ( 416 tera_core::TeraAppError::internal("secret"), 417 "internal_failure", 418 "internal", 419 ), 420 ] { 421 let ffi = TeraAppError::from(core); 422 assert_eq!(ffi.report().code, code); 423 assert_eq!(ffi.report().category, category); 424 assert!(!ffi.report().safe_message.contains("secret")); 425 } 426 } 427 428 #[test] 429 fn today_and_draft_failures_have_stable_recovery_classes() { 430 for error in [ 431 TodayError::InvalidRequest, 432 TodayError::EventNotVisible, 433 TodayError::ProjectionMissing, 434 TodayError::SnapshotMissing, 435 TodayError::CursorPositionMissing, 436 TodayError::RuntimeUnavailable, 437 TodayError::CorruptProjection, 438 TodayError::Serialization, 439 ] { 440 let ffi = TeraAppError::from(error); 441 assert_eq!(ffi.report().category, "today"); 442 assert!(!ffi.report().safe_message.is_empty()); 443 } 444 445 for error in [ 446 Phase1DraftError::IdentityUnavailable, 447 Phase1DraftError::InvalidDraft, 448 Phase1DraftError::InvalidInventoryRequest, 449 Phase1DraftError::InvalidInventoryCursor, 450 Phase1DraftError::InvalidMedia, 451 Phase1DraftError::InvalidQueuePolicy, 452 Phase1DraftError::RevisionConflict, 453 Phase1DraftError::NotFound, 454 Phase1DraftError::Terminal, 455 Phase1DraftError::MediaNotReady, 456 Phase1DraftError::OperationUnavailable, 457 Phase1DraftError::Operation, 458 Phase1DraftError::Storage, 459 Phase1DraftError::Overlay, 460 Phase1DraftError::Corrupt, 461 Phase1DraftError::ClockUnavailable, 462 Phase1DraftError::DeadlineOverflow, 463 Phase1DraftError::NoWritableRelay, 464 Phase1DraftError::InvalidRevision, 465 ] { 466 let ffi = TeraAppError::from(error); 467 assert_eq!(ffi.report().category, "authoring"); 468 assert!(!ffi.report().safe_message.is_empty()); 469 } 470 } 471 472 #[test] 473 fn cursor_media_settings_and_profile_failures_cover_every_stable_class() { 474 use tera_core::runtime::product_surface::{ 475 CursorError, IdentitySettingsError, Phase1InboundMediaError, 476 }; 477 478 for cursor in [ 479 CursorError::InvalidContext, 480 CursorError::Malformed, 481 CursorError::Integrity, 482 CursorError::Version, 483 CursorError::ContextMismatch, 484 CursorError::SnapshotMismatch, 485 CursorError::Stale, 486 CursorError::InvalidPosition, 487 ] { 488 let error = TeraAppError::from(TodayError::Cursor(cursor)); 489 assert_eq!(error.report().code, "today_cursor_invalid"); 490 let recovery = crate::recovery::classify_error_recovery( 491 error.report().schema_version, 492 error.report().code.clone(), 493 ); 494 assert_eq!( 495 recovery.disposition, 496 crate::recovery::FfiRecoveryDisposition::StaleCursor 497 ); 498 } 499 for media in [ 500 Phase1InboundMediaError::InvalidReference, 501 Phase1InboundMediaError::InvalidDigest, 502 Phase1InboundMediaError::MissingDigest, 503 Phase1InboundMediaError::InvalidMediaType, 504 Phase1InboundMediaError::InvalidDimensions, 505 Phase1InboundMediaError::InvalidByteSize, 506 Phase1InboundMediaError::InvalidAlt, 507 Phase1InboundMediaError::MetadataMismatch, 508 Phase1InboundMediaError::InvalidOperation, 509 Phase1InboundMediaError::OperationMismatch, 510 Phase1InboundMediaError::InvalidFailure, 511 Phase1InboundMediaError::InvalidConfiguration, 512 Phase1InboundMediaError::ConfigurationMismatch, 513 Phase1InboundMediaError::InvalidVerificationTime, 514 Phase1InboundMediaError::InvalidCachePolicy, 515 Phase1InboundMediaError::InvalidCacheObservation, 516 Phase1InboundMediaError::CacheQuotaExceeded, 517 Phase1InboundMediaError::ArtifactCollision, 518 Phase1InboundMediaError::CorruptReceipt, 519 Phase1InboundMediaError::CorruptState, 520 Phase1InboundMediaError::UnsupportedSchema, 521 Phase1InboundMediaError::CacheUnavailable, 522 Phase1InboundMediaError::CacheIo, 523 Phase1InboundMediaError::CorruptArtifact, 524 ] { 525 let expected = tera_core::error::recovery::inbound_media_code(&media); 526 let error = TeraAppError::from(TodayError::InboundMedia(media)); 527 assert_eq!(error.report().code, expected); 528 } 529 for settings in [ 530 SettingsError::UnknownRelayAccess, 531 SettingsError::InvalidRelayEndpoint, 532 SettingsError::InvalidRelayEndpointCount, 533 SettingsError::DuplicateRelayEndpoint, 534 SettingsError::InvalidBlossomEndpoint, 535 SettingsError::InvalidBlossomEndpointCount, 536 SettingsError::NetworkEnvironmentMismatch, 537 SettingsError::InvalidMediaCacheBytes, 538 SettingsError::InvalidMediaCacheArtifacts, 539 SettingsError::RevisionConflict, 540 SettingsError::RevisionExhausted, 541 SettingsError::UnsupportedSchema, 542 SettingsError::CorruptDocument, 543 SettingsError::Storage, 544 SettingsError::Identity(IdentitySettingsError::InvalidIdentityId), 545 ] { 546 let expected_retryable = matches!( 547 settings, 548 SettingsError::RevisionConflict | SettingsError::Storage 549 ); 550 let error = TeraAppError::from(settings); 551 assert_eq!(error.report().retryable, expected_retryable); 552 } 553 for profile in [ 554 ProfileMetadataError::InvalidName, 555 ProfileMetadataError::InvalidDisplayName, 556 ProfileMetadataError::InvalidAbout, 557 ProfileMetadataError::InvalidNip05, 558 ] { 559 assert_eq!(TeraAppError::from(profile).report().category, "profile"); 560 } 561 assert_eq!( 562 TeraAppError::initialization("private").report().code, 563 "initialization_failed" 564 ); 565 assert_eq!( 566 TeraAppError::invalid_argument("bad") 567 .with_operation_id("operation".to_owned()) 568 .report() 569 .operation_id 570 .as_deref(), 571 Some("operation") 572 ); 573 } 574 } 575 576 #[cfg(test)] 577 #[path = "capacity_tests.rs"] 578 mod capacity_tests;