use crate::generated::pb;
pub(crate) const BRIDGE_ERROR_TRAILER: &str = "bridge-error-bin";
pub mod codes {
pub const UNKNOWN_GRAIN: &str = "unknown_grain";
pub const UNKNOWN_METHOD: &str = "unknown_method";
pub const INVALID_KEY: &str = "invalid_key";
pub const INVALID_PAYLOAD: &str = "invalid_payload";
pub const SERIALIZATION_ERROR: &str = "serialization_error";
pub const ORLEANS_REJECTION: &str = "orleans_rejection";
pub const ORLEANS_TIMEOUT: &str = "orleans_timeout";
pub const ORLEANS_UNAVAILABLE: &str = "orleans_unavailable";
pub const APPLICATION_ERROR: &str = "application_error";
pub const CANCELLED: &str = "cancelled";
pub const INTERNAL: &str = "internal";
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum OrleansError {
#[error("transport error: {0}")]
Transport(#[from] tonic::transport::Error),
#[error("grpc status: {0}")]
Status(#[from] tonic::Status),
#[error("serialization error: {0}")]
Serialization(String),
#[error("bridge error {code}: {message}")]
Bridge {
code: String,
message: String,
detail: Option<String>,
retryable: bool,
},
#[error("timeout")]
Timeout,
#[error("invalid configuration: {0}")]
InvalidConfig(String),
}
impl OrleansError {
pub(crate) fn from_status(status: tonic::Status) -> Self {
if let Some(bridge) = decode_bridge_error(&status) {
return OrleansError::Bridge {
code: bridge.code,
message: bridge.message,
detail: (!bridge.detail.is_empty()).then_some(bridge.detail),
retryable: bridge.retryable,
};
}
match status.code() {
tonic::Code::DeadlineExceeded => OrleansError::Timeout,
tonic::Code::Cancelled => OrleansError::Bridge {
code: codes::CANCELLED.to_owned(),
message: status.message().to_owned(),
detail: None,
retryable: false,
},
tonic::Code::Unavailable => OrleansError::Bridge {
code: codes::ORLEANS_UNAVAILABLE.to_owned(),
message: status.message().to_owned(),
detail: None,
retryable: true,
},
_ => OrleansError::Status(status),
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
OrleansError::Bridge { retryable, .. } => *retryable,
OrleansError::Status(status) => {
matches!(status.code(), tonic::Code::Unavailable)
}
OrleansError::Timeout => false,
_ => false,
}
}
#[must_use]
pub fn code(&self) -> Option<&str> {
match self {
OrleansError::Bridge { code, .. } => Some(code.as_str()),
_ => None,
}
}
}
fn decode_bridge_error(status: &tonic::Status) -> Option<pb::BridgeError> {
let value = status.metadata().get_bin(BRIDGE_ERROR_TRAILER)?;
let bytes = value.to_bytes().ok()?;
<pb::BridgeError as prost::Message>::decode(bytes).ok()
}
#[cfg(test)]
mod tests {
use prost::Message as _;
use tonic::metadata::MetadataValue;
use super::*;
fn status_with_bridge_error(error: &pb::BridgeError) -> tonic::Status {
let mut status = tonic::Status::new(tonic::Code::Unimplemented, "boom");
let bytes = error.encode_to_vec();
status
.metadata_mut()
.insert_bin(BRIDGE_ERROR_TRAILER, MetadataValue::from_bytes(&bytes));
status
}
#[test]
fn decodes_structured_trailer() {
let bridge = pb::BridgeError {
code: codes::UNKNOWN_METHOD.to_owned(),
message: "no such method".to_owned(),
detail: String::new(),
retryable: false,
};
let error = OrleansError::from_status(status_with_bridge_error(&bridge));
assert_eq!(error.code(), Some(codes::UNKNOWN_METHOD));
assert!(!error.is_retryable());
match error {
OrleansError::Bridge {
message, detail, ..
} => {
assert_eq!(message, "no such method");
assert_eq!(detail, None);
}
other => panic!("expected bridge error, got {other:?}"),
}
}
#[test]
fn retryable_trailer_is_retryable() {
let bridge = pb::BridgeError {
code: codes::ORLEANS_REJECTION.to_owned(),
message: "rejected".to_owned(),
detail: "overloaded".to_owned(),
retryable: true,
};
let error = OrleansError::from_status(status_with_bridge_error(&bridge));
assert!(error.is_retryable());
assert!(matches!(
error,
OrleansError::Bridge {
detail: Some(_),
..
}
));
}
#[test]
fn maps_bare_status_codes() {
let timeout = OrleansError::from_status(tonic::Status::deadline_exceeded("late"));
assert!(matches!(timeout, OrleansError::Timeout));
let unavailable = OrleansError::from_status(tonic::Status::unavailable("down"));
assert_eq!(unavailable.code(), Some(codes::ORLEANS_UNAVAILABLE));
assert!(unavailable.is_retryable());
let other = OrleansError::from_status(tonic::Status::internal("oops"));
assert!(matches!(other, OrleansError::Status(_)));
assert_eq!(other.code(), None);
}
#[test]
fn cancelled_status_maps_to_cancelled_code() {
let err = OrleansError::from_status(tonic::Status::cancelled("stop"));
assert_eq!(err.code(), Some(codes::CANCELLED));
assert!(!err.is_retryable());
}
#[test]
fn display_renders_each_variant() {
let bridge = OrleansError::Bridge {
code: "orleans_timeout".to_owned(),
message: "boom".to_owned(),
detail: None,
retryable: false,
};
assert_eq!(bridge.to_string(), "bridge error orleans_timeout: boom");
let status = OrleansError::Status(tonic::Status::internal("nope"));
assert!(status.to_string().starts_with("grpc status"));
assert!(
OrleansError::Serialization("bad".to_owned())
.to_string()
.contains("serialization error")
);
assert!(
OrleansError::InvalidConfig("x".to_owned())
.to_string()
.contains("invalid configuration")
);
}
#[test]
fn non_bridge_errors_have_no_code_and_are_not_retryable() {
let serialization = OrleansError::Serialization("bad".to_owned());
assert_eq!(serialization.code(), None);
assert!(!serialization.is_retryable());
assert!(serialization.to_string().contains("serialization error"));
let config = OrleansError::InvalidConfig("nope".to_owned());
assert_eq!(config.code(), None);
assert!(!config.is_retryable());
assert_eq!(OrleansError::Timeout.to_string(), "timeout");
assert!(!OrleansError::Timeout.is_retryable());
}
}