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: this is a
41 /// terminal outcome of that submission read back from the completion
42 /// stream, and re-submitting is an application decision — the automatic
43 /// retry path (`submit*` RPC errors) surfaces rejections as
44 /// [`Error::Status`] instead, with full category/retry-delay precision.
45 #[error("command rejected ({code}): {message}")]
46 CommandRejected {
47 /// The rejection status code.
48 code: String,
49 /// The rejection message.
50 message: String,
51 },
52
53 /// Authentication/authorization was rejected (bad or expired credentials).
54 /// Not retriable — a token-transport failure surfaces as [`Error::Connection`]
55 /// or [`Error::Http`] instead.
56 #[error("authentication failed: {0}")]
57 Auth(String),
58
59 /// A request precondition or configuration value was invalid before send.
60 #[error("invalid request: {0}")]
61 InvalidRequest(String),
62
63 /// The server's response was well-formed at the transport level but not
64 /// what the protocol expects (e.g. a missing field, or a stream that ended
65 /// unexpectedly). Not a caller-input error.
66 #[error("unexpected response: {0}")]
67 UnexpectedResponse(String),
68
69 /// The operation exceeded its configured deadline. Retriable.
70 #[error("operation timed out")]
71 Timeout,
72
73 /// A typed payload failed to convert to or from the Ledger API `Value` —
74 /// a `canton-daml` codec error. Not retriable: the shape will not change
75 /// on a retry.
76 // The cause is in the message *and* kept as `source`: every other variant
77 // here prints its detail, and an application that logs `{err}` without
78 // walking the chain would otherwise be told only that something failed.
79 #[error("payload conversion failed: {0}")]
80 Payload(#[source] Box<dyn std::error::Error + Send + Sync>),
81}
82
83impl Error {
84 /// The gRPC status code the participant answered with, on **either**
85 /// transport.
86 ///
87 /// The JSON Ledger API reports it numerically as `grpcCodeValue`, so the
88 /// same failure yields the same code whichever lane carried it — the HTTP
89 /// status alone does not, since Canton maps several codes onto one status.
90 /// `None` when there is no participant verdict to report: a transport
91 /// failure, a timeout, or an HTTP body that is not a Canton error object
92 /// (a proxy's error page, say).
93 #[must_use]
94 pub fn code(&self) -> Option<tonic::Code> {
95 match self {
96 Error::Status(status) => Some(status.code()),
97 Error::Http { body, .. } => http_grpc_code(body),
98 _ => None,
99 }
100 }
101
102 /// Whether retrying the operation may succeed.
103 ///
104 /// For gRPC statuses, Canton's own verdict wins: every Ledger API error
105 /// carries an [`ErrorCategory`] whose retryability is defined by the
106 /// [error-code documentation], and retryable errors additionally carry a
107 /// `google.rpc.RetryInfo` detail. Only when a status carries neither (a
108 /// proxy in the middle, a non-Canton server) does the classification fall
109 /// back to the transient gRPC codes (`Unavailable`, `DeadlineExceeded`,
110 /// `ResourceExhausted`, `Aborted`).
111 ///
112 /// Beyond statuses, transient conditions are retriable: timeouts,
113 /// transport/connection failures, and transient HTTP status codes
114 /// (408, 429, 5xx). Everything else — invalid input, auth rejection,
115 /// command rejection, `NotFound`/`AlreadyExists`, deserialization — is not.
116 ///
117 /// [error-code documentation]: https://docs.daml.com/canton/reference/error_codes.html
118 #[must_use]
119 pub fn is_retriable(&self) -> bool {
120 use tonic::Code::{Aborted, DeadlineExceeded, ResourceExhausted, Unavailable};
121 match self {
122 Error::Timeout | Error::Transport(_) | Error::Connection(_) => true,
123 Error::Status(status) => match status_category(status) {
124 Some(category) => category.is_retriable(),
125 // No category ⇒ not a Canton self-service error. A RetryInfo
126 // detail is still an explicit "retry me"; else fall back to
127 // the transient codes, plus a connection that died in flight.
128 None => {
129 status_retry_delay(status).is_some()
130 || matches!(
131 status.code(),
132 Unavailable | DeadlineExceeded | ResourceExhausted | Aborted
133 )
134 || is_transport_death(status)
135 }
136 },
137 // The JSON Ledger API carries the same verdict in the error body
138 // (`errorCategory`, `retryInfo`); parse it before falling back to
139 // the transient HTTP status codes.
140 Error::Http { status, body } => match http_category(body) {
141 Some(category) => category.is_retriable(),
142 None => http_retry_delay(body).is_some() || matches!(status, 408 | 429 | 500..=599),
143 },
144 _ => false,
145 }
146 }
147
148 /// The Canton [`ErrorCategory`] of this error, when it carries one: from
149 /// `ErrorInfo.metadata["category"]` on a gRPC status, or the
150 /// `errorCategory` field of a JSON API error body. This is the field the
151 /// [error-code documentation] tells clients to base error handling on;
152 /// [`Error::is_retriable`] already does.
153 ///
154 /// [error-code documentation]: https://docs.daml.com/canton/reference/error_codes.html
155 #[must_use]
156 pub fn category(&self) -> Option<ErrorCategory> {
157 match self {
158 Error::Status(status) => status_category(status),
159 Error::Http { body, .. } => http_category(body),
160 _ => None,
161 }
162 }
163
164 /// The server-recommended delay before retrying, from the
165 /// `google.rpc.RetryInfo` detail of a gRPC status or the `retryInfo`
166 /// field of a JSON API error body. Canton attaches it to retryable
167 /// errors; the retry helper ([`crate::retry::run_with_retry`]) already
168 /// honours it.
169 #[must_use]
170 pub fn retry_delay(&self) -> Option<std::time::Duration> {
171 match self {
172 Error::Status(status) => status_retry_delay(status),
173 Error::Http { body, .. } => http_retry_delay(body),
174 _ => None,
175 }
176 }
177
178 /// The correlation id of the failed request, from the
179 /// `google.rpc.RequestInfo` detail of a gRPC status or the
180 /// `correlationId`/`traceId` of a JSON API error body. Canton echoes it
181 /// in every error; quote it when reporting a problem to the participant's
182 /// operator, who can find the server-side trace by it.
183 #[must_use]
184 pub fn correlation_id(&self) -> Option<String> {
185 match self {
186 Error::Status(status) => {
187 use tonic_types::StatusExt as _;
188 status
189 .get_details_request_info()
190 .map(|info| info.request_id)
191 }
192 Error::Http { body, .. } => http_correlation_id(body),
193 _ => None,
194 }
195 }
196
197 /// The resources this error is about: which contract, package, party or
198 /// synchronizer the participant is complaining of. Canton attaches these to
199 /// the errors where "which one?" is the first question — `CONTRACT_NOT_FOUND`
200 /// names the contract id, contention names the locked contracts.
201 ///
202 /// A `Vec` rather than an `Option` because the wire carries a list: the JSON
203 /// Ledger API's `resources` is an array of `[type, name]` pairs, and one
204 /// error can name several. The gRPC side yields at most one today — that is
205 /// a limit of `tonic_types`, which models a single `google.rpc.ResourceInfo`
206 /// detail, not of the protocol.
207 #[must_use]
208 pub fn resource_info(&self) -> Vec<ResourceInfo> {
209 match self {
210 Error::Status(status) => {
211 use tonic_types::StatusExt as _;
212 status
213 .get_details_resource_info()
214 .map(|info| ResourceInfo {
215 resource_type: info.resource_type,
216 resource_name: info.resource_name,
217 owner: info.owner,
218 description: info.description,
219 })
220 .into_iter()
221 .collect()
222 }
223 Error::Http { body, .. } => http_resource_info(body),
224 _ => Vec::new(),
225 }
226 }
227
228 /// The machine-readable identity of the failure: Canton's error `reason`
229 /// (e.g. `DUPLICATE_COMMAND`) plus its context `metadata`. Prefer it over
230 /// string-matching [`Display`](std::fmt::Display) output.
231 ///
232 /// Available on **either** transport. gRPC carries it as a
233 /// `google.rpc.ErrorInfo` detail; the JSON Ledger API spells the same two
234 /// things as `code` and `context`, and this reads whichever is there. That
235 /// matters because the alternative on the JSON lane was the string matching
236 /// this method exists to replace.
237 ///
238 /// `None` when the participant published no identity to report: a transport
239 /// failure, a status without the detail, or a **redacted** error — Canton
240 /// answers a security-sensitive failure with the literal `"NA"`, which is
241 /// the absence of an error id rather than an error id.
242 ///
243 /// `domain` is empty on the JSON lane, and Canton leaves it empty on gRPC
244 /// too.
245 #[must_use]
246 pub fn error_info(&self) -> Option<ErrorInfo> {
247 match self {
248 Error::Status(status) => {
249 use tonic_types::StatusExt as _;
250 status
251 .get_error_details()
252 .error_info()
253 .map(|info| ErrorInfo {
254 reason: info.reason.clone(),
255 domain: info.domain.clone(),
256 metadata: info.metadata.clone(),
257 })
258 }
259 Error::Http { body, .. } => http_error_info(body),
260 _ => None,
261 }
262 }
263}
264
265/// The category of the status's `ErrorInfo`, when present and recognized.
266/// Whether a status is a connection that died *mid-call*, rather than a verdict
267/// the participant returned.
268///
269/// tonic maps a broken stream or a dropped unary connection to a `Status` with
270/// code `Unknown` (message "transport error" / "h2 protocol error"), not to a
271/// `transport::Error` — that variant is only for connection *establishment*. So
272/// a real drop reaches [`Error::is_retriable`] as a category-less `Unknown` and,
273/// without this, is treated as terminal: the resumable stream gives up on the
274/// first blip and the retry pipeline never fires on the most ordinary failure
275/// there is.
276///
277/// The signal is the source chain, not the message text (which is
278/// tonic-version-specific). A client-side transport failure carries a `source`
279/// — tonic built the status *from* the underlying h2/hyper/io error. A status
280/// decoded from response trailers, which is how a genuine application `Unknown`
281/// arrives, has none. So `Unknown` (or `Internal`, tonic's other transport-error
282/// code) with a source present is a dropped connection; the same code without a
283/// source is the server's own answer and stays terminal.
284fn is_transport_death(status: &tonic::Status) -> bool {
285 use std::error::Error as _;
286 matches!(status.code(), tonic::Code::Unknown | tonic::Code::Internal)
287 && status.source().is_some()
288}
289
290fn status_category(status: &tonic::Status) -> Option<ErrorCategory> {
291 use tonic_types::StatusExt as _;
292 let info = status.get_details_error_info()?;
293 ErrorCategory::from_i32(info.metadata.get("category")?.parse().ok()?)
294}
295
296/// The `RetryInfo.retry_delay` of a status, when present.
297fn status_retry_delay(status: &tonic::Status) -> Option<std::time::Duration> {
298 use tonic_types::StatusExt as _;
299 status.get_details_retry_info()?.retry_delay
300}
301
302/// The `errorCategory` of a JSON Ledger API error body, when present and
303/// recognized. Non-JSON bodies (a proxy's HTML error page, a token endpoint)
304/// simply yield `None`.
305fn http_category(body: &str) -> Option<ErrorCategory> {
306 let body: serde_json::Value = serde_json::from_str(body).ok()?;
307 let id = body.get("errorCategory")?.as_i64()?;
308 ErrorCategory::from_i32(i32::try_from(id).ok()?)
309}
310
311/// The `retryInfo` of a JSON Ledger API error body, when present. The field
312/// is a human-readable duration (e.g. `"1 second"`, `"250 milliseconds"`).
313fn http_retry_delay(body: &str) -> Option<std::time::Duration> {
314 let body: serde_json::Value = serde_json::from_str(body).ok()?;
315 parse_spelled_duration(body.get("retryInfo")?.as_str()?)
316}
317
318/// The `resources` of a JSON Ledger API error body: an array of `[type, name]`
319/// pairs (e.g. `[["ErrorResource(CONTRACT_ID)", "00abc…"]]`). Entries that are
320/// not a pair of strings are skipped rather than failing the whole read — a
321/// diagnostic must not itself become an error.
322fn http_resource_info(body: &str) -> Vec<ResourceInfo> {
323 let Ok(body) = serde_json::from_str::<serde_json::Value>(body) else {
324 return Vec::new();
325 };
326 let Some(resources) = body.get("resources").and_then(serde_json::Value::as_array) else {
327 return Vec::new();
328 };
329 resources
330 .iter()
331 .filter_map(|entry| {
332 let pair = entry.as_array()?;
333 Some(ResourceInfo {
334 resource_type: pair.first()?.as_str()?.to_string(),
335 resource_name: pair.get(1)?.as_str()?.to_string(),
336 owner: String::new(),
337 description: String::new(),
338 })
339 })
340 .collect()
341}
342
343/// The `grpcCodeValue` of a JSON Ledger API error body — the numeric gRPC code
344/// the participant would have answered with over the other transport.
345///
346/// Only reported when the field is actually there: `tonic::Code::from` maps an
347/// unknown number to `Unknown`, and manufacturing `Unknown` for a body that
348/// never carried a code would claim a verdict the participant never gave.
349fn http_grpc_code(body: &str) -> Option<tonic::Code> {
350 let body: serde_json::Value = serde_json::from_str(body).ok()?;
351 let value = body.get("grpcCodeValue")?.as_i64()?;
352 Some(tonic::Code::from(i32::try_from(value).ok()?))
353}
354
355/// The `code` and `context` of a JSON Ledger API error body, as the
356/// `ErrorInfo` the gRPC lane carries as a status detail.
357///
358/// `"NA"` is Canton's placeholder on a redacted (security-sensitive) error and
359/// is treated as no id at all — reporting it would hand the caller a string
360/// that looks like an error code and matches nothing.
361///
362/// A context value that is not a string keeps its JSON spelling rather than
363/// being dropped: `metadata` is a string map, and losing a field silently is
364/// worse than rendering it.
365fn http_error_info(body: &str) -> Option<ErrorInfo> {
366 let body: serde_json::Value = serde_json::from_str(body).ok()?;
367 let reason = body.get("code")?.as_str()?;
368 if reason.is_empty() || reason == "NA" {
369 return None;
370 }
371 let metadata = body
372 .get("context")
373 .and_then(serde_json::Value::as_object)
374 .map(|context| {
375 context
376 .iter()
377 .map(|(key, value)| {
378 let value = match value.as_str() {
379 Some(text) => text.to_string(),
380 None => value.to_string(),
381 };
382 (key.clone(), value)
383 })
384 .collect()
385 })
386 .unwrap_or_default();
387 Some(ErrorInfo {
388 reason: reason.to_string(),
389 // Canton sets no domain on either transport, and the JSON body has no
390 // field for one. Empty here says the same thing gRPC says.
391 domain: String::new(),
392 metadata,
393 })
394}
395
396/// The `correlationId` (or, failing that, `traceId`) of a JSON Ledger API
397/// error body, when present.
398fn http_correlation_id(body: &str) -> Option<String> {
399 let body: serde_json::Value = serde_json::from_str(body).ok()?;
400 ["correlationId", "traceId"]
401 .iter()
402 .find_map(|key| Some(body.get(key)?.as_str()?.to_string()))
403}
404
405/// Parse a `"<number> <unit>"` duration as the JSON API spells `retryInfo`
406/// (Scala `Duration#toString`: `"1 second"`, `"5 seconds"`, …).
407fn parse_spelled_duration(text: &str) -> Option<std::time::Duration> {
408 let mut words = text.split_whitespace();
409 let amount: f64 = words.next()?.parse().ok()?;
410 let unit = words.next()?;
411 if words.next().is_some() {
412 return None;
413 }
414 let seconds = match unit.strip_suffix('s').unwrap_or(unit) {
415 "day" => amount * 86_400.0,
416 "hour" => amount * 3_600.0,
417 "minute" => amount * 60.0,
418 "second" => amount,
419 "millisecond" => amount / 1e3,
420 "microsecond" => amount / 1e6,
421 "nanosecond" => amount / 1e9,
422 _ => return None,
423 };
424 // `from_secs_f64` panics on a value a `Duration` cannot hold, and this
425 // number arrives from the server: a `retryInfo` of `"1e300 seconds"` — or
426 // an ordinary-looking `"1e15 days"` — would abort the caller's process
427 // inside error classification, the one place that must stay infallible.
428 // `try_from_secs_f64` rejects those the same way it rejects the NaN and
429 // negative values this guarded against before.
430 std::time::Duration::try_from_secs_f64(seconds).ok()
431}
432
433/// Canton's error categories — the coarse classification every Ledger API
434/// error carries (`ErrorInfo.metadata["category"]`), which the [error-code
435/// documentation] defines retryability on. `#[non_exhaustive]`: Canton may add
436/// categories.
437///
438/// The variants are the categories of the Canton 3.x docs, by their stable
439/// numeric ids (13 is a server-log-only warning that never reaches the API).
440///
441/// [error-code documentation]: https://docs.daml.com/canton/reference/error_codes.html
442#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
443#[non_exhaustive]
444pub enum ErrorCategory {
445 /// 1 — a service was momentarily unavailable; the request may or may not
446 /// have been processed. Retry with backoff.
447 TransientServerFailure,
448 /// 2 — contention on shared resources (locks, rate limits, a locked
449 /// contract). Retry with backoff.
450 ContentionOnSharedResources,
451 /// 3 — the request's deadline expired with its outcome unknown. Retry a
452 /// bounded number of times, relying on command deduplication.
453 DeadlineExceededRequestStateUnknown,
454 /// 4 — a system-internal invariant was violated (implementation bug or
455 /// data corruption). Not retriable; needs operator/vendor attention.
456 SystemInternalAssumptionViolated,
457 /// 5 — a potential attack or faulty peer was detected; details are
458 /// deliberately withheld. Not retriable.
459 SecurityAlert,
460 /// 6 — missing or invalid authentication credentials. Not retriable
461 /// until the credentials are fixed.
462 AuthInterceptorInvalidAuthenticationCredentials,
463 /// 7 — authenticated, but not permitted to perform the operation. Not
464 /// retriable until permissions change.
465 InsufficientPermission,
466 /// 8 — the request is invalid regardless of system state (malformed
467 /// arguments, size limits). Not retriable.
468 InvalidIndependentOfSystemState,
469 /// 9 — the current ledger state does not satisfy the request's
470 /// preconditions (Daml interpretation failures land here). Not blindly
471 /// retriable; needs an application-level strategy.
472 InvalidGivenCurrentSystemStateOther,
473 /// 10 — a referenced resource already exists (e.g. a duplicate command).
474 /// Not retriable as-is.
475 InvalidGivenCurrentSystemStateResourceExists,
476 /// 11 — a referenced resource does not exist (contract, package, party).
477 /// Not retriable as-is.
478 InvalidGivenCurrentSystemStateResourceMissing,
479 /// 12 — the request reads past the current ledger end. Retriable: the
480 /// system may naturally progress to make it valid.
481 InvalidGivenCurrentSystemStateSeekAfterEnd,
482 /// 14 — the operation is not implemented / not enabled on this node. Not
483 /// retriable.
484 InternalUnsupportedOperation,
485}
486
487/// The std spelling of [`ErrorCategory::from_i32`], so the category can be
488/// used with generic conversion bounds. The inherent method stays: it is the
489/// documented name and the `Option` shape matches "unknown id" better than an
490/// error type would.
491impl TryFrom<i32> for ErrorCategory {
492 type Error = i32;
493 fn try_from(id: i32) -> std::result::Result<Self, i32> {
494 Self::from_i32(id).ok_or(id)
495 }
496}
497
498impl From<ErrorCategory> for i32 {
499 fn from(category: ErrorCategory) -> i32 {
500 category.as_i32()
501 }
502}
503
504impl ErrorCategory {
505 /// The category for Canton's numeric id, `None` when unrecognized.
506 #[must_use]
507 pub const fn from_i32(id: i32) -> Option<Self> {
508 Some(match id {
509 1 => Self::TransientServerFailure,
510 2 => Self::ContentionOnSharedResources,
511 3 => Self::DeadlineExceededRequestStateUnknown,
512 4 => Self::SystemInternalAssumptionViolated,
513 5 => Self::SecurityAlert,
514 6 => Self::AuthInterceptorInvalidAuthenticationCredentials,
515 7 => Self::InsufficientPermission,
516 8 => Self::InvalidIndependentOfSystemState,
517 9 => Self::InvalidGivenCurrentSystemStateOther,
518 10 => Self::InvalidGivenCurrentSystemStateResourceExists,
519 11 => Self::InvalidGivenCurrentSystemStateResourceMissing,
520 12 => Self::InvalidGivenCurrentSystemStateSeekAfterEnd,
521 14 => Self::InternalUnsupportedOperation,
522 _ => return None,
523 })
524 }
525
526 /// Canton's numeric id for this category.
527 #[must_use]
528 pub const fn as_i32(self) -> i32 {
529 match self {
530 Self::TransientServerFailure => 1,
531 Self::ContentionOnSharedResources => 2,
532 Self::DeadlineExceededRequestStateUnknown => 3,
533 Self::SystemInternalAssumptionViolated => 4,
534 Self::SecurityAlert => 5,
535 Self::AuthInterceptorInvalidAuthenticationCredentials => 6,
536 Self::InsufficientPermission => 7,
537 Self::InvalidIndependentOfSystemState => 8,
538 Self::InvalidGivenCurrentSystemStateOther => 9,
539 Self::InvalidGivenCurrentSystemStateResourceExists => 10,
540 Self::InvalidGivenCurrentSystemStateResourceMissing => 11,
541 Self::InvalidGivenCurrentSystemStateSeekAfterEnd => 12,
542 Self::InternalUnsupportedOperation => 14,
543 }
544 }
545
546 /// Whether the error-code documentation classifies this category as
547 /// retryable (transient failures, contention, unknown-outcome deadlines,
548 /// and reads past the ledger end).
549 #[must_use]
550 pub const fn is_retriable(self) -> bool {
551 matches!(
552 self,
553 Self::TransientServerFailure
554 | Self::ContentionOnSharedResources
555 | Self::DeadlineExceededRequestStateUnknown
556 | Self::InvalidGivenCurrentSystemStateSeekAfterEnd
557 )
558 }
559}
560
561/// Structured `google.rpc.ErrorInfo` details from a gRPC status: the machine-
562/// readable `reason`, its `domain`, and error `metadata`. `#[non_exhaustive]`.
563#[derive(Clone, Debug, Default, PartialEq, Eq)]
564#[non_exhaustive]
565pub struct ErrorInfo {
566 /// Machine-readable error reason (e.g. a Canton/Daml error code).
567 pub reason: String,
568 /// The logical grouping the `reason` belongs to.
569 pub domain: String,
570 /// Additional structured context for the error.
571 pub metadata: std::collections::HashMap<String, String>,
572}
573
574/// A resource a failure is about, from a `google.rpc.ResourceInfo` detail on a
575/// gRPC status or an entry of a JSON API error body's `resources`.
576/// `#[non_exhaustive]`.
577#[derive(Clone, Debug, Default, PartialEq, Eq)]
578#[non_exhaustive]
579pub struct ResourceInfo {
580 /// What kind of thing it is, as Canton names it (e.g.
581 /// `ErrorResource(CONTRACT_ID)`).
582 pub resource_type: String,
583 /// The identifier itself — a contract id, package name, party, …
584 pub resource_name: String,
585 /// The owner, when the server reports one. Empty on the JSON transport,
586 /// whose `resources` entries carry only the type and the name.
587 pub owner: String,
588 /// A human-readable note, when the server reports one. Empty on the JSON
589 /// transport for the same reason.
590 pub description: String,
591}
592
593impl From<tonic::Status> for Error {
594 fn from(status: tonic::Status) -> Self {
595 Error::Status(Box::new(status))
596 }
597}
598
599impl From<tonic::transport::Error> for Error {
600 fn from(err: tonic::transport::Error) -> Self {
601 Error::Transport(Box::new(err))
602 }
603}
604
605impl From<serde_json::Error> for Error {
606 fn from(err: serde_json::Error) -> Self {
607 Error::Json(Box::new(err))
608 }
609}
610
611/// SDK-wide result alias. Re-exported by the facade as `canton::Result`.
612pub type Result<T, E = Error> = std::result::Result<T, E>;
613
614#[cfg(test)]
615#[allow(clippy::unwrap_used, clippy::expect_used)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn error_info_is_extracted_from_a_status_and_absent_otherwise() {
621 use tonic_types::{ErrorDetails, StatusExt as _};
622
623 let mut metadata = std::collections::HashMap::new();
624 metadata.insert("resource".to_string(), "contract-1".to_string());
625 let details = ErrorDetails::with_error_info("DUPLICATE_COMMAND", "canton", metadata);
626 let status = tonic::Status::with_error_details(tonic::Code::AlreadyExists, "dup", details);
627
628 let info = Error::from(status)
629 .error_info()
630 .expect("error info present");
631 assert_eq!(info.reason, "DUPLICATE_COMMAND");
632 assert_eq!(info.domain, "canton");
633 assert_eq!(
634 info.metadata.get("resource").map(String::as_str),
635 Some("contract-1")
636 );
637
638 // A status without ErrorInfo, and a non-status error, yield None.
639 assert!(
640 Error::from(tonic::Status::not_found("x"))
641 .error_info()
642 .is_none()
643 );
644 assert!(Error::Timeout.error_info().is_none());
645 }
646
647 /// A synthetic Canton-style status: `ErrorInfo` with a `category` metadata
648 /// entry, `RequestInfo` with the correlation id, and — when `delay` is set —
649 /// a `RetryInfo` recommendation.
650 fn canton_status(
651 code: tonic::Code,
652 category: i32,
653 delay: Option<std::time::Duration>,
654 ) -> tonic::Status {
655 use tonic_types::{ErrorDetails, StatusExt as _};
656
657 let mut metadata = std::collections::HashMap::new();
658 metadata.insert("category".to_string(), category.to_string());
659 let mut details = ErrorDetails::with_error_info("SOME_ERROR_CODE", "participant", metadata);
660 details.set_request_info("corr-1234", "");
661 if let Some(delay) = delay {
662 details.set_retry_info(Some(delay));
663 }
664 tonic::Status::with_error_details(code, "boom", details)
665 }
666
667 #[test]
668 fn the_canton_category_decides_retryability_over_the_grpc_code() {
669 use std::time::Duration;
670
671 // ABORTED is transient by code — but category 10 (resource exists,
672 // e.g. a duplicate change id) says no. The category wins.
673 let err = Error::from(canton_status(tonic::Code::Aborted, 10, None));
674 assert_eq!(
675 err.category(),
676 Some(ErrorCategory::InvalidGivenCurrentSystemStateResourceExists)
677 );
678 assert!(!err.is_retriable());
679
680 // OUT_OF_RANGE is not transient by code — but category 12 (seek past
681 // the ledger end) says retry. The category wins again.
682 let err = Error::from(canton_status(
683 tonic::Code::OutOfRange,
684 12,
685 Some(Duration::from_secs(1)),
686 ));
687 assert_eq!(
688 err.category(),
689 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
690 );
691 assert!(err.is_retriable());
692 assert_eq!(err.retry_delay(), Some(Duration::from_secs(1)));
693 }
694
695 #[test]
696 fn correlation_id_and_retry_delay_are_extracted() {
697 use std::time::Duration;
698
699 let err = Error::from(canton_status(
700 tonic::Code::Unavailable,
701 1,
702 Some(Duration::from_millis(250)),
703 ));
704 assert_eq!(err.category(), Some(ErrorCategory::TransientServerFailure));
705 assert_eq!(err.correlation_id().as_deref(), Some("corr-1234"));
706 assert_eq!(err.retry_delay(), Some(Duration::from_millis(250)));
707
708 // Plain statuses and non-status errors carry none of it.
709 let plain = Error::from(tonic::Status::unavailable("x"));
710 assert_eq!(plain.category(), None);
711 assert_eq!(plain.correlation_id(), None);
712 assert_eq!(plain.retry_delay(), None);
713 assert_eq!(Error::Timeout.category(), None);
714 }
715
716 #[test]
717 fn statuses_without_a_category_fall_back_to_code_classification() {
718 use tonic_types::{ErrorDetails, StatusExt as _};
719
720 // No details at all: the transient codes still classify.
721 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
722 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
723
724 // An unrecognized category id is ignored (forward compatibility), and
725 // the code fallback applies.
726 let err = Error::from(canton_status(tonic::Code::Unavailable, 99, None));
727 assert_eq!(err.category(), None);
728 assert!(err.is_retriable());
729
730 // A bare RetryInfo (no category) is an explicit "retry me", even on a
731 // code the fallback would refuse.
732 let mut details = ErrorDetails::new();
733 details.set_retry_info(Some(std::time::Duration::from_secs(2)));
734 let status =
735 tonic::Status::with_error_details(tonic::Code::FailedPrecondition, "wait", details);
736 assert!(Error::from(status).is_retriable());
737 }
738
739 #[test]
740 fn json_api_error_bodies_classify_by_category() {
741 // A real body captured from a LocalNet participant (JSON Ledger API):
742 // category 12 (seek after end) is retryable although the HTTP status
743 // (400) is not in the transient set — the category verdict wins.
744 let body = r#"{
745 "code": "OFFSET_AFTER_LEDGER_END",
746 "cause": "Begin offset (999999999) is after ledger end (23577)",
747 "correlationId": null,
748 "traceId": "36a33702b2fa7908a7349be166ccfa38",
749 "context": {"participant": "'app-provider'", "category": "12"},
750 "resources": [],
751 "errorCategory": 12,
752 "grpcCodeValue": 11,
753 "retryInfo": "1 second",
754 "definiteAnswer": null
755 }"#;
756 let err = Error::Http {
757 status: 400,
758 body: body.to_string(),
759 };
760 assert_eq!(
761 err.category(),
762 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
763 );
764 assert!(err.is_retriable());
765 assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(1)));
766 // correlationId is null — falls back to traceId.
767 assert_eq!(
768 err.correlation_id().as_deref(),
769 Some("36a33702b2fa7908a7349be166ccfa38")
770 );
771
772 // The same failure over gRPC yields OutOfRange and an ErrorInfo naming
773 // OFFSET_AFTER_LEDGER_END. The JSON body spells both — `grpcCodeValue`
774 // and `code`/`context` — so both accessors must answer here too, or a
775 // caller on this transport is left with the string matching
776 // `error_info` exists to replace.
777 assert_eq!(err.code(), Some(tonic::Code::OutOfRange));
778 let info = err.error_info().expect("the body names the error");
779 assert_eq!(info.reason, "OFFSET_AFTER_LEDGER_END");
780 assert_eq!(
781 info.metadata.get("category").map(String::as_str),
782 Some("12")
783 );
784 assert_eq!(
785 info.metadata.get("participant").map(String::as_str),
786 Some("'app-provider'")
787 );
788
789 // A non-retryable category on a retryable-looking HTTP status: the
790 // category still wins (e.g. a 503 whose body says "invalid argument").
791 let err = Error::Http {
792 status: 503,
793 body: r#"{"errorCategory": 8}"#.to_string(),
794 };
795 assert_eq!(
796 err.category(),
797 Some(ErrorCategory::InvalidIndependentOfSystemState)
798 );
799 assert!(!err.is_retriable());
800 }
801
802 #[test]
803 fn non_json_http_bodies_fall_back_to_status_code_classification() {
804 let retriable = Error::Http {
805 status: 503,
806 body: "<html>Service Unavailable</html>".to_string(),
807 };
808 assert!(retriable.is_retriable());
809 assert_eq!(retriable.category(), None);
810 assert_eq!(retriable.retry_delay(), None);
811
812 let terminal = Error::Http {
813 status: 404,
814 body: String::new(),
815 };
816 assert!(!terminal.is_retriable());
817 assert_eq!(terminal.correlation_id(), None);
818 }
819
820 #[test]
821 fn spelled_durations_parse_and_garbage_is_refused() {
822 use std::time::Duration;
823 for (text, expected) in [
824 ("1 second", Duration::from_secs(1)),
825 ("5 seconds", Duration::from_secs(5)),
826 ("250 milliseconds", Duration::from_millis(250)),
827 ("2 minutes", Duration::from_secs(120)),
828 ("1 hour", Duration::from_secs(3600)),
829 ("0.5 seconds", Duration::from_millis(500)),
830 // The rarer units and the day arm: mutation testing showed these
831 // conversion factors were never exercised, so a wrong multiplier
832 // in day/microsecond/nanosecond would have passed unnoticed.
833 ("2 days", Duration::from_secs(2 * 86_400)),
834 ("1 day", Duration::from_secs(86_400)),
835 ("500 microseconds", Duration::from_micros(500)),
836 ("250 nanoseconds", Duration::from_nanos(250)),
837 ] {
838 assert_eq!(parse_spelled_duration(text), Some(expected), "{text}");
839 }
840 // A server-supplied number too large for a `Duration` is refused, not
841 // panicked on: this runs while an error is being classified, and the
842 // value is whatever the participant put in the body.
843 for bad in [
844 "",
845 "soon",
846 "1",
847 "1 fortnight",
848 "-1 second",
849 "1 second ago",
850 "1e300 seconds",
851 "1e300 days",
852 "NaN seconds",
853 "inf seconds",
854 ] {
855 assert_eq!(parse_spelled_duration(bad), None, "{bad}");
856 }
857 }
858
859 #[test]
860 fn category_ids_round_trip_and_follow_the_docs_retryability() {
861 for id in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14] {
862 let category = ErrorCategory::from_i32(id).expect("known id");
863 assert_eq!(category.as_i32(), id);
864 // Per the error-code docs: 1, 2, 3 and 12 are the retryable ones.
865 assert_eq!(category.is_retriable(), matches!(id, 1 | 2 | 3 | 12));
866 }
867 assert_eq!(ErrorCategory::from_i32(0), None);
868 // 13 (BackgroundProcessDegradationWarning) never reaches the API.
869 assert_eq!(ErrorCategory::from_i32(13), None);
870 assert_eq!(ErrorCategory::from_i32(15), None);
871 }
872
873 /// Canton strips the detail from security-sensitive failures — auth,
874 /// permission, internal — and tells the caller to ask the operator. Every
875 /// accessor must degrade to "nothing" rather than mislead, and the one
876 /// thing that survives must keep surviving: without the correlation id
877 /// there is no way to ask.
878 ///
879 /// Both fixtures are the real shapes, taken from a live 3.5.7 participant
880 /// answering with an invalid token.
881 #[test]
882 fn a_redacted_status_yields_nothing_except_the_correlation_id() {
883 use tonic_types::{ErrorDetails, StatusExt as _};
884
885 // gRPC: no ErrorInfo, no RetryInfo, no ResourceInfo — only RequestInfo.
886 let mut details = ErrorDetails::new();
887 details.set_request_info("93199811c5b2090c51cf45fe8c88060c", "");
888 let status = tonic::Status::with_error_details(
889 tonic::Code::Unauthenticated,
890 "An error occurred. Please contact the operator and inquire about the request \
891 93199811c5b2090c51cf45fe8c88060c",
892 details,
893 );
894 let err = Error::from(status);
895
896 assert_eq!(err.category(), None, "a redacted status has no category");
897 assert_eq!(err.error_info(), None);
898 assert!(err.resource_info().is_empty());
899 assert_eq!(err.retry_delay(), None);
900 assert_eq!(
901 err.correlation_id().as_deref(),
902 Some("93199811c5b2090c51cf45fe8c88060c"),
903 "the correlation id is the only actionable thing left"
904 );
905 // Classification falls back to the code, which is the right answer:
906 // bad credentials will not become good on a retry.
907 assert!(!err.is_retriable());
908
909 // JSON: the category is reported as -1 rather than omitted, which must
910 // not be mistaken for a real category.
911 let body = r#"{"code":"NA","cause":"An error occurred. Please contact the operator",
912 "errorCategory":-1,"retryInfo":null,"resources":[],
913 "correlationId":"41f217564e4e76f6cbc853a94a82fa80",
914 "traceId":"41f217564e4e76f6cbc853a94a82fa80"}"#;
915 let err = Error::Http {
916 status: 401,
917 body: body.to_string(),
918 };
919
920 assert_eq!(err.category(), None, "-1 is not a category");
921 assert!(err.resource_info().is_empty());
922 assert_eq!(err.retry_delay(), None);
923 // `"NA"` is Canton saying it withheld the id, not an id spelled "NA".
924 assert_eq!(err.error_info(), None, "\"NA\" is not an error id");
925 assert_eq!(
926 err.correlation_id().as_deref(),
927 Some("41f217564e4e76f6cbc853a94a82fa80")
928 );
929 assert!(!err.is_retriable(), "401 is not transient");
930 }
931
932 #[test]
933 fn resource_info_names_what_the_error_is_about() {
934 use tonic_types::{ErrorDetails, StatusExt as _};
935
936 // gRPC: one `google.rpc.ResourceInfo` detail is all tonic_types models.
937 let mut details = ErrorDetails::new();
938 details.set_resource_info("ErrorResource(CONTRACT_ID)", "00abc", "alice", "not found");
939 let status = tonic::Status::with_error_details(tonic::Code::NotFound, "gone", details);
940 let found = Error::from(status).resource_info();
941 assert_eq!(found.len(), 1);
942 assert_eq!(found[0].resource_type, "ErrorResource(CONTRACT_ID)");
943 assert_eq!(found[0].resource_name, "00abc");
944 assert_eq!(found[0].owner, "alice");
945
946 // A status with no such detail yields nothing, not a default-filled entry.
947 assert!(
948 Error::from(tonic::Status::not_found("x"))
949 .resource_info()
950 .is_empty()
951 );
952 assert!(Error::Timeout.resource_info().is_empty());
953 }
954
955 #[test]
956 fn resource_info_reads_the_json_apis_resources_array() {
957 // The body shape is verbatim from a live Canton 3.5.7 participant
958 // answering an exercise on a contract that does not exist.
959 let body = r#"{"code":"CONTRACT_NOT_FOUND","cause":"…","errorCategory":11,
960 "resources":[["ErrorResource(CONTRACT_ID)","00ababab"]],"retryInfo":null}"#;
961 let err = Error::Http {
962 status: 404,
963 body: body.to_string(),
964 };
965 let found = err.resource_info();
966 assert_eq!(found.len(), 1);
967 assert_eq!(found[0].resource_type, "ErrorResource(CONTRACT_ID)");
968 assert_eq!(found[0].resource_name, "00ababab");
969 // The JSON pairs carry no owner or description; they are empty, not absent.
970 assert!(found[0].owner.is_empty());
971
972 // Several resources — contention names more than one, which is why this
973 // returns a Vec and not an Option.
974 let many = Error::Http {
975 status: 409,
976 body: r#"{"resources":[["A","1"],["B","2"]]}"#.to_string(),
977 };
978 assert_eq!(many.resource_info().len(), 2);
979
980 // A malformed entry is skipped, and an empty/absent array is not an error:
981 // a diagnostic accessor must never itself fail.
982 let ragged = Error::Http {
983 status: 500,
984 body: r#"{"resources":[["A"],["B","2"],42,null]}"#.to_string(),
985 };
986 assert_eq!(ragged.resource_info().len(), 1);
987 for body in [r#"{"resources":[]}"#, "{}", "not json at all", ""] {
988 let err = Error::Http {
989 status: 500,
990 body: body.to_string(),
991 };
992 assert!(err.resource_info().is_empty(), "{body}");
993 }
994 }
995
996 #[test]
997 fn transient_conditions_are_retriable() {
998 assert!(Error::Timeout.is_retriable());
999 assert!(Error::Connection("reset".to_string()).is_retriable());
1000 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
1001 assert!(Error::from(tonic::Status::deadline_exceeded("x")).is_retriable());
1002 assert!(Error::from(tonic::Status::resource_exhausted("x")).is_retriable());
1003 assert!(Error::from(tonic::Status::aborted("x")).is_retriable());
1004 }
1005
1006 #[test]
1007 fn a_connection_that_dies_mid_call_is_retriable_but_a_server_unknown_is_not() {
1008 use std::io;
1009
1010 // What tonic hands back when a stream or unary connection dies in
1011 // flight: code Unknown, built *from* the transport error, so the source
1012 // chain is populated. This is the ordinary dropped connection, and the
1013 // resumable stream and the retry pipeline both depend on it being
1014 // retriable — before this, they gave up on the first blip.
1015 let dropped = tonic::Status::from_error(Box::new(io::Error::new(
1016 io::ErrorKind::ConnectionReset,
1017 "h2 protocol error: error reading a body from connection",
1018 )));
1019 assert_eq!(
1020 dropped.code(),
1021 tonic::Code::Unknown,
1022 "from_error yields Unknown"
1023 );
1024 assert!(Error::from(dropped).is_retriable());
1025
1026 // A genuine `Unknown` the participant returned is decoded from response
1027 // trailers and carries no local source. That is the server's verdict,
1028 // and retrying it would just re-ask the same losing question.
1029 let server_unknown = tonic::Status::new(tonic::Code::Unknown, "business rule failed");
1030 assert!(!Error::from(server_unknown).is_retriable());
1031
1032 // `Internal` follows the same rule: transport-built is retriable, a
1033 // bare server Internal is not.
1034 assert!(
1035 Error::from(tonic::Status::from_error(Box::new(io::Error::other(
1036 "broken pipe"
1037 ))))
1038 .is_retriable()
1039 );
1040 assert!(!Error::from(tonic::Status::internal("server bug")).is_retriable());
1041 }
1042
1043 #[test]
1044 fn transient_http_codes_are_retriable_but_client_codes_are_not() {
1045 // The whole 5xx range is transient (per the doc), plus 408/429 — not just
1046 // a hand-picked subset. 501/509/511/520 were previously missed.
1047 for status in [
1048 408, 429, 500, 501, 502, 503, 504, 507, 509, 511, 520, 527, 599,
1049 ] {
1050 assert!(
1051 Error::Http {
1052 status,
1053 body: String::new()
1054 }
1055 .is_retriable(),
1056 "http {status} should be retriable"
1057 );
1058 }
1059 // 4xx (incl. the JSON API's 413 too-large) stay non-retriable.
1060 for status in [400, 401, 403, 404, 409, 413, 422] {
1061 assert!(
1062 !Error::Http {
1063 status,
1064 body: String::new()
1065 }
1066 .is_retriable(),
1067 "http {status} should not be retriable"
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn definite_failures_are_not_retriable() {
1074 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
1075 assert!(!Error::from(tonic::Status::already_exists("dup")).is_retriable());
1076 assert!(!Error::from(tonic::Status::invalid_argument("x")).is_retriable());
1077 assert!(!Error::InvalidRequest("x".to_string()).is_retriable());
1078 assert!(!Error::Auth("x".to_string()).is_retriable());
1079 assert!(
1080 !Error::CommandRejected {
1081 code: "GrpcStatus".to_string(),
1082 message: "boom".to_string()
1083 }
1084 .is_retriable()
1085 );
1086 assert!(!Error::UnexpectedResponse("x".to_string()).is_retriable());
1087 }
1088
1089 #[test]
1090 fn code_is_exposed_only_for_status_errors() {
1091 assert_eq!(
1092 Error::from(tonic::Status::not_found("x")).code(),
1093 Some(tonic::Code::NotFound)
1094 );
1095 assert_eq!(Error::Timeout.code(), None);
1096 assert_eq!(Error::Connection("x".to_string()).code(), None);
1097 assert_eq!(
1098 Error::Http {
1099 status: 503,
1100 body: String::new()
1101 }
1102 .code(),
1103 None
1104 );
1105 }
1106
1107 #[test]
1108 fn display_messages_are_lowercase_and_informative() {
1109 assert_eq!(Error::Timeout.to_string(), "operation timed out");
1110 assert_eq!(
1111 Error::InvalidRequest("bad uri".to_string()).to_string(),
1112 "invalid request: bad uri"
1113 );
1114 assert_eq!(
1115 Error::Auth("token expired".to_string()).to_string(),
1116 "authentication failed: token expired"
1117 );
1118 assert_eq!(
1119 Error::Http {
1120 status: 503,
1121 body: "down".to_string()
1122 }
1123 .to_string(),
1124 "http 503: down"
1125 );
1126 assert_eq!(
1127 Error::CommandRejected {
1128 code: "INVALID_ARGUMENT".to_string(),
1129 message: "nope".to_string()
1130 }
1131 .to_string(),
1132 "command rejected (INVALID_ARGUMENT): nope"
1133 );
1134 }
1135}