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