error.rs (31623B)
1 use std::io::ErrorKind; 2 3 use radroots_sdk::{RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkRecoveryAction}; 4 use serde_json::{Map, Value, json}; 5 6 use crate::out::envelope::{CliExitCode, OutputError}; 7 use crate::runtime::RuntimeError; 8 use crate::runtime::account::AccountRuntimeFailure; 9 use crate::runtime::sdk::CliSdkAdapterError; 10 use crate::view::runtime::CommandDisposition; 11 12 #[derive(Debug, thiserror::Error, PartialEq, Eq)] 13 pub enum OperationAdapterError { 14 #[error("unknown operation `{0}`")] 15 UnknownOperation(String), 16 #[error( 17 "operation `{operation_id}` registry request `{registry_request}` does not match adapter request `{adapter_request}`" 18 )] 19 RequestTypeMismatch { 20 operation_id: String, 21 registry_request: String, 22 adapter_request: String, 23 }, 24 #[error( 25 "operation `{operation_id}` registry result `{registry_result}` does not match adapter result `{adapter_result}`" 26 )] 27 ResultTypeMismatch { 28 operation_id: String, 29 registry_result: String, 30 adapter_result: String, 31 }, 32 #[error("failed to serialize operation result: {0}")] 33 Serialization(String), 34 #[error("invalid operation input for `{operation_id}`: {message}")] 35 InvalidInput { 36 operation_id: String, 37 message: String, 38 }, 39 #[error("resource not found for `{operation_id}`: {message}")] 40 NotFound { 41 operation_id: String, 42 message: String, 43 }, 44 #[error("validation failed for `{operation_id}`: {message}")] 45 ValidationFailed { 46 operation_id: String, 47 message: String, 48 }, 49 #[error("approval required for `{operation_id}`: {message}")] 50 ApprovalRequired { 51 operation_id: String, 52 message: String, 53 }, 54 #[error("operation `{operation_id}` is forbidden while offline: {message}")] 55 OfflineForbidden { 56 operation_id: String, 57 message: String, 58 }, 59 #[error("operation `{operation_id}` cannot run online: {message}")] 60 NetworkUnavailable { 61 operation_id: String, 62 message: String, 63 }, 64 #[error("account unresolved for `{operation_id}`: {message}")] 65 AccountUnresolved { 66 operation_id: String, 67 message: String, 68 }, 69 #[error("account is watch-only for `{operation_id}`: {message}")] 70 AccountWatchOnly { 71 operation_id: String, 72 message: String, 73 }, 74 #[error("account mismatch for `{operation_id}`: {message}")] 75 AccountMismatch { 76 operation_id: String, 77 message: String, 78 }, 79 #[error("signer unconfigured for `{operation_id}`: {message}")] 80 SignerUnconfigured { 81 operation_id: String, 82 message: String, 83 }, 84 #[error("signer unavailable for `{operation_id}`: {message}")] 85 SignerUnavailable { 86 operation_id: String, 87 message: String, 88 }, 89 #[error("signer mode deferred for `{operation_id}`: {message}")] 90 SignerModeDeferred { 91 operation_id: String, 92 message: String, 93 }, 94 #[error("provider unconfigured for `{operation_id}`: {message}")] 95 ProviderUnconfigured { 96 operation_id: String, 97 message: String, 98 }, 99 #[error("provider unavailable for `{operation_id}`: {message}")] 100 ProviderUnavailable { 101 operation_id: String, 102 message: String, 103 }, 104 #[error("operation `{operation_id}` is unavailable: {message}")] 105 OperationUnavailable { 106 operation_id: String, 107 message: String, 108 }, 109 #[error("operation `{operation_id}` is not implemented: {message}")] 110 NotImplemented { 111 operation_id: String, 112 message: String, 113 }, 114 #[error("operation `{operation_id}` failed: {message}")] 115 DetailedFailure { 116 operation_id: String, 117 code: String, 118 class: String, 119 message: String, 120 exit_code: CliExitCode, 121 detail_json: String, 122 }, 123 #[error("operation runtime error: {0}")] 124 Runtime(String), 125 } 126 127 impl OperationAdapterError { 128 pub fn approval_required(operation_id: &str) -> Self { 129 Self::ApprovalRequired { 130 operation_id: operation_id.to_owned(), 131 message: "missing required `approval_token` input".to_owned(), 132 } 133 } 134 135 pub fn from_command_disposition( 136 operation_id: &str, 137 disposition: CommandDisposition, 138 message: String, 139 ) -> Self { 140 match disposition { 141 CommandDisposition::Success => Self::Runtime(message), 142 CommandDisposition::NotFound => Self::NotFound { 143 operation_id: operation_id.to_owned(), 144 message, 145 }, 146 CommandDisposition::ValidationFailed => Self::ValidationFailed { 147 operation_id: operation_id.to_owned(), 148 message, 149 }, 150 CommandDisposition::Unconfigured => Self::unconfigured(operation_id, message), 151 CommandDisposition::ExternalUnavailable => Self::unavailable(operation_id, message), 152 CommandDisposition::Unsupported => Self::InvalidInput { 153 operation_id: operation_id.to_owned(), 154 message, 155 }, 156 CommandDisposition::InternalError => Self::Runtime(message), 157 } 158 } 159 160 pub fn unconfigured(operation_id: &str, message: String) -> Self { 161 classify_runtime_failure( 162 operation_id, 163 message, 164 RuntimeFailureAvailability::Unconfigured, 165 ) 166 } 167 168 pub fn operation_unavailable_with_detail( 169 operation_id: &str, 170 message: String, 171 detail: Value, 172 ) -> Self { 173 Self::DetailedFailure { 174 operation_id: operation_id.to_owned(), 175 code: "operation_unavailable".to_owned(), 176 class: "operation".to_owned(), 177 message, 178 exit_code: CliExitCode::RuntimeUnavailable, 179 detail_json: detail.to_string(), 180 } 181 } 182 183 pub fn not_found_with_detail(operation_id: &str, message: String, detail: Value) -> Self { 184 Self::DetailedFailure { 185 operation_id: operation_id.to_owned(), 186 code: "not_found".to_owned(), 187 class: "resource".to_owned(), 188 message, 189 exit_code: CliExitCode::NotFound, 190 detail_json: detail.to_string(), 191 } 192 } 193 194 pub fn not_implemented(operation_id: &str, message: String) -> Self { 195 Self::NotImplemented { 196 operation_id: operation_id.to_owned(), 197 message, 198 } 199 } 200 201 pub fn not_implemented_with_detail(operation_id: &str, message: String, detail: Value) -> Self { 202 Self::DetailedFailure { 203 operation_id: operation_id.to_owned(), 204 code: "not_implemented".to_owned(), 205 class: "operation".to_owned(), 206 message, 207 exit_code: CliExitCode::RuntimeUnavailable, 208 detail_json: detail.to_string(), 209 } 210 } 211 212 pub fn network_unavailable_with_detail( 213 operation_id: &str, 214 message: String, 215 detail: Value, 216 ) -> Self { 217 Self::DetailedFailure { 218 operation_id: operation_id.to_owned(), 219 code: "network_unavailable".to_owned(), 220 class: "network".to_owned(), 221 message, 222 exit_code: CliExitCode::SyncOrNetworkFailure, 223 detail_json: detail.to_string(), 224 } 225 } 226 227 pub fn validation_failed_with_detail( 228 operation_id: &str, 229 message: String, 230 detail: Value, 231 ) -> Self { 232 Self::DetailedFailure { 233 operation_id: operation_id.to_owned(), 234 code: "validation_failed".to_owned(), 235 class: "validation".to_owned(), 236 message, 237 exit_code: CliExitCode::ValidationFailed, 238 detail_json: detail.to_string(), 239 } 240 } 241 242 pub fn unavailable(operation_id: &str, message: String) -> Self { 243 classify_runtime_failure( 244 operation_id, 245 message, 246 RuntimeFailureAvailability::Unavailable, 247 ) 248 } 249 250 pub fn runtime_failure(operation_id: &str, error: RuntimeError) -> Self { 251 let message = error.to_string(); 252 let lowered = message.to_ascii_lowercase(); 253 match &error { 254 RuntimeError::Io(io_error) if io_error.kind() == ErrorKind::NotFound => { 255 Self::NotFound { 256 operation_id: operation_id.to_owned(), 257 message, 258 } 259 } 260 RuntimeError::Config(_) if looks_like_not_found(&lowered) => Self::NotFound { 261 operation_id: operation_id.to_owned(), 262 message, 263 }, 264 RuntimeError::Account(failure) => account_runtime_failure(operation_id, failure), 265 RuntimeError::Config(_) 266 if contains_any( 267 &lowered, 268 &[ 269 "no local account", 270 "account selector", 271 "account selection", 272 "account mismatch", 273 "did not match any local account", 274 "unresolved account", 275 ], 276 ) => 277 { 278 classify_runtime_failure( 279 operation_id, 280 message, 281 RuntimeFailureAvailability::Unconfigured, 282 ) 283 } 284 RuntimeError::Config(_) if looks_like_signer_failure(&lowered) => { 285 Self::SignerUnconfigured { 286 operation_id: operation_id.to_owned(), 287 message, 288 } 289 } 290 RuntimeError::Config(_) if looks_like_validation_failure(&lowered) => { 291 Self::ValidationFailed { 292 operation_id: operation_id.to_owned(), 293 message, 294 } 295 } 296 RuntimeError::Network(_) if looks_like_auth_failure(&lowered) => { 297 auth_runtime_failure(operation_id, message, &lowered) 298 } 299 RuntimeError::Network(_) if looks_like_signer_failure(&lowered) => { 300 Self::SignerUnavailable { 301 operation_id: operation_id.to_owned(), 302 message, 303 } 304 } 305 RuntimeError::Network(_) if looks_like_provider_failure(&lowered) => { 306 Self::ProviderUnavailable { 307 operation_id: operation_id.to_owned(), 308 message, 309 } 310 } 311 RuntimeError::Network(_) if looks_like_operation_failure(&lowered) => { 312 Self::OperationUnavailable { 313 operation_id: operation_id.to_owned(), 314 message, 315 } 316 } 317 RuntimeError::Network(_) => Self::NetworkUnavailable { 318 operation_id: operation_id.to_owned(), 319 message, 320 }, 321 RuntimeError::Accounts(_) => classify_runtime_failure( 322 operation_id, 323 message, 324 RuntimeFailureAvailability::Unavailable, 325 ), 326 _ => Self::Runtime(message), 327 } 328 } 329 330 pub fn sdk_adapter_failure(operation_id: &str, error: CliSdkAdapterError) -> Self { 331 match error { 332 CliSdkAdapterError::Runtime(error) => Self::runtime_failure(operation_id, error), 333 CliSdkAdapterError::Sdk(error) => Self::sdk_failure(operation_id, error), 334 } 335 } 336 337 pub fn sdk_failure(operation_id: &str, error: RadrootsSdkError) -> Self { 338 let code = error.code().to_owned(); 339 let class = sdk_error_class_name(error.class()).to_owned(); 340 let message = error.to_string(); 341 let exit_code = sdk_error_exit_code(error.class()); 342 let mut detail = error.detail_json(); 343 let actions = sdk_recovery_next_actions(operation_id, &error.recovery_actions()); 344 if !actions.is_empty() 345 && let Some(detail) = detail.as_object_mut() 346 { 347 detail.insert( 348 "actions".to_owned(), 349 Value::Array(actions.into_iter().map(Value::String).collect()), 350 ); 351 } 352 Self::DetailedFailure { 353 operation_id: operation_id.to_owned(), 354 code, 355 class, 356 message, 357 exit_code, 358 detail_json: detail.to_string(), 359 } 360 } 361 362 pub fn to_output_error(&self) -> OutputError { 363 match self { 364 Self::ApprovalRequired { message, .. } => OutputError::new( 365 "approval_required", 366 message.clone(), 367 CliExitCode::ApprovalRequiredOrDenied, 368 ), 369 Self::InvalidInput { message, .. } => { 370 OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput) 371 } 372 Self::NotFound { 373 operation_id, 374 message, 375 } => runtime_output_error( 376 "not_found", 377 operation_id, 378 "resource", 379 message, 380 CliExitCode::NotFound, 381 ), 382 Self::ValidationFailed { 383 operation_id, 384 message, 385 } => runtime_output_error( 386 "validation_failed", 387 operation_id, 388 "validation", 389 message, 390 CliExitCode::ValidationFailed, 391 ), 392 Self::OfflineForbidden { 393 operation_id, 394 message, 395 } => runtime_output_error( 396 "offline_forbidden", 397 operation_id, 398 "network", 399 message, 400 CliExitCode::SyncOrNetworkFailure, 401 ), 402 Self::NetworkUnavailable { 403 operation_id, 404 message, 405 } => runtime_output_error( 406 "network_unavailable", 407 operation_id, 408 "network", 409 message, 410 CliExitCode::SyncOrNetworkFailure, 411 ), 412 Self::AccountUnresolved { 413 operation_id, 414 message, 415 } => runtime_output_error( 416 "account_unresolved", 417 operation_id, 418 "account", 419 message, 420 CliExitCode::AuthorizationFailed, 421 ), 422 Self::AccountWatchOnly { 423 operation_id, 424 message, 425 } => runtime_output_error( 426 "account_watch_only", 427 operation_id, 428 "account", 429 message, 430 CliExitCode::SignerUnavailable, 431 ), 432 Self::AccountMismatch { 433 operation_id, 434 message, 435 } => runtime_output_error( 436 "account_mismatch", 437 operation_id, 438 "account", 439 message, 440 CliExitCode::AuthorizationFailed, 441 ), 442 Self::SignerUnconfigured { 443 operation_id, 444 message, 445 } => runtime_output_error( 446 "signer_unconfigured", 447 operation_id, 448 "signer", 449 message, 450 CliExitCode::SignerUnavailable, 451 ), 452 Self::SignerUnavailable { 453 operation_id, 454 message, 455 } => runtime_output_error( 456 "signer_unavailable", 457 operation_id, 458 "signer", 459 message, 460 CliExitCode::SignerUnavailable, 461 ), 462 Self::SignerModeDeferred { 463 operation_id, 464 message, 465 } => runtime_output_error( 466 "signer_mode_deferred", 467 operation_id, 468 "signer", 469 message, 470 CliExitCode::SignerUnavailable, 471 ), 472 Self::ProviderUnconfigured { 473 operation_id, 474 message, 475 } => runtime_output_error( 476 "provider_unconfigured", 477 operation_id, 478 "provider", 479 message, 480 CliExitCode::RuntimeUnavailable, 481 ), 482 Self::ProviderUnavailable { 483 operation_id, 484 message, 485 } => runtime_output_error( 486 "provider_unavailable", 487 operation_id, 488 "provider", 489 message, 490 CliExitCode::RuntimeUnavailable, 491 ), 492 Self::OperationUnavailable { 493 operation_id, 494 message, 495 } => runtime_output_error( 496 "operation_unavailable", 497 operation_id, 498 "operation", 499 message, 500 CliExitCode::RuntimeUnavailable, 501 ), 502 Self::NotImplemented { 503 operation_id, 504 message, 505 } => runtime_output_error( 506 "not_implemented", 507 operation_id, 508 "operation", 509 message, 510 CliExitCode::RuntimeUnavailable, 511 ), 512 Self::DetailedFailure { 513 operation_id, 514 code, 515 class, 516 message, 517 exit_code, 518 detail_json, 519 } => runtime_output_error_with_detail( 520 code.as_str(), 521 operation_id, 522 class, 523 message, 524 *exit_code, 525 detail_json, 526 ), 527 Self::UnknownOperation(operation_id) => OutputError::new( 528 "unknown_operation", 529 format!("unknown operation `{operation_id}`"), 530 CliExitCode::InvalidInput, 531 ), 532 Self::RequestTypeMismatch { .. } | Self::ResultTypeMismatch { .. } => OutputError::new( 533 "contract_mismatch", 534 self.to_string(), 535 CliExitCode::InternalError, 536 ), 537 Self::Serialization(message) => OutputError::new( 538 "serialization_failed", 539 message.clone(), 540 CliExitCode::InternalError, 541 ), 542 Self::Runtime(message) => { 543 OutputError::new("runtime_error", message.clone(), CliExitCode::InternalError) 544 } 545 } 546 } 547 } 548 549 fn sdk_error_exit_code(class: RadrootsSdkErrorClass) -> CliExitCode { 550 match class { 551 RadrootsSdkErrorClass::Authorization => CliExitCode::AuthorizationFailed, 552 RadrootsSdkErrorClass::Clock 553 | RadrootsSdkErrorClass::Configuration 554 | RadrootsSdkErrorClass::Request => CliExitCode::InvalidInput, 555 RadrootsSdkErrorClass::LocalMutation => CliExitCode::Conflict, 556 RadrootsSdkErrorClass::Storage => CliExitCode::RuntimeUnavailable, 557 RadrootsSdkErrorClass::Transport => CliExitCode::SyncOrNetworkFailure, 558 RadrootsSdkErrorClass::Unsupported => CliExitCode::RuntimeUnavailable, 559 _ => CliExitCode::InternalError, 560 } 561 } 562 563 fn sdk_error_class_name(class: RadrootsSdkErrorClass) -> &'static str { 564 match class { 565 RadrootsSdkErrorClass::Authorization => "authorization", 566 RadrootsSdkErrorClass::Clock => "clock", 567 RadrootsSdkErrorClass::Configuration => "configuration", 568 RadrootsSdkErrorClass::LocalMutation => "local_mutation", 569 RadrootsSdkErrorClass::Request => "request", 570 RadrootsSdkErrorClass::Storage => "storage", 571 RadrootsSdkErrorClass::Transport => "transport", 572 RadrootsSdkErrorClass::Unsupported => "unsupported", 573 _ => "internal", 574 } 575 } 576 577 fn sdk_recovery_next_actions( 578 operation_id: &str, 579 recovery_actions: &[RadrootsSdkRecoveryAction], 580 ) -> Vec<String> { 581 recovery_actions 582 .iter() 583 .filter_map(|action| match action { 584 RadrootsSdkRecoveryAction::RetryOutboxEnqueue 585 | RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey 586 | RadrootsSdkRecoveryAction::FixRequest => Some(operation_retry_action(operation_id)), 587 RadrootsSdkRecoveryAction::InspectLocalStores => { 588 Some("radroots store status get".to_owned()) 589 } 590 RadrootsSdkRecoveryAction::ConfigureRelayTargets => { 591 Some("radroots relay list".to_owned()) 592 } 593 RadrootsSdkRecoveryAction::SelectAuthorizedActor => { 594 Some("radroots account list".to_owned()) 595 } 596 RadrootsSdkRecoveryAction::RetryAfterTransportFailure => { 597 Some(operation_retry_action(operation_id)) 598 } 599 RadrootsSdkRecoveryAction::EnableRequiredFeature => { 600 Some("radroots health status get".to_owned()) 601 } 602 _ => None, 603 }) 604 .fold(Vec::new(), |mut actions, action| { 605 if !actions.contains(&action) { 606 actions.push(action); 607 } 608 actions 609 }) 610 } 611 612 fn operation_retry_action(operation_id: &str) -> String { 613 format!("radroots {}", operation_id.replace('.', " ")) 614 } 615 616 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 617 enum RuntimeFailureAvailability { 618 Unconfigured, 619 Unavailable, 620 } 621 622 fn account_runtime_failure( 623 operation_id: &str, 624 failure: &AccountRuntimeFailure, 625 ) -> OperationAdapterError { 626 let message = failure.message().to_owned(); 627 match failure { 628 AccountRuntimeFailure::Unresolved(_) => account_failure_output( 629 operation_id, 630 "account_unresolved", 631 message, 632 CliExitCode::AuthorizationFailed, 633 failure.detail_json(), 634 || OperationAdapterError::AccountUnresolved { 635 operation_id: operation_id.to_owned(), 636 message: failure.message().to_owned(), 637 }, 638 ), 639 AccountRuntimeFailure::WatchOnly(_) => account_failure_output( 640 operation_id, 641 "account_watch_only", 642 message, 643 CliExitCode::SignerUnavailable, 644 failure.detail_json(), 645 || OperationAdapterError::AccountWatchOnly { 646 operation_id: operation_id.to_owned(), 647 message: failure.message().to_owned(), 648 }, 649 ), 650 AccountRuntimeFailure::Mismatch(_) => account_failure_output( 651 operation_id, 652 "account_mismatch", 653 message, 654 CliExitCode::AuthorizationFailed, 655 failure.detail_json(), 656 || OperationAdapterError::AccountMismatch { 657 operation_id: operation_id.to_owned(), 658 message: failure.message().to_owned(), 659 }, 660 ), 661 } 662 } 663 664 fn account_failure_output( 665 operation_id: &str, 666 code: &str, 667 message: String, 668 exit_code: CliExitCode, 669 detail_json: Option<&str>, 670 fallback: impl FnOnce() -> OperationAdapterError, 671 ) -> OperationAdapterError { 672 match detail_json { 673 Some(detail_json) => OperationAdapterError::DetailedFailure { 674 operation_id: operation_id.to_owned(), 675 code: code.to_owned(), 676 class: "account".to_owned(), 677 message, 678 exit_code, 679 detail_json: detail_json.to_owned(), 680 }, 681 None => fallback(), 682 } 683 } 684 685 fn auth_runtime_failure( 686 operation_id: &str, 687 message: String, 688 lowered: &str, 689 ) -> OperationAdapterError { 690 let unauthorized = contains_any( 691 lowered, 692 &[ 693 "unauthorized", 694 "forbidden", 695 "permission denied", 696 "invalid token", 697 "bearer token rejected", 698 "http 401", 699 "http 403", 700 "status 401", 701 "status 403", 702 ], 703 ); 704 OperationAdapterError::DetailedFailure { 705 operation_id: operation_id.to_owned(), 706 code: if unauthorized { 707 "auth_unauthorized".to_owned() 708 } else { 709 "auth_unavailable".to_owned() 710 }, 711 class: "auth".to_owned(), 712 message, 713 exit_code: CliExitCode::AuthorizationFailed, 714 detail_json: Value::Null.to_string(), 715 } 716 } 717 718 fn classify_runtime_failure( 719 operation_id: &str, 720 message: String, 721 availability: RuntimeFailureAvailability, 722 ) -> OperationAdapterError { 723 let lowered = message.to_ascii_lowercase(); 724 if contains_any(&lowered, &["watch_only", "watch-only", "watch only"]) { 725 return OperationAdapterError::AccountWatchOnly { 726 operation_id: operation_id.to_owned(), 727 message, 728 }; 729 } 730 if contains_any(&lowered, &["account mismatch"]) { 731 return OperationAdapterError::AccountMismatch { 732 operation_id: operation_id.to_owned(), 733 message, 734 }; 735 } 736 if contains_any( 737 &lowered, 738 &[ 739 "no account", 740 "no local account", 741 "account selector", 742 "account selection", 743 "did not match any local account", 744 "unresolved account", 745 "selected account", 746 ], 747 ) { 748 return OperationAdapterError::AccountUnresolved { 749 operation_id: operation_id.to_owned(), 750 message, 751 }; 752 } 753 if contains_any( 754 &lowered, 755 &[ 756 "signer", 757 "sign_event", 758 "remote_nip46", 759 "nip46", 760 "secret-backed", 761 "secret backed", 762 ], 763 ) { 764 return match availability { 765 RuntimeFailureAvailability::Unconfigured => OperationAdapterError::SignerUnconfigured { 766 operation_id: operation_id.to_owned(), 767 message, 768 }, 769 RuntimeFailureAvailability::Unavailable => OperationAdapterError::SignerUnavailable { 770 operation_id: operation_id.to_owned(), 771 message, 772 }, 773 }; 774 } 775 if contains_any( 776 &lowered, 777 &[ 778 "provider", 779 "write-plane", 780 "write plane", 781 "radrootsd", 782 "bridge", 783 "rpc", 784 "daemon", 785 ], 786 ) { 787 return match availability { 788 RuntimeFailureAvailability::Unconfigured => { 789 OperationAdapterError::ProviderUnconfigured { 790 operation_id: operation_id.to_owned(), 791 message, 792 } 793 } 794 RuntimeFailureAvailability::Unavailable => OperationAdapterError::ProviderUnavailable { 795 operation_id: operation_id.to_owned(), 796 message, 797 }, 798 }; 799 } 800 OperationAdapterError::OperationUnavailable { 801 operation_id: operation_id.to_owned(), 802 message, 803 } 804 } 805 806 fn contains_any(value: &str, needles: &[&str]) -> bool { 807 needles.iter().any(|needle| value.contains(needle)) 808 } 809 810 fn looks_like_auth_failure(value: &str) -> bool { 811 contains_any( 812 value, 813 &[ 814 "authentication", 815 "bridge auth", 816 "authorization", 817 "authorize", 818 "unauthorized", 819 "forbidden", 820 "bearer token", 821 "invalid token", 822 "permission denied", 823 "status 401", 824 "status 403", 825 "http 401", 826 "http 403", 827 ], 828 ) 829 } 830 831 fn looks_like_signer_failure(value: &str) -> bool { 832 contains_any( 833 value, 834 &[ 835 "signer", 836 "sign_event", 837 "sign event", 838 "signer session", 839 "nip46", 840 "nip-46", 841 "remote_nip46", 842 ], 843 ) 844 } 845 846 fn looks_like_provider_failure(value: &str) -> bool { 847 contains_any( 848 value, 849 &[ 850 "provider unavailable", 851 "provider unconfigured", 852 "provider runtime", 853 "provider failed", 854 "radrootsd unavailable", 855 "daemon unavailable", 856 "proxy provider", 857 ], 858 ) 859 } 860 861 fn looks_like_operation_failure(value: &str) -> bool { 862 contains_any( 863 value, 864 &[ 865 "method not found", 866 "unknown method", 867 "unsupported method", 868 "unsupported operation", 869 "operation unavailable", 870 "operation disabled", 871 "publish proxy disabled", 872 "publish.event is disabled", 873 ], 874 ) 875 } 876 877 fn looks_like_not_found(value: &str) -> bool { 878 contains_any( 879 value, 880 &[ 881 "not found", 882 "no such file or directory", 883 "path not found", 884 "missing file", 885 ], 886 ) 887 } 888 889 fn looks_like_validation_failure(value: &str) -> bool { 890 contains_any( 891 value, 892 &[ 893 "invalid", 894 "parse ", 895 "parse:", 896 "must not", 897 "must be", 898 "validation", 899 "failed to import account", 900 ], 901 ) 902 } 903 904 fn runtime_output_error( 905 code: &str, 906 operation_id: &str, 907 class: &str, 908 message: &str, 909 exit_code: CliExitCode, 910 ) -> OutputError { 911 let mut error = OutputError::new(code, message.to_owned(), exit_code); 912 error.detail = Some(json!({ 913 "operation_id": operation_id, 914 "class": class, 915 })); 916 error 917 } 918 919 fn runtime_output_error_with_detail( 920 code: &str, 921 operation_id: &str, 922 class: &str, 923 message: &str, 924 exit_code: CliExitCode, 925 detail_json: &str, 926 ) -> OutputError { 927 let mut error = OutputError::new(code, message.to_owned(), exit_code); 928 let mut detail = serde_json::from_str::<Map<String, Value>>(detail_json).unwrap_or_default(); 929 detail.insert( 930 "operation_id".to_owned(), 931 Value::from(operation_id.to_owned()), 932 ); 933 detail.insert("class".to_owned(), Value::from(class.to_owned())); 934 error.detail = Some(Value::Object(detail)); 935 error 936 } 937 938 #[cfg(test)] 939 mod tests { 940 use super::*; 941 942 #[test] 943 fn sdk_storage_error_maps_to_typed_output_without_string_classification() { 944 let error = OperationAdapterError::sdk_failure( 945 "store.status.get", 946 RadrootsSdkError::EventStore { 947 message: "database is locked".to_owned(), 948 }, 949 ); 950 951 let output = error.to_output_error(); 952 953 assert_eq!(output.code, "event_store"); 954 assert_eq!(output.exit_code, CliExitCode::RuntimeUnavailable.code()); 955 let detail = output.detail.expect("detail"); 956 assert_eq!(detail["operation_id"], "store.status.get"); 957 assert_eq!(detail["class"], "storage"); 958 assert_eq!(detail["retryable"], true); 959 assert_eq!(detail["detail"]["message"], "database is locked"); 960 assert_eq!(detail["actions"], json!(["radroots store status get"])); 961 } 962 963 #[test] 964 fn sdk_request_error_maps_recovery_to_operation_retry_action() { 965 let error = OperationAdapterError::sdk_failure( 966 "listing.publish", 967 RadrootsSdkError::InvalidRequest { 968 message: "idempotency key must not contain boundary whitespace".to_owned(), 969 }, 970 ); 971 972 let output = error.to_output_error(); 973 974 assert_eq!(output.code, "invalid_request"); 975 assert_eq!(output.exit_code, CliExitCode::InvalidInput.code()); 976 let detail = output.detail.expect("detail"); 977 assert_eq!(detail["class"], "request"); 978 assert_eq!(detail["retryable"], false); 979 assert_eq!(detail["actions"], json!(["radroots listing publish"])); 980 } 981 }