Skip to main content

canton_core/
error.rs

1//! The SDK-wide error type and [`Result`] alias.
2
3/// The single error type for the whole Canton Rust SDK.
4///
5/// It is `#[non_exhaustive]` so new variants can be added without a breaking
6/// change. Large upstream error types are boxed so that `Result<T, Error>`
7/// stays cheap to move on the happy path.
8#[derive(Debug, thiserror::Error)]
9#[non_exhaustive]
10pub enum Error {
11    /// gRPC transport failure (DNS, TCP, TLS, HTTP/2). Retriable.
12    #[error("transport error")]
13    Transport(#[source] Box<tonic::transport::Error>),
14
15    /// A non-gRPC connection failure (e.g. an HTTP/JSON or token-endpoint
16    /// request that could not be sent). Retriable.
17    #[error("connection error: {0}")]
18    Connection(String),
19
20    /// The server returned a gRPC status. The full [`tonic::Status`] is kept so
21    /// callers can inspect the code, message, and metadata; see [`Error::code`].
22    #[error("grpc status {}: {}", .0.code(), .0.message())]
23    Status(#[source] Box<tonic::Status>),
24
25    /// A non-success HTTP response from the JSON API or a token endpoint.
26    /// Retriable for transient status codes (see [`Error::is_retriable`]).
27    #[error("http {status}: {body}")]
28    Http {
29        /// The HTTP status code.
30        status: u16,
31        /// The response body (truncated by the caller if large).
32        body: String,
33    },
34
35    /// JSON (de)serialization error.
36    #[error("json error: {0}")]
37    Json(#[source] Box<serde_json::Error>),
38
39    /// A command was rejected by the ledger for business/interpretation
40    /// reasons (as opposed to a transport failure). Not retriable.
41    #[error("command rejected ({code}): {message}")]
42    CommandRejected {
43        /// The rejection status code.
44        code: String,
45        /// The rejection message.
46        message: String,
47    },
48
49    /// Authentication/authorization was rejected (bad or expired credentials).
50    /// Not retriable — a token-transport failure surfaces as [`Error::Connection`]
51    /// or [`Error::Http`] instead.
52    #[error("authentication failed: {0}")]
53    Auth(String),
54
55    /// A request precondition or configuration value was invalid before send.
56    #[error("invalid request: {0}")]
57    InvalidRequest(String),
58
59    /// The server's response was well-formed at the transport level but not
60    /// what the protocol expects (e.g. a missing field, or a stream that ended
61    /// unexpectedly). Not a caller-input error.
62    #[error("unexpected response: {0}")]
63    UnexpectedResponse(String),
64
65    /// The operation exceeded its configured deadline. Retriable.
66    #[error("operation timed out")]
67    Timeout,
68}
69
70impl Error {
71    /// The gRPC status code, if this error originates from a gRPC status.
72    #[must_use]
73    pub fn code(&self) -> Option<tonic::Code> {
74        match self {
75            Error::Status(status) => Some(status.code()),
76            _ => None,
77        }
78    }
79
80    /// Whether retrying the operation may succeed.
81    ///
82    /// Transient conditions are retriable: timeouts, transport/connection
83    /// failures, the transient gRPC codes (`Unavailable`, `DeadlineExceeded`,
84    /// `ResourceExhausted`, `Aborted`), and transient HTTP status codes
85    /// (408, 429, 5xx). Everything else — invalid input, auth rejection,
86    /// command rejection, `NotFound`/`AlreadyExists`, deserialization — is not.
87    #[must_use]
88    pub fn is_retriable(&self) -> bool {
89        use tonic::Code::{Aborted, DeadlineExceeded, ResourceExhausted, Unavailable};
90        match self {
91            Error::Timeout | Error::Transport(_) | Error::Connection(_) => true,
92            Error::Status(status) => matches!(
93                status.code(),
94                Unavailable | DeadlineExceeded | ResourceExhausted | Aborted
95            ),
96            Error::Http { status, .. } => matches!(status, 408 | 429 | 500 | 502 | 503 | 504),
97            _ => false,
98        }
99    }
100
101    /// The structured `google.rpc.ErrorInfo` carried by a gRPC status, when
102    /// present. Canton populates this with the machine-readable error `reason`
103    /// (e.g. `DUPLICATE_COMMAND`) plus context `metadata` — prefer it over
104    /// string-matching [`Display`](std::fmt::Display) output. Returns `None` for
105    /// non-status errors or statuses without an `ErrorInfo` detail.
106    #[must_use]
107    pub fn error_info(&self) -> Option<ErrorInfo> {
108        match self {
109            Error::Status(status) => {
110                use tonic_types::StatusExt as _;
111                status
112                    .get_error_details()
113                    .error_info()
114                    .map(|info| ErrorInfo {
115                        reason: info.reason.clone(),
116                        domain: info.domain.clone(),
117                        metadata: info.metadata.clone(),
118                    })
119            }
120            _ => None,
121        }
122    }
123}
124
125/// Structured `google.rpc.ErrorInfo` details from a gRPC status: the machine-
126/// readable `reason`, its `domain`, and error `metadata`. `#[non_exhaustive]`.
127#[derive(Clone, Debug, Default, PartialEq, Eq)]
128#[non_exhaustive]
129pub struct ErrorInfo {
130    /// Machine-readable error reason (e.g. a Canton/Daml error code).
131    pub reason: String,
132    /// The logical grouping the `reason` belongs to.
133    pub domain: String,
134    /// Additional structured context for the error.
135    pub metadata: std::collections::HashMap<String, String>,
136}
137
138impl From<tonic::Status> for Error {
139    fn from(status: tonic::Status) -> Self {
140        Error::Status(Box::new(status))
141    }
142}
143
144impl From<tonic::transport::Error> for Error {
145    fn from(err: tonic::transport::Error) -> Self {
146        Error::Transport(Box::new(err))
147    }
148}
149
150impl From<serde_json::Error> for Error {
151    fn from(err: serde_json::Error) -> Self {
152        Error::Json(Box::new(err))
153    }
154}
155
156/// SDK-wide result alias. Re-exported by the facade as `canton::Result`.
157pub type Result<T, E = Error> = std::result::Result<T, E>;
158
159#[cfg(test)]
160#[allow(clippy::unwrap_used, clippy::expect_used)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn error_info_is_extracted_from_a_status_and_absent_otherwise() {
166        use tonic_types::{ErrorDetails, StatusExt as _};
167
168        let mut metadata = std::collections::HashMap::new();
169        metadata.insert("resource".to_string(), "contract-1".to_string());
170        let details = ErrorDetails::with_error_info("DUPLICATE_COMMAND", "canton", metadata);
171        let status = tonic::Status::with_error_details(tonic::Code::AlreadyExists, "dup", details);
172
173        let info = Error::from(status)
174            .error_info()
175            .expect("error info present");
176        assert_eq!(info.reason, "DUPLICATE_COMMAND");
177        assert_eq!(info.domain, "canton");
178        assert_eq!(
179            info.metadata.get("resource").map(String::as_str),
180            Some("contract-1")
181        );
182
183        // A status without ErrorInfo, and a non-status error, yield None.
184        assert!(
185            Error::from(tonic::Status::not_found("x"))
186                .error_info()
187                .is_none()
188        );
189        assert!(Error::Timeout.error_info().is_none());
190    }
191
192    #[test]
193    fn transient_conditions_are_retriable() {
194        assert!(Error::Timeout.is_retriable());
195        assert!(Error::Connection("reset".to_string()).is_retriable());
196        assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
197        assert!(Error::from(tonic::Status::deadline_exceeded("x")).is_retriable());
198        assert!(Error::from(tonic::Status::resource_exhausted("x")).is_retriable());
199        assert!(Error::from(tonic::Status::aborted("x")).is_retriable());
200    }
201
202    #[test]
203    fn transient_http_codes_are_retriable_but_client_codes_are_not() {
204        for status in [408, 429, 500, 502, 503, 504] {
205            assert!(
206                Error::Http {
207                    status,
208                    body: String::new()
209                }
210                .is_retriable(),
211                "http {status} should be retriable"
212            );
213        }
214        for status in [400, 401, 403, 404, 409] {
215            assert!(
216                !Error::Http {
217                    status,
218                    body: String::new()
219                }
220                .is_retriable(),
221                "http {status} should not be retriable"
222            );
223        }
224    }
225
226    #[test]
227    fn definite_failures_are_not_retriable() {
228        assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
229        assert!(!Error::from(tonic::Status::already_exists("dup")).is_retriable());
230        assert!(!Error::from(tonic::Status::invalid_argument("x")).is_retriable());
231        assert!(!Error::InvalidRequest("x".to_string()).is_retriable());
232        assert!(!Error::Auth("x".to_string()).is_retriable());
233        assert!(
234            !Error::CommandRejected {
235                code: "GrpcStatus".to_string(),
236                message: "boom".to_string()
237            }
238            .is_retriable()
239        );
240        assert!(!Error::UnexpectedResponse("x".to_string()).is_retriable());
241    }
242
243    #[test]
244    fn code_is_exposed_only_for_status_errors() {
245        assert_eq!(
246            Error::from(tonic::Status::not_found("x")).code(),
247            Some(tonic::Code::NotFound)
248        );
249        assert_eq!(Error::Timeout.code(), None);
250        assert_eq!(Error::Connection("x".to_string()).code(), None);
251        assert_eq!(
252            Error::Http {
253                status: 503,
254                body: String::new()
255            }
256            .code(),
257            None
258        );
259    }
260
261    #[test]
262    fn display_messages_are_lowercase_and_informative() {
263        assert_eq!(Error::Timeout.to_string(), "operation timed out");
264        assert_eq!(
265            Error::InvalidRequest("bad uri".to_string()).to_string(),
266            "invalid request: bad uri"
267        );
268        assert_eq!(
269            Error::Auth("token expired".to_string()).to_string(),
270            "authentication failed: token expired"
271        );
272        assert_eq!(
273            Error::Http {
274                status: 503,
275                body: "down".to_string()
276            }
277            .to_string(),
278            "http 503: down"
279        );
280        assert_eq!(
281            Error::CommandRejected {
282                code: "INVALID_ARGUMENT".to_string(),
283                message: "nope".to_string()
284            }
285            .to_string(),
286            "command rejected (INVALID_ARGUMENT): nope"
287        );
288    }
289}