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
74impl Error {
75    /// The gRPC status code, if this error originates from a gRPC status.
76    #[must_use]
77    pub fn code(&self) -> Option<tonic::Code> {
78        match self {
79            Error::Status(status) => Some(status.code()),
80            _ => None,
81        }
82    }
83
84    /// Whether retrying the operation may succeed.
85    ///
86    /// For gRPC statuses, Canton's own verdict wins: every Ledger API error
87    /// carries an [`ErrorCategory`] whose retryability is defined by the
88    /// [error-code documentation], and retryable errors additionally carry a
89    /// `google.rpc.RetryInfo` detail. Only when a status carries neither (a
90    /// proxy in the middle, a non-Canton server) does the classification fall
91    /// back to the transient gRPC codes (`Unavailable`, `DeadlineExceeded`,
92    /// `ResourceExhausted`, `Aborted`).
93    ///
94    /// Beyond statuses, transient conditions are retriable: timeouts,
95    /// transport/connection failures, and transient HTTP status codes
96    /// (408, 429, 5xx). Everything else — invalid input, auth rejection,
97    /// command rejection, `NotFound`/`AlreadyExists`, deserialization — is not.
98    ///
99    /// [error-code documentation]: https://docs.daml.com/canton/reference/error_codes.html
100    #[must_use]
101    pub fn is_retriable(&self) -> bool {
102        use tonic::Code::{Aborted, DeadlineExceeded, ResourceExhausted, Unavailable};
103        match self {
104            Error::Timeout | Error::Transport(_) | Error::Connection(_) => true,
105            Error::Status(status) => match status_category(status) {
106                Some(category) => category.is_retriable(),
107                // No category ⇒ not a Canton self-service error. A RetryInfo
108                // detail is still an explicit "retry me"; else fall back to
109                // the transient codes.
110                None => {
111                    status_retry_delay(status).is_some()
112                        || matches!(
113                            status.code(),
114                            Unavailable | DeadlineExceeded | ResourceExhausted | Aborted
115                        )
116                }
117            },
118            // The JSON Ledger API carries the same verdict in the error body
119            // (`errorCategory`, `retryInfo`); parse it before falling back to
120            // the transient HTTP status codes.
121            Error::Http { status, body } => match http_category(body) {
122                Some(category) => category.is_retriable(),
123                None => http_retry_delay(body).is_some() || matches!(status, 408 | 429 | 500..=599),
124            },
125            _ => false,
126        }
127    }
128
129    /// The Canton [`ErrorCategory`] of this error, when it carries one: from
130    /// `ErrorInfo.metadata["category"]` on a gRPC status, or the
131    /// `errorCategory` field of a JSON API error body. This is the field the
132    /// [error-code documentation] tells clients to base error handling on;
133    /// [`Error::is_retriable`] already does.
134    ///
135    /// [error-code documentation]: https://docs.daml.com/canton/reference/error_codes.html
136    #[must_use]
137    pub fn category(&self) -> Option<ErrorCategory> {
138        match self {
139            Error::Status(status) => status_category(status),
140            Error::Http { body, .. } => http_category(body),
141            _ => None,
142        }
143    }
144
145    /// The server-recommended delay before retrying, from the
146    /// `google.rpc.RetryInfo` detail of a gRPC status or the `retryInfo`
147    /// field of a JSON API error body. Canton attaches it to retryable
148    /// errors; the retry helper ([`crate::retry::run_with_retry`]) already
149    /// honours it.
150    #[must_use]
151    pub fn retry_delay(&self) -> Option<std::time::Duration> {
152        match self {
153            Error::Status(status) => status_retry_delay(status),
154            Error::Http { body, .. } => http_retry_delay(body),
155            _ => None,
156        }
157    }
158
159    /// The correlation id of the failed request, from the
160    /// `google.rpc.RequestInfo` detail of a gRPC status or the
161    /// `correlationId`/`traceId` of a JSON API error body. Canton echoes it
162    /// in every error; quote it when reporting a problem to the participant's
163    /// operator, who can find the server-side trace by it.
164    #[must_use]
165    pub fn correlation_id(&self) -> Option<String> {
166        match self {
167            Error::Status(status) => {
168                use tonic_types::StatusExt as _;
169                status
170                    .get_details_request_info()
171                    .map(|info| info.request_id)
172            }
173            Error::Http { body, .. } => http_correlation_id(body),
174            _ => None,
175        }
176    }
177
178    /// The structured `google.rpc.ErrorInfo` carried by a gRPC status, when
179    /// present. Canton populates this with the machine-readable error `reason`
180    /// (e.g. `DUPLICATE_COMMAND`) plus context `metadata` — prefer it over
181    /// string-matching [`Display`](std::fmt::Display) output. Returns `None` for
182    /// non-status errors or statuses without an `ErrorInfo` detail.
183    #[must_use]
184    pub fn error_info(&self) -> Option<ErrorInfo> {
185        match self {
186            Error::Status(status) => {
187                use tonic_types::StatusExt as _;
188                status
189                    .get_error_details()
190                    .error_info()
191                    .map(|info| ErrorInfo {
192                        reason: info.reason.clone(),
193                        domain: info.domain.clone(),
194                        metadata: info.metadata.clone(),
195                    })
196            }
197            _ => None,
198        }
199    }
200}
201
202/// The category of the status's `ErrorInfo`, when present and recognized.
203fn status_category(status: &tonic::Status) -> Option<ErrorCategory> {
204    use tonic_types::StatusExt as _;
205    let info = status.get_details_error_info()?;
206    ErrorCategory::from_i32(info.metadata.get("category")?.parse().ok()?)
207}
208
209/// The `RetryInfo.retry_delay` of a status, when present.
210fn status_retry_delay(status: &tonic::Status) -> Option<std::time::Duration> {
211    use tonic_types::StatusExt as _;
212    status.get_details_retry_info()?.retry_delay
213}
214
215/// The `errorCategory` of a JSON Ledger API error body, when present and
216/// recognized. Non-JSON bodies (a proxy's HTML error page, a token endpoint)
217/// simply yield `None`.
218fn http_category(body: &str) -> Option<ErrorCategory> {
219    let body: serde_json::Value = serde_json::from_str(body).ok()?;
220    let id = body.get("errorCategory")?.as_i64()?;
221    ErrorCategory::from_i32(i32::try_from(id).ok()?)
222}
223
224/// The `retryInfo` of a JSON Ledger API error body, when present. The field
225/// is a human-readable duration (e.g. `"1 second"`, `"250 milliseconds"`).
226fn http_retry_delay(body: &str) -> Option<std::time::Duration> {
227    let body: serde_json::Value = serde_json::from_str(body).ok()?;
228    parse_spelled_duration(body.get("retryInfo")?.as_str()?)
229}
230
231/// The `correlationId` (or, failing that, `traceId`) of a JSON Ledger API
232/// error body, when present.
233fn http_correlation_id(body: &str) -> Option<String> {
234    let body: serde_json::Value = serde_json::from_str(body).ok()?;
235    ["correlationId", "traceId"]
236        .iter()
237        .find_map(|key| Some(body.get(key)?.as_str()?.to_string()))
238}
239
240/// Parse a `"<number> <unit>"` duration as the JSON API spells `retryInfo`
241/// (Scala `Duration#toString`: `"1 second"`, `"5 seconds"`, …).
242fn parse_spelled_duration(text: &str) -> Option<std::time::Duration> {
243    let mut words = text.split_whitespace();
244    let amount: f64 = words.next()?.parse().ok()?;
245    let unit = words.next()?;
246    if words.next().is_some() {
247        return None;
248    }
249    let seconds = match unit.strip_suffix('s').unwrap_or(unit) {
250        "day" => amount * 86_400.0,
251        "hour" => amount * 3_600.0,
252        "minute" => amount * 60.0,
253        "second" => amount,
254        "millisecond" => amount / 1e3,
255        "microsecond" => amount / 1e6,
256        "nanosecond" => amount / 1e9,
257        _ => return None,
258    };
259    (seconds.is_finite() && seconds >= 0.0).then(|| std::time::Duration::from_secs_f64(seconds))
260}
261
262/// Canton's error categories — the coarse classification every Ledger API
263/// error carries (`ErrorInfo.metadata["category"]`), which the [error-code
264/// documentation] defines retryability on. `#[non_exhaustive]`: Canton may add
265/// categories.
266///
267/// The variants are the categories of the Canton 3.x docs, by their stable
268/// numeric ids (13 is a server-log-only warning that never reaches the API).
269///
270/// [error-code documentation]: https://docs.daml.com/canton/reference/error_codes.html
271#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
272#[non_exhaustive]
273pub enum ErrorCategory {
274    /// 1 — a service was momentarily unavailable; the request may or may not
275    /// have been processed. Retry with backoff.
276    TransientServerFailure,
277    /// 2 — contention on shared resources (locks, rate limits, a locked
278    /// contract). Retry with backoff.
279    ContentionOnSharedResources,
280    /// 3 — the request's deadline expired with its outcome unknown. Retry a
281    /// bounded number of times, relying on command deduplication.
282    DeadlineExceededRequestStateUnknown,
283    /// 4 — a system-internal invariant was violated (implementation bug or
284    /// data corruption). Not retriable; needs operator/vendor attention.
285    SystemInternalAssumptionViolated,
286    /// 5 — a potential attack or faulty peer was detected; details are
287    /// deliberately withheld. Not retriable.
288    SecurityAlert,
289    /// 6 — missing or invalid authentication credentials. Not retriable
290    /// until the credentials are fixed.
291    AuthInterceptorInvalidAuthenticationCredentials,
292    /// 7 — authenticated, but not permitted to perform the operation. Not
293    /// retriable until permissions change.
294    InsufficientPermission,
295    /// 8 — the request is invalid regardless of system state (malformed
296    /// arguments, size limits). Not retriable.
297    InvalidIndependentOfSystemState,
298    /// 9 — the current ledger state does not satisfy the request's
299    /// preconditions (Daml interpretation failures land here). Not blindly
300    /// retriable; needs an application-level strategy.
301    InvalidGivenCurrentSystemStateOther,
302    /// 10 — a referenced resource already exists (e.g. a duplicate command).
303    /// Not retriable as-is.
304    InvalidGivenCurrentSystemStateResourceExists,
305    /// 11 — a referenced resource does not exist (contract, package, party).
306    /// Not retriable as-is.
307    InvalidGivenCurrentSystemStateResourceMissing,
308    /// 12 — the request reads past the current ledger end. Retriable: the
309    /// system may naturally progress to make it valid.
310    InvalidGivenCurrentSystemStateSeekAfterEnd,
311    /// 14 — the operation is not implemented / not enabled on this node. Not
312    /// retriable.
313    InternalUnsupportedOperation,
314}
315
316impl ErrorCategory {
317    /// The category for Canton's numeric id, `None` when unrecognized.
318    #[must_use]
319    pub const fn from_i32(id: i32) -> Option<Self> {
320        Some(match id {
321            1 => Self::TransientServerFailure,
322            2 => Self::ContentionOnSharedResources,
323            3 => Self::DeadlineExceededRequestStateUnknown,
324            4 => Self::SystemInternalAssumptionViolated,
325            5 => Self::SecurityAlert,
326            6 => Self::AuthInterceptorInvalidAuthenticationCredentials,
327            7 => Self::InsufficientPermission,
328            8 => Self::InvalidIndependentOfSystemState,
329            9 => Self::InvalidGivenCurrentSystemStateOther,
330            10 => Self::InvalidGivenCurrentSystemStateResourceExists,
331            11 => Self::InvalidGivenCurrentSystemStateResourceMissing,
332            12 => Self::InvalidGivenCurrentSystemStateSeekAfterEnd,
333            14 => Self::InternalUnsupportedOperation,
334            _ => return None,
335        })
336    }
337
338    /// Canton's numeric id for this category.
339    #[must_use]
340    pub const fn as_i32(self) -> i32 {
341        match self {
342            Self::TransientServerFailure => 1,
343            Self::ContentionOnSharedResources => 2,
344            Self::DeadlineExceededRequestStateUnknown => 3,
345            Self::SystemInternalAssumptionViolated => 4,
346            Self::SecurityAlert => 5,
347            Self::AuthInterceptorInvalidAuthenticationCredentials => 6,
348            Self::InsufficientPermission => 7,
349            Self::InvalidIndependentOfSystemState => 8,
350            Self::InvalidGivenCurrentSystemStateOther => 9,
351            Self::InvalidGivenCurrentSystemStateResourceExists => 10,
352            Self::InvalidGivenCurrentSystemStateResourceMissing => 11,
353            Self::InvalidGivenCurrentSystemStateSeekAfterEnd => 12,
354            Self::InternalUnsupportedOperation => 14,
355        }
356    }
357
358    /// Whether the error-code documentation classifies this category as
359    /// retryable (transient failures, contention, unknown-outcome deadlines,
360    /// and reads past the ledger end).
361    #[must_use]
362    pub const fn is_retriable(self) -> bool {
363        matches!(
364            self,
365            Self::TransientServerFailure
366                | Self::ContentionOnSharedResources
367                | Self::DeadlineExceededRequestStateUnknown
368                | Self::InvalidGivenCurrentSystemStateSeekAfterEnd
369        )
370    }
371}
372
373/// Structured `google.rpc.ErrorInfo` details from a gRPC status: the machine-
374/// readable `reason`, its `domain`, and error `metadata`. `#[non_exhaustive]`.
375#[derive(Clone, Debug, Default, PartialEq, Eq)]
376#[non_exhaustive]
377pub struct ErrorInfo {
378    /// Machine-readable error reason (e.g. a Canton/Daml error code).
379    pub reason: String,
380    /// The logical grouping the `reason` belongs to.
381    pub domain: String,
382    /// Additional structured context for the error.
383    pub metadata: std::collections::HashMap<String, String>,
384}
385
386impl From<tonic::Status> for Error {
387    fn from(status: tonic::Status) -> Self {
388        Error::Status(Box::new(status))
389    }
390}
391
392impl From<tonic::transport::Error> for Error {
393    fn from(err: tonic::transport::Error) -> Self {
394        Error::Transport(Box::new(err))
395    }
396}
397
398impl From<serde_json::Error> for Error {
399    fn from(err: serde_json::Error) -> Self {
400        Error::Json(Box::new(err))
401    }
402}
403
404/// SDK-wide result alias. Re-exported by the facade as `canton::Result`.
405pub type Result<T, E = Error> = std::result::Result<T, E>;
406
407#[cfg(test)]
408#[allow(clippy::unwrap_used, clippy::expect_used)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn error_info_is_extracted_from_a_status_and_absent_otherwise() {
414        use tonic_types::{ErrorDetails, StatusExt as _};
415
416        let mut metadata = std::collections::HashMap::new();
417        metadata.insert("resource".to_string(), "contract-1".to_string());
418        let details = ErrorDetails::with_error_info("DUPLICATE_COMMAND", "canton", metadata);
419        let status = tonic::Status::with_error_details(tonic::Code::AlreadyExists, "dup", details);
420
421        let info = Error::from(status)
422            .error_info()
423            .expect("error info present");
424        assert_eq!(info.reason, "DUPLICATE_COMMAND");
425        assert_eq!(info.domain, "canton");
426        assert_eq!(
427            info.metadata.get("resource").map(String::as_str),
428            Some("contract-1")
429        );
430
431        // A status without ErrorInfo, and a non-status error, yield None.
432        assert!(
433            Error::from(tonic::Status::not_found("x"))
434                .error_info()
435                .is_none()
436        );
437        assert!(Error::Timeout.error_info().is_none());
438    }
439
440    /// A synthetic Canton-style status: `ErrorInfo` with a `category` metadata
441    /// entry, `RequestInfo` with the correlation id, and — when `delay` is set —
442    /// a `RetryInfo` recommendation.
443    fn canton_status(
444        code: tonic::Code,
445        category: i32,
446        delay: Option<std::time::Duration>,
447    ) -> tonic::Status {
448        use tonic_types::{ErrorDetails, StatusExt as _};
449
450        let mut metadata = std::collections::HashMap::new();
451        metadata.insert("category".to_string(), category.to_string());
452        let mut details = ErrorDetails::with_error_info("SOME_ERROR_CODE", "participant", metadata);
453        details.set_request_info("corr-1234", "");
454        if let Some(delay) = delay {
455            details.set_retry_info(Some(delay));
456        }
457        tonic::Status::with_error_details(code, "boom", details)
458    }
459
460    #[test]
461    fn the_canton_category_decides_retryability_over_the_grpc_code() {
462        use std::time::Duration;
463
464        // ABORTED is transient by code — but category 10 (resource exists,
465        // e.g. a duplicate change id) says no. The category wins.
466        let err = Error::from(canton_status(tonic::Code::Aborted, 10, None));
467        assert_eq!(
468            err.category(),
469            Some(ErrorCategory::InvalidGivenCurrentSystemStateResourceExists)
470        );
471        assert!(!err.is_retriable());
472
473        // OUT_OF_RANGE is not transient by code — but category 12 (seek past
474        // the ledger end) says retry. The category wins again.
475        let err = Error::from(canton_status(
476            tonic::Code::OutOfRange,
477            12,
478            Some(Duration::from_secs(1)),
479        ));
480        assert_eq!(
481            err.category(),
482            Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
483        );
484        assert!(err.is_retriable());
485        assert_eq!(err.retry_delay(), Some(Duration::from_secs(1)));
486    }
487
488    #[test]
489    fn correlation_id_and_retry_delay_are_extracted() {
490        use std::time::Duration;
491
492        let err = Error::from(canton_status(
493            tonic::Code::Unavailable,
494            1,
495            Some(Duration::from_millis(250)),
496        ));
497        assert_eq!(err.category(), Some(ErrorCategory::TransientServerFailure));
498        assert_eq!(err.correlation_id().as_deref(), Some("corr-1234"));
499        assert_eq!(err.retry_delay(), Some(Duration::from_millis(250)));
500
501        // Plain statuses and non-status errors carry none of it.
502        let plain = Error::from(tonic::Status::unavailable("x"));
503        assert_eq!(plain.category(), None);
504        assert_eq!(plain.correlation_id(), None);
505        assert_eq!(plain.retry_delay(), None);
506        assert_eq!(Error::Timeout.category(), None);
507    }
508
509    #[test]
510    fn statuses_without_a_category_fall_back_to_code_classification() {
511        use tonic_types::{ErrorDetails, StatusExt as _};
512
513        // No details at all: the transient codes still classify.
514        assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
515        assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
516
517        // An unrecognized category id is ignored (forward compatibility), and
518        // the code fallback applies.
519        let err = Error::from(canton_status(tonic::Code::Unavailable, 99, None));
520        assert_eq!(err.category(), None);
521        assert!(err.is_retriable());
522
523        // A bare RetryInfo (no category) is an explicit "retry me", even on a
524        // code the fallback would refuse.
525        let mut details = ErrorDetails::new();
526        details.set_retry_info(Some(std::time::Duration::from_secs(2)));
527        let status =
528            tonic::Status::with_error_details(tonic::Code::FailedPrecondition, "wait", details);
529        assert!(Error::from(status).is_retriable());
530    }
531
532    #[test]
533    fn json_api_error_bodies_classify_by_category() {
534        // A real body captured from a LocalNet participant (JSON Ledger API):
535        // category 12 (seek after end) is retryable although the HTTP status
536        // (400) is not in the transient set — the category verdict wins.
537        let body = r#"{
538            "code": "OFFSET_AFTER_LEDGER_END",
539            "cause": "Begin offset (999999999) is after ledger end (23577)",
540            "correlationId": null,
541            "traceId": "36a33702b2fa7908a7349be166ccfa38",
542            "context": {"participant": "'app-provider'", "category": "12"},
543            "resources": [],
544            "errorCategory": 12,
545            "grpcCodeValue": 11,
546            "retryInfo": "1 second",
547            "definiteAnswer": null
548        }"#;
549        let err = Error::Http {
550            status: 400,
551            body: body.to_string(),
552        };
553        assert_eq!(
554            err.category(),
555            Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
556        );
557        assert!(err.is_retriable());
558        assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(1)));
559        // correlationId is null — falls back to traceId.
560        assert_eq!(
561            err.correlation_id().as_deref(),
562            Some("36a33702b2fa7908a7349be166ccfa38")
563        );
564
565        // A non-retryable category on a retryable-looking HTTP status: the
566        // category still wins (e.g. a 503 whose body says "invalid argument").
567        let err = Error::Http {
568            status: 503,
569            body: r#"{"errorCategory": 8}"#.to_string(),
570        };
571        assert_eq!(
572            err.category(),
573            Some(ErrorCategory::InvalidIndependentOfSystemState)
574        );
575        assert!(!err.is_retriable());
576    }
577
578    #[test]
579    fn non_json_http_bodies_fall_back_to_status_code_classification() {
580        let retriable = Error::Http {
581            status: 503,
582            body: "<html>Service Unavailable</html>".to_string(),
583        };
584        assert!(retriable.is_retriable());
585        assert_eq!(retriable.category(), None);
586        assert_eq!(retriable.retry_delay(), None);
587
588        let terminal = Error::Http {
589            status: 404,
590            body: String::new(),
591        };
592        assert!(!terminal.is_retriable());
593        assert_eq!(terminal.correlation_id(), None);
594    }
595
596    #[test]
597    fn spelled_durations_parse_and_garbage_is_refused() {
598        use std::time::Duration;
599        for (text, expected) in [
600            ("1 second", Duration::from_secs(1)),
601            ("5 seconds", Duration::from_secs(5)),
602            ("250 milliseconds", Duration::from_millis(250)),
603            ("2 minutes", Duration::from_secs(120)),
604            ("1 hour", Duration::from_secs(3600)),
605            ("0.5 seconds", Duration::from_millis(500)),
606        ] {
607            assert_eq!(parse_spelled_duration(text), Some(expected), "{text}");
608        }
609        for bad in ["", "soon", "1", "1 fortnight", "-1 second", "1 second ago"] {
610            assert_eq!(parse_spelled_duration(bad), None, "{bad}");
611        }
612    }
613
614    #[test]
615    fn category_ids_round_trip_and_follow_the_docs_retryability() {
616        for id in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14] {
617            let category = ErrorCategory::from_i32(id).expect("known id");
618            assert_eq!(category.as_i32(), id);
619            // Per the error-code docs: 1, 2, 3 and 12 are the retryable ones.
620            assert_eq!(category.is_retriable(), matches!(id, 1 | 2 | 3 | 12));
621        }
622        assert_eq!(ErrorCategory::from_i32(0), None);
623        // 13 (BackgroundProcessDegradationWarning) never reaches the API.
624        assert_eq!(ErrorCategory::from_i32(13), None);
625        assert_eq!(ErrorCategory::from_i32(15), None);
626    }
627
628    #[test]
629    fn transient_conditions_are_retriable() {
630        assert!(Error::Timeout.is_retriable());
631        assert!(Error::Connection("reset".to_string()).is_retriable());
632        assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
633        assert!(Error::from(tonic::Status::deadline_exceeded("x")).is_retriable());
634        assert!(Error::from(tonic::Status::resource_exhausted("x")).is_retriable());
635        assert!(Error::from(tonic::Status::aborted("x")).is_retriable());
636    }
637
638    #[test]
639    fn transient_http_codes_are_retriable_but_client_codes_are_not() {
640        // The whole 5xx range is transient (per the doc), plus 408/429 — not just
641        // a hand-picked subset. 501/509/511/520 were previously missed.
642        for status in [
643            408, 429, 500, 501, 502, 503, 504, 507, 509, 511, 520, 527, 599,
644        ] {
645            assert!(
646                Error::Http {
647                    status,
648                    body: String::new()
649                }
650                .is_retriable(),
651                "http {status} should be retriable"
652            );
653        }
654        // 4xx (incl. the JSON API's 413 too-large) stay non-retriable.
655        for status in [400, 401, 403, 404, 409, 413, 422] {
656            assert!(
657                !Error::Http {
658                    status,
659                    body: String::new()
660                }
661                .is_retriable(),
662                "http {status} should not be retriable"
663            );
664        }
665    }
666
667    #[test]
668    fn definite_failures_are_not_retriable() {
669        assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
670        assert!(!Error::from(tonic::Status::already_exists("dup")).is_retriable());
671        assert!(!Error::from(tonic::Status::invalid_argument("x")).is_retriable());
672        assert!(!Error::InvalidRequest("x".to_string()).is_retriable());
673        assert!(!Error::Auth("x".to_string()).is_retriable());
674        assert!(
675            !Error::CommandRejected {
676                code: "GrpcStatus".to_string(),
677                message: "boom".to_string()
678            }
679            .is_retriable()
680        );
681        assert!(!Error::UnexpectedResponse("x".to_string()).is_retriable());
682    }
683
684    #[test]
685    fn code_is_exposed_only_for_status_errors() {
686        assert_eq!(
687            Error::from(tonic::Status::not_found("x")).code(),
688            Some(tonic::Code::NotFound)
689        );
690        assert_eq!(Error::Timeout.code(), None);
691        assert_eq!(Error::Connection("x".to_string()).code(), None);
692        assert_eq!(
693            Error::Http {
694                status: 503,
695                body: String::new()
696            }
697            .code(),
698            None
699        );
700    }
701
702    #[test]
703    fn display_messages_are_lowercase_and_informative() {
704        assert_eq!(Error::Timeout.to_string(), "operation timed out");
705        assert_eq!(
706            Error::InvalidRequest("bad uri".to_string()).to_string(),
707            "invalid request: bad uri"
708        );
709        assert_eq!(
710            Error::Auth("token expired".to_string()).to_string(),
711            "authentication failed: token expired"
712        );
713        assert_eq!(
714            Error::Http {
715                status: 503,
716                body: "down".to_string()
717            }
718            .to_string(),
719            "http 503: down"
720        );
721        assert_eq!(
722            Error::CommandRejected {
723                code: "INVALID_ARGUMENT".to_string(),
724                message: "nope".to_string()
725            }
726            .to_string(),
727            "command rejected (INVALID_ARGUMENT): nope"
728        );
729    }
730}