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