1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//! Error types for GUARDIAN client operations.
use thiserror::Error;
/// A Result type alias for GUARDIAN client operations.
pub type ClientResult<T> = Result<T, ClientError>;
/// Errors that can occur when using the GUARDIAN client.
#[derive(Debug, Error)]
pub enum ClientError {
/// Failed to establish connection to the GUARDIAN server.
#[error("gRPC transport error: {0}")]
Transport(#[from] tonic::transport::Error),
/// The server returned a gRPC error status.
#[error("gRPC status error: {0}")]
Status(Box<tonic::Status>),
/// The server returned an application-level error.
#[error("Server returned error: {0}")]
ServerError(String),
/// Failed to serialize or deserialize JSON data.
#[error("JSON serialization error: {0}")]
Json(#[from] serde_json::Error),
/// The server response was invalid or unexpected.
#[error("Invalid response: {0}")]
InvalidResponse(String),
}
impl From<tonic::Status> for ClientError {
fn from(status: tonic::Status) -> Self {
ClientError::Status(Box::new(status))
}
}
impl ClientError {
/// Parse the structured `{ code, message, meta }` object Guardian attaches
/// to a gRPC `Status.details` (feature `009-human-readable-errors`).
/// Returns `None` for non-Status errors or Status errors without details.
fn guardian_error_details(&self) -> Option<serde_json::Value> {
match self {
ClientError::Status(status) => {
let details = status.details();
if details.is_empty() {
return None;
}
serde_json::from_slice(details).ok()
}
_ => None,
}
}
/// Stable, machine-readable Guardian error code (e.g. `account_paused`),
/// when this error originated from a Guardian gRPC `Status`. Branch on
/// this rather than on the message text.
pub fn guardian_code(&self) -> Option<String> {
self.guardian_error_details()?
.get("code")?
.as_str()
.map(str::to_owned)
}
/// Short, end-user-safe message safe to show in a wallet UI, when this
/// error originated from a Guardian gRPC `Status` carrying the structured
/// `{ code, message, meta }` details object. Returns `None` when the
/// details object is absent — a bare `Status.message` may be
/// developer-facing text (e.g. pre-service auth-metadata validation), so
/// callers substitute their own generic safe message instead.
pub fn user_message(&self) -> Option<String> {
self.guardian_error_details()
.and_then(|d| d.get("message")?.as_str().map(str::to_owned))
}
/// The structured `meta` block (`retryable`, `retry_after_secs`, …) from a
/// Guardian gRPC `Status`, when present.
pub fn guardian_meta(&self) -> Option<serde_json::Value> {
self.guardian_error_details()?.get("meta").cloned()
}
/// Whether this error means "the requested record does not exist". Handles
/// both the gRPC `Status` path (gRPC `NotFound`, feature 009) and the
/// legacy in-band `ServerError` message. Callers use this instead of
/// substring-matching the message text.
pub fn is_not_found(&self) -> bool {
match self {
ClientError::Status(status) => status.code() == tonic::Code::NotFound,
ClientError::ServerError(msg) => msg.contains("not found"),
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn guardian_status() -> tonic::Status {
// Mirrors the object the server attaches to Status.details (feature 009).
let details = serde_json::json!({
"code": "account_paused",
"message": "This account is paused and can't approve transactions right now.",
"meta": { "retryable": false }
})
.to_string()
.into_bytes();
tonic::Status::with_details(
tonic::Code::FailedPrecondition,
"This account is paused and can't approve transactions right now.",
details.into(),
)
}
#[test]
fn accessors_parse_guardian_status_details() {
let err: ClientError = guardian_status().into();
assert_eq!(err.guardian_code().as_deref(), Some("account_paused"));
assert_eq!(
err.user_message().as_deref(),
Some("This account is paused and can't approve transactions right now.")
);
assert_eq!(err.guardian_meta().unwrap()["retryable"], false);
assert!(!err.is_not_found());
}
#[test]
fn user_message_is_none_without_structured_details() {
// A bare Status.message may be developer-facing (e.g. pre-service
// auth-metadata validation); it must not be surfaced as user-safe.
let err: ClientError =
tonic::Status::new(tonic::Code::Unavailable, "raw internal detail").into();
assert_eq!(err.guardian_code(), None);
assert_eq!(err.user_message(), None);
}
#[test]
fn is_not_found_detects_grpc_not_found_and_legacy_message() {
let status: ClientError = tonic::Status::new(tonic::Code::NotFound, "x").into();
assert!(status.is_not_found());
assert!(ClientError::ServerError("Delta not found for account".into()).is_not_found());
assert!(!ClientError::ServerError("boom".into()).is_not_found());
}
}