use thiserror::Error;
pub type ClientResult<T> = Result<T, ClientError>;
#[derive(Debug, Error)]
pub enum ClientError {
#[error("gRPC transport error: {0}")]
Transport(#[from] tonic::transport::Error),
#[error("gRPC status error: {0}")]
Status(Box<tonic::Status>),
#[error("Server returned error: {0}")]
ServerError(String),
#[error("JSON serialization error: {0}")]
Json(#[from] serde_json::Error),
#[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 {
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,
}
}
pub fn guardian_code(&self) -> Option<String> {
self.guardian_error_details()?
.get("code")?
.as_str()
.map(str::to_owned)
}
pub fn user_message(&self) -> Option<String> {
self.guardian_error_details()
.and_then(|d| d.get("message")?.as_str().map(str::to_owned))
}
pub fn guardian_meta(&self) -> Option<serde_json::Value> {
self.guardian_error_details()?.get("meta").cloned()
}
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,
}
}
pub fn is_retryable(&self) -> bool {
if let Some(meta) = self.guardian_meta()
&& let Some(retryable) = meta.get("retryable").and_then(|v| v.as_bool())
{
return retryable;
}
matches!(
self,
ClientError::Status(status) if status.code() == tonic::Code::ResourceExhausted
)
}
pub fn retry_after(&self) -> Option<std::time::Duration> {
if let ClientError::Status(status) = self
&& let Some(value) = status.metadata().get("retry-after")
&& let Ok(text) = value.to_str()
&& let Ok(secs) = text.trim().parse::<u64>()
{
return Some(std::time::Duration::from_secs(secs));
}
let secs = self.guardian_meta()?.get("retry_after_secs")?.as_u64()?;
Some(std::time::Duration::from_secs(secs))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn guardian_status() -> tonic::Status {
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() {
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());
}
#[test]
fn retry_classification_matches_shared_fixture() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../fixtures/guardian-client/rate-limit-policy.json"
);
let fixture: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
for case in fixture["cases"].as_array().unwrap() {
let name = case["name"].as_str().unwrap();
let grpc = &case["grpc"];
let code = tonic::Code::from_i32(grpc["code"].as_i64().unwrap() as i32);
let mut status = if case["body"].is_null() {
tonic::Status::new(code, "plain failure")
} else {
tonic::Status::with_details(
code,
"test",
case["body"].to_string().into_bytes().into(),
)
};
if let Some(hint) = grpc["retryAfterMetadata"].as_str() {
status.metadata_mut().insert(
"retry-after",
hint.parse().expect("fixture hints are ASCII"),
);
}
let err: ClientError = status.into();
assert_eq!(
err.is_retryable(),
case["expected"]["retryable"].as_bool().unwrap(),
"retryable mismatch: {name}"
);
assert_eq!(
err.retry_after(),
case["expected"]["retryAfterSecs"]
.as_u64()
.map(std::time::Duration::from_secs),
"retry hint mismatch: {name}"
);
}
}
}