field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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;