Skip to main content

orleans_rust_client/
error.rs

1//! Error types and the stable bridge error vocabulary.
2
3use crate::generated::pb;
4
5/// Metadata trailer key under which the bridge encodes a [`pb::BridgeError`]
6/// for non-OK responses. The `-bin` suffix marks it as binary gRPC metadata.
7pub(crate) const BRIDGE_ERROR_TRAILER: &str = "bridge-error-bin";
8
9/// Stable, machine-readable error codes returned by the bridge.
10///
11/// These strings are part of the bridge contract: clients may match on them.
12/// They are intentionally decoupled from gRPC status codes and from any
13/// particular .NET exception type.
14pub mod codes {
15    /// The target interface/grain type is not registered with the bridge.
16    pub const UNKNOWN_GRAIN: &str = "unknown_grain";
17    /// The grain exists but does not expose the requested method.
18    pub const UNKNOWN_METHOD: &str = "unknown_method";
19    /// The supplied key kind is not valid for the target grain.
20    pub const INVALID_KEY: &str = "invalid_key";
21    /// The request payload could not be interpreted under the declared codec.
22    pub const INVALID_PAYLOAD: &str = "invalid_payload";
23    /// A response value could not be serialized back to the caller.
24    pub const SERIALIZATION_ERROR: &str = "serialization_error";
25    /// Orleans rejected the message (e.g. overload, placement failure).
26    pub const ORLEANS_REJECTION: &str = "orleans_rejection";
27    /// The grain call exceeded its deadline.
28    pub const ORLEANS_TIMEOUT: &str = "orleans_timeout";
29    /// The cluster could not be reached.
30    pub const ORLEANS_UNAVAILABLE: &str = "orleans_unavailable";
31    /// The grain method threw an application exception.
32    pub const APPLICATION_ERROR: &str = "application_error";
33    /// The call was cancelled before completion.
34    pub const CANCELLED: &str = "cancelled";
35    /// An unexpected bridge-internal failure.
36    pub const INTERNAL: &str = "internal";
37}
38
39/// Errors returned by [`crate::OrleansClient`] and grain calls.
40#[derive(thiserror::Error, Debug)]
41#[non_exhaustive]
42pub enum OrleansError {
43    /// The gRPC channel could not be established.
44    #[error("transport error: {0}")]
45    Transport(#[from] tonic::transport::Error),
46
47    /// A transport-level gRPC status that did not carry structured bridge
48    /// error metadata.
49    #[error("grpc status: {0}")]
50    Status(#[from] tonic::Status),
51
52    /// A request or response payload could not be (de)serialized on the client
53    /// side.
54    #[error("serialization error: {0}")]
55    Serialization(String),
56
57    /// A structured, Orleans-level error reported by the bridge. The `code`
58    /// field is one of [`codes`].
59    #[error("bridge error {code}: {message}")]
60    Bridge {
61        /// Stable error code; see [`codes`].
62        code: String,
63        /// Human-readable description.
64        message: String,
65        /// Optional additional detail (only populated in dev mode).
66        detail: Option<String>,
67        /// Whether the caller may safely retry the request.
68        retryable: bool,
69    },
70
71    /// The call exceeded its client-side deadline.
72    #[error("timeout")]
73    Timeout,
74
75    /// The client was misconfigured.
76    #[error("invalid configuration: {0}")]
77    InvalidConfig(String),
78}
79
80impl OrleansError {
81    /// Convert a gRPC [`tonic::Status`] into an [`OrleansError`], decoding the
82    /// structured bridge error trailer when present.
83    pub(crate) fn from_status(status: tonic::Status) -> Self {
84        if let Some(bridge) = decode_bridge_error(&status) {
85            return OrleansError::Bridge {
86                code: bridge.code,
87                message: bridge.message,
88                detail: (!bridge.detail.is_empty()).then_some(bridge.detail),
89                retryable: bridge.retryable,
90            };
91        }
92
93        match status.code() {
94            tonic::Code::DeadlineExceeded => OrleansError::Timeout,
95            tonic::Code::Cancelled => OrleansError::Bridge {
96                code: codes::CANCELLED.to_owned(),
97                message: status.message().to_owned(),
98                detail: None,
99                retryable: false,
100            },
101            tonic::Code::Unavailable => OrleansError::Bridge {
102                code: codes::ORLEANS_UNAVAILABLE.to_owned(),
103                message: status.message().to_owned(),
104                detail: None,
105                retryable: true,
106            },
107            _ => OrleansError::Status(status),
108        }
109    }
110
111    /// Whether the failed call is safe to retry. Retryable bridge errors,
112    /// `Unavailable`, and bare timeouts are considered transient.
113    #[must_use]
114    pub fn is_retryable(&self) -> bool {
115        match self {
116            OrleansError::Bridge { retryable, .. } => *retryable,
117            OrleansError::Status(status) => {
118                matches!(status.code(), tonic::Code::Unavailable)
119            }
120            OrleansError::Timeout => false,
121            _ => false,
122        }
123    }
124
125    /// The stable error code, if this is a structured bridge error.
126    #[must_use]
127    pub fn code(&self) -> Option<&str> {
128        match self {
129            OrleansError::Bridge { code, .. } => Some(code.as_str()),
130            _ => None,
131        }
132    }
133}
134
135fn decode_bridge_error(status: &tonic::Status) -> Option<pb::BridgeError> {
136    let value = status.metadata().get_bin(BRIDGE_ERROR_TRAILER)?;
137    let bytes = value.to_bytes().ok()?;
138    <pb::BridgeError as prost::Message>::decode(bytes).ok()
139}
140
141#[cfg(test)]
142mod tests {
143    use prost::Message as _;
144    use tonic::metadata::MetadataValue;
145
146    use super::*;
147
148    fn status_with_bridge_error(error: &pb::BridgeError) -> tonic::Status {
149        let mut status = tonic::Status::new(tonic::Code::Unimplemented, "boom");
150        let bytes = error.encode_to_vec();
151        status
152            .metadata_mut()
153            .insert_bin(BRIDGE_ERROR_TRAILER, MetadataValue::from_bytes(&bytes));
154        status
155    }
156
157    #[test]
158    fn decodes_structured_trailer() {
159        let bridge = pb::BridgeError {
160            code: codes::UNKNOWN_METHOD.to_owned(),
161            message: "no such method".to_owned(),
162            detail: String::new(),
163            retryable: false,
164        };
165        let error = OrleansError::from_status(status_with_bridge_error(&bridge));
166        assert_eq!(error.code(), Some(codes::UNKNOWN_METHOD));
167        assert!(!error.is_retryable());
168        match error {
169            OrleansError::Bridge {
170                message, detail, ..
171            } => {
172                assert_eq!(message, "no such method");
173                assert_eq!(detail, None);
174            }
175            other => panic!("expected bridge error, got {other:?}"),
176        }
177    }
178
179    #[test]
180    fn retryable_trailer_is_retryable() {
181        let bridge = pb::BridgeError {
182            code: codes::ORLEANS_REJECTION.to_owned(),
183            message: "rejected".to_owned(),
184            detail: "overloaded".to_owned(),
185            retryable: true,
186        };
187        let error = OrleansError::from_status(status_with_bridge_error(&bridge));
188        assert!(error.is_retryable());
189        assert!(matches!(
190            error,
191            OrleansError::Bridge {
192                detail: Some(_),
193                ..
194            }
195        ));
196    }
197
198    #[test]
199    fn maps_bare_status_codes() {
200        let timeout = OrleansError::from_status(tonic::Status::deadline_exceeded("late"));
201        assert!(matches!(timeout, OrleansError::Timeout));
202
203        let unavailable = OrleansError::from_status(tonic::Status::unavailable("down"));
204        assert_eq!(unavailable.code(), Some(codes::ORLEANS_UNAVAILABLE));
205        assert!(unavailable.is_retryable());
206
207        let other = OrleansError::from_status(tonic::Status::internal("oops"));
208        assert!(matches!(other, OrleansError::Status(_)));
209        assert_eq!(other.code(), None);
210    }
211
212    #[test]
213    fn cancelled_status_maps_to_cancelled_code() {
214        let err = OrleansError::from_status(tonic::Status::cancelled("stop"));
215        assert_eq!(err.code(), Some(codes::CANCELLED));
216        assert!(!err.is_retryable());
217    }
218
219    #[test]
220    fn display_renders_each_variant() {
221        let bridge = OrleansError::Bridge {
222            code: "orleans_timeout".to_owned(),
223            message: "boom".to_owned(),
224            detail: None,
225            retryable: false,
226        };
227        assert_eq!(bridge.to_string(), "bridge error orleans_timeout: boom");
228
229        let status = OrleansError::Status(tonic::Status::internal("nope"));
230        assert!(status.to_string().starts_with("grpc status"));
231
232        assert!(
233            OrleansError::Serialization("bad".to_owned())
234                .to_string()
235                .contains("serialization error")
236        );
237        assert!(
238            OrleansError::InvalidConfig("x".to_owned())
239                .to_string()
240                .contains("invalid configuration")
241        );
242    }
243
244    #[test]
245    fn non_bridge_errors_have_no_code_and_are_not_retryable() {
246        let serialization = OrleansError::Serialization("bad".to_owned());
247        assert_eq!(serialization.code(), None);
248        assert!(!serialization.is_retryable());
249        assert!(serialization.to_string().contains("serialization error"));
250
251        let config = OrleansError::InvalidConfig("nope".to_owned());
252        assert_eq!(config.code(), None);
253        assert!(!config.is_retryable());
254
255        assert_eq!(OrleansError::Timeout.to_string(), "timeout");
256        assert!(!OrleansError::Timeout.is_retryable());
257    }
258}