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 category of the status's `ErrorInfo`, when present and recognized.
297/// Whether a status is a connection that died *mid-call*, rather than a verdict
298/// the participant returned.
299///
300/// tonic maps a broken stream or a dropped unary connection to a `Status` with
301/// code `Unknown` (message "transport error" / "h2 protocol error"), not to a
302/// `transport::Error` — that variant is only for connection *establishment*. So
303/// a real drop reaches [`Error::is_retriable`] as a category-less `Unknown` and,
304/// without this, is treated as terminal: the resumable stream gives up on the
305/// first blip and the retry pipeline never fires on the most ordinary failure
306/// there is.
307///
308/// The signal is the source chain, not the message text (which is
309/// tonic-version-specific). A client-side transport failure carries a `source`
310/// — tonic built the status *from* the underlying h2/hyper/io error. A status
311/// decoded from response trailers, which is how a genuine application `Unknown`
312/// arrives, has none. So `Unknown` (or `Internal`, tonic's other transport-error
313/// code) with a source present is a dropped connection; the same code without a
314/// source is the server's own answer and stays terminal.
315fn is_transport_death(status: &tonic::Status) -> bool {
316 use std::error::Error as _;
317 matches!(status.code(), tonic::Code::Unknown | tonic::Code::Internal)
318 && status.source().is_some()
319}
320
321fn status_category(status: &tonic::Status) -> Option<ErrorCategory> {
322 use tonic_types::StatusExt as _;
323 let info = status.get_details_error_info()?;
324 ErrorCategory::from_i32(info.metadata.get("category")?.parse().ok()?)
325}
326
327/// The `RetryInfo.retry_delay` of a status, when present.
328fn status_retry_delay(status: &tonic::Status) -> Option<std::time::Duration> {
329 use tonic_types::StatusExt as _;
330 status.get_details_retry_info()?.retry_delay
331}
332
333/// The `errorCategory` of a JSON Ledger API error body, when present and
334/// recognized. Non-JSON bodies (a proxy's HTML error page, a token endpoint)
335/// simply yield `None`.
336fn http_category(body: &str) -> Option<ErrorCategory> {
337 let body: serde_json::Value = serde_json::from_str(body).ok()?;
338 let id = body.get("errorCategory")?.as_i64()?;
339 ErrorCategory::from_i32(i32::try_from(id).ok()?)
340}
341
342/// The `retryInfo` of a JSON Ledger API error body, when present. The field
343/// is a human-readable duration (e.g. `"1 second"`, `"250 milliseconds"`).
344fn http_retry_delay(body: &str) -> Option<std::time::Duration> {
345 let body: serde_json::Value = serde_json::from_str(body).ok()?;
346 parse_spelled_duration(body.get("retryInfo")?.as_str()?)
347}
348
349/// The `resources` of a JSON Ledger API error body: an array of `[type, name]`
350/// pairs (e.g. `[["ErrorResource(CONTRACT_ID)", "00abc…"]]`). Entries that are
351/// not a pair of strings are skipped rather than failing the whole read — a
352/// diagnostic must not itself become an error.
353fn http_resource_info(body: &str) -> Vec<ResourceInfo> {
354 let Ok(body) = serde_json::from_str::<serde_json::Value>(body) else {
355 return Vec::new();
356 };
357 let Some(resources) = body.get("resources").and_then(serde_json::Value::as_array) else {
358 return Vec::new();
359 };
360 resources
361 .iter()
362 .filter_map(|entry| {
363 let pair = entry.as_array()?;
364 Some(ResourceInfo {
365 resource_type: pair.first()?.as_str()?.to_string(),
366 resource_name: pair.get(1)?.as_str()?.to_string(),
367 owner: String::new(),
368 description: String::new(),
369 })
370 })
371 .collect()
372}
373
374/// The `grpcCodeValue` of a JSON Ledger API error body — the numeric gRPC code
375/// the participant would have answered with over the other transport.
376///
377/// Only reported when the field is actually there: `tonic::Code::from` maps an
378/// unknown number to `Unknown`, and manufacturing `Unknown` for a body that
379/// never carried a code would claim a verdict the participant never gave.
380fn http_grpc_code(body: &str) -> Option<tonic::Code> {
381 let body: serde_json::Value = serde_json::from_str(body).ok()?;
382 let value = body.get("grpcCodeValue")?.as_i64()?;
383 Some(tonic::Code::from(i32::try_from(value).ok()?))
384}
385
386/// The `code` and `context` of a JSON Ledger API error body, as the
387/// `ErrorInfo` the gRPC lane carries as a status detail.
388///
389/// `"NA"` is Canton's placeholder on a redacted (security-sensitive) error and
390/// is treated as no id at all — reporting it would hand the caller a string
391/// that looks like an error code and matches nothing.
392///
393/// A context value that is not a string keeps its JSON spelling rather than
394/// being dropped: `metadata` is a string map, and losing a field silently is
395/// worse than rendering it.
396fn http_error_info(body: &str) -> Option<ErrorInfo> {
397 let body: serde_json::Value = serde_json::from_str(body).ok()?;
398 let reason = body.get("code")?.as_str()?;
399 if reason.is_empty() || reason == "NA" {
400 return None;
401 }
402 let metadata = body
403 .get("context")
404 .and_then(serde_json::Value::as_object)
405 .map(|context| {
406 context
407 .iter()
408 .map(|(key, value)| {
409 let value = match value.as_str() {
410 Some(text) => text.to_string(),
411 None => value.to_string(),
412 };
413 (key.clone(), value)
414 })
415 .collect()
416 })
417 .unwrap_or_default();
418 Some(ErrorInfo {
419 reason: reason.to_string(),
420 // Canton sets no domain on either transport, and the JSON body has no
421 // field for one. Empty here says the same thing gRPC says.
422 domain: String::new(),
423 metadata,
424 })
425}
426
427/// The `correlationId` (or, failing that, `traceId`) of a JSON Ledger API
428/// error body, when present.
429fn http_correlation_id(body: &str) -> Option<String> {
430 let body: serde_json::Value = serde_json::from_str(body).ok()?;
431 ["correlationId", "traceId"]
432 .iter()
433 .find_map(|key| Some(body.get(key)?.as_str()?.to_string()))
434}
435
436/// Parse a `"<number> <unit>"` duration as the JSON API spells `retryInfo`
437/// (Scala `Duration#toString`: `"1 second"`, `"5 seconds"`, …).
438fn parse_spelled_duration(text: &str) -> Option<std::time::Duration> {
439 let mut words = text.split_whitespace();
440 let amount: f64 = words.next()?.parse().ok()?;
441 let unit = words.next()?;
442 if words.next().is_some() {
443 return None;
444 }
445 let seconds = match unit.strip_suffix('s').unwrap_or(unit) {
446 "day" => amount * 86_400.0,
447 "hour" => amount * 3_600.0,
448 "minute" => amount * 60.0,
449 "second" => amount,
450 "millisecond" => amount / 1e3,
451 "microsecond" => amount / 1e6,
452 "nanosecond" => amount / 1e9,
453 _ => return None,
454 };
455 // `from_secs_f64` panics on a value a `Duration` cannot hold, and this
456 // number arrives from the server: a `retryInfo` of `"1e300 seconds"` — or
457 // an ordinary-looking `"1e15 days"` — would abort the caller's process
458 // inside error classification, the one place that must stay infallible.
459 // `try_from_secs_f64` rejects those the same way it rejects the NaN and
460 // negative values this guarded against before. What it accepts is then
461 // bounded: a `Duration` near its maximum survives parsing but not the
462 // arithmetic a retry schedule does on it, and no participant means
463 // "retry in a hundred years" anyway.
464 std::time::Duration::try_from_secs_f64(seconds)
465 .ok()
466 .map(|delay| delay.min(MAX_SPELLED_DELAY))
467}
468
469/// The longest `retryInfo` a JSON body can spell: anything above a day is not
470/// a schedule, it is a number the server put there, and it is clamped.
471const MAX_SPELLED_DELAY: std::time::Duration = std::time::Duration::from_secs(86_400);
472
473/// Canton's error categories — the coarse classification every Ledger API
474/// error carries (`ErrorInfo.metadata["category"]`), which the [error-code
475/// documentation] defines retryability on. `#[non_exhaustive]`: Canton may add
476/// categories.
477///
478/// The variants are the categories of the Canton 3.x docs, by their stable
479/// numeric ids (13 is a server-log-only warning that never reaches the API).
480///
481/// [error-code documentation]: https://docs.daml.com/canton/reference/error_codes.html
482#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
483#[non_exhaustive]
484pub enum ErrorCategory {
485 /// 1 — a service was momentarily unavailable; the request may or may not
486 /// have been processed. Retry with backoff.
487 TransientServerFailure,
488 /// 2 — contention on shared resources (locks, rate limits, a locked
489 /// contract). Retry with backoff.
490 ContentionOnSharedResources,
491 /// 3 — the request's deadline expired with its outcome unknown. Retry a
492 /// bounded number of times, relying on command deduplication.
493 DeadlineExceededRequestStateUnknown,
494 /// 4 — a system-internal invariant was violated (implementation bug or
495 /// data corruption). Not retriable; needs operator/vendor attention.
496 SystemInternalAssumptionViolated,
497 /// 5 — a potential attack or faulty peer was detected; details are
498 /// deliberately withheld. Not retriable.
499 SecurityAlert,
500 /// 6 — missing or invalid authentication credentials. Not retriable
501 /// until the credentials are fixed.
502 AuthInterceptorInvalidAuthenticationCredentials,
503 /// 7 — authenticated, but not permitted to perform the operation. Not
504 /// retriable until permissions change.
505 InsufficientPermission,
506 /// 8 — the request is invalid regardless of system state (malformed
507 /// arguments, size limits). Not retriable.
508 InvalidIndependentOfSystemState,
509 /// 9 — the current ledger state does not satisfy the request's
510 /// preconditions (Daml interpretation failures land here). Not blindly
511 /// retriable; needs an application-level strategy.
512 InvalidGivenCurrentSystemStateOther,
513 /// 10 — a referenced resource already exists (e.g. a duplicate command).
514 /// Not retriable as-is.
515 InvalidGivenCurrentSystemStateResourceExists,
516 /// 11 — a referenced resource does not exist (contract, package, party).
517 /// Not retriable as-is.
518 InvalidGivenCurrentSystemStateResourceMissing,
519 /// 12 — the request reads past the current ledger end. Retriable: the
520 /// system may naturally progress to make it valid.
521 InvalidGivenCurrentSystemStateSeekAfterEnd,
522 /// 14 — the operation is not implemented / not enabled on this node. Not
523 /// retriable.
524 InternalUnsupportedOperation,
525}
526
527/// The std spelling of [`ErrorCategory::from_i32`], so the category can be
528/// used with generic conversion bounds. The inherent method stays: it is the
529/// documented name and the `Option` shape matches "unknown id" better than an
530/// error type would.
531impl TryFrom<i32> for ErrorCategory {
532 type Error = i32;
533 fn try_from(id: i32) -> std::result::Result<Self, i32> {
534 Self::from_i32(id).ok_or(id)
535 }
536}
537
538impl From<ErrorCategory> for i32 {
539 fn from(category: ErrorCategory) -> i32 {
540 category.as_i32()
541 }
542}
543
544impl ErrorCategory {
545 /// The category for Canton's numeric id, `None` when unrecognized.
546 #[must_use]
547 pub const fn from_i32(id: i32) -> Option<Self> {
548 Some(match id {
549 1 => Self::TransientServerFailure,
550 2 => Self::ContentionOnSharedResources,
551 3 => Self::DeadlineExceededRequestStateUnknown,
552 4 => Self::SystemInternalAssumptionViolated,
553 5 => Self::SecurityAlert,
554 6 => Self::AuthInterceptorInvalidAuthenticationCredentials,
555 7 => Self::InsufficientPermission,
556 8 => Self::InvalidIndependentOfSystemState,
557 9 => Self::InvalidGivenCurrentSystemStateOther,
558 10 => Self::InvalidGivenCurrentSystemStateResourceExists,
559 11 => Self::InvalidGivenCurrentSystemStateResourceMissing,
560 12 => Self::InvalidGivenCurrentSystemStateSeekAfterEnd,
561 14 => Self::InternalUnsupportedOperation,
562 _ => return None,
563 })
564 }
565
566 /// Canton's numeric id for this category.
567 #[must_use]
568 pub const fn as_i32(self) -> i32 {
569 match self {
570 Self::TransientServerFailure => 1,
571 Self::ContentionOnSharedResources => 2,
572 Self::DeadlineExceededRequestStateUnknown => 3,
573 Self::SystemInternalAssumptionViolated => 4,
574 Self::SecurityAlert => 5,
575 Self::AuthInterceptorInvalidAuthenticationCredentials => 6,
576 Self::InsufficientPermission => 7,
577 Self::InvalidIndependentOfSystemState => 8,
578 Self::InvalidGivenCurrentSystemStateOther => 9,
579 Self::InvalidGivenCurrentSystemStateResourceExists => 10,
580 Self::InvalidGivenCurrentSystemStateResourceMissing => 11,
581 Self::InvalidGivenCurrentSystemStateSeekAfterEnd => 12,
582 Self::InternalUnsupportedOperation => 14,
583 }
584 }
585
586 /// Whether the error-code documentation classifies this category as
587 /// retryable (transient failures, contention, unknown-outcome deadlines,
588 /// and reads past the ledger end).
589 #[must_use]
590 pub const fn is_retriable(self) -> bool {
591 matches!(
592 self,
593 Self::TransientServerFailure
594 | Self::ContentionOnSharedResources
595 | Self::DeadlineExceededRequestStateUnknown
596 | Self::InvalidGivenCurrentSystemStateSeekAfterEnd
597 )
598 }
599}
600
601/// Structured `google.rpc.ErrorInfo` details from a gRPC status: the machine-
602/// readable `reason`, its `domain`, and error `metadata`. `#[non_exhaustive]`.
603#[derive(Clone, Debug, Default, PartialEq, Eq)]
604#[non_exhaustive]
605pub struct ErrorInfo {
606 /// Machine-readable error reason (e.g. a Canton/Daml error code).
607 pub reason: String,
608 /// The logical grouping the `reason` belongs to.
609 pub domain: String,
610 /// Additional structured context for the error.
611 pub metadata: std::collections::HashMap<String, String>,
612}
613
614/// A resource a failure is about, from a `google.rpc.ResourceInfo` detail on a
615/// gRPC status or an entry of a JSON API error body's `resources`.
616/// `#[non_exhaustive]`.
617#[derive(Clone, Debug, Default, PartialEq, Eq)]
618#[non_exhaustive]
619pub struct ResourceInfo {
620 /// What kind of thing it is, as Canton names it (e.g.
621 /// `ErrorResource(CONTRACT_ID)`).
622 pub resource_type: String,
623 /// The identifier itself — a contract id, package name, party, …
624 pub resource_name: String,
625 /// The owner, when the server reports one. Empty on the JSON transport,
626 /// whose `resources` entries carry only the type and the name.
627 pub owner: String,
628 /// A human-readable note, when the server reports one. Empty on the JSON
629 /// transport for the same reason.
630 pub description: String,
631}
632
633/// Most transport errors keep the sentence that matters in their source chain
634/// and print only a generic outer line: `reqwest` says `error sending request
635/// for url (…)` while `invalid peer certificate: UnknownIssuer` sits one level
636/// down, and `tokio_postgres` says `db error` over `FATAL: password
637/// authentication failed`. Reporting only the outer message hides the one
638/// thing an operator needs.
639///
640/// Lives here because four crates were each losing it separately, and three of
641/// them had grown their own private copy of this function before anyone noticed
642/// the fourth still had none.
643#[must_use]
644pub fn chain(error: &dyn std::error::Error) -> String {
645 let mut message = error.to_string();
646 let mut source = error.source();
647 while let Some(cause) = source {
648 message.push_str(": ");
649 message.push_str(&cause.to_string());
650 source = cause.source();
651 }
652 message
653}
654
655impl From<tonic::Status> for Error {
656 fn from(status: tonic::Status) -> Self {
657 Error::Status(Box::new(status))
658 }
659}
660
661impl From<tonic::transport::Error> for Error {
662 fn from(err: tonic::transport::Error) -> Self {
663 Error::Transport(Box::new(err))
664 }
665}
666
667impl From<serde_json::Error> for Error {
668 fn from(err: serde_json::Error) -> Self {
669 Error::Json(Box::new(err))
670 }
671}
672
673/// SDK-wide result alias. Re-exported by the facade as `canton::Result`.
674pub type Result<T, E = Error> = std::result::Result<T, E>;
675
676#[cfg(test)]
677#[allow(clippy::unwrap_used, clippy::expect_used)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn error_info_is_extracted_from_a_status_and_absent_otherwise() {
683 use tonic_types::{ErrorDetails, StatusExt as _};
684
685 let mut metadata = std::collections::HashMap::new();
686 metadata.insert("resource".to_string(), "contract-1".to_string());
687 let details = ErrorDetails::with_error_info("DUPLICATE_COMMAND", "canton", metadata);
688 let status = tonic::Status::with_error_details(tonic::Code::AlreadyExists, "dup", details);
689
690 let info = Error::from(status)
691 .error_info()
692 .expect("error info present");
693 assert_eq!(info.reason, "DUPLICATE_COMMAND");
694 assert_eq!(info.domain, "canton");
695 assert_eq!(
696 info.metadata.get("resource").map(String::as_str),
697 Some("contract-1")
698 );
699
700 // A status without ErrorInfo, and a non-status error, yield None.
701 assert!(
702 Error::from(tonic::Status::not_found("x"))
703 .error_info()
704 .is_none()
705 );
706 assert!(Error::Timeout.error_info().is_none());
707 }
708
709 /// A synthetic Canton-style status: `ErrorInfo` with a `category` metadata
710 /// entry, `RequestInfo` with the correlation id, and — when `delay` is set —
711 /// a `RetryInfo` recommendation.
712 fn canton_status(
713 code: tonic::Code,
714 category: i32,
715 delay: Option<std::time::Duration>,
716 ) -> tonic::Status {
717 use tonic_types::{ErrorDetails, StatusExt as _};
718
719 let mut metadata = std::collections::HashMap::new();
720 metadata.insert("category".to_string(), category.to_string());
721 let mut details = ErrorDetails::with_error_info("SOME_ERROR_CODE", "participant", metadata);
722 details.set_request_info("corr-1234", "");
723 if let Some(delay) = delay {
724 details.set_retry_info(Some(delay));
725 }
726 tonic::Status::with_error_details(code, "boom", details)
727 }
728
729 #[test]
730 fn the_canton_category_decides_retryability_over_the_grpc_code() {
731 use std::time::Duration;
732
733 // ABORTED is transient by code — but category 10 (resource exists,
734 // e.g. a duplicate change id) says no. The category wins.
735 let err = Error::from(canton_status(tonic::Code::Aborted, 10, None));
736 assert_eq!(
737 err.category(),
738 Some(ErrorCategory::InvalidGivenCurrentSystemStateResourceExists)
739 );
740 assert!(!err.is_retriable());
741
742 // OUT_OF_RANGE is not transient by code — but category 12 (seek past
743 // the ledger end) says retry. The category wins again.
744 let err = Error::from(canton_status(
745 tonic::Code::OutOfRange,
746 12,
747 Some(Duration::from_secs(1)),
748 ));
749 assert_eq!(
750 err.category(),
751 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
752 );
753 assert!(err.is_retriable());
754 assert_eq!(err.retry_delay(), Some(Duration::from_secs(1)));
755 }
756
757 #[test]
758 fn correlation_id_and_retry_delay_are_extracted() {
759 use std::time::Duration;
760
761 let err = Error::from(canton_status(
762 tonic::Code::Unavailable,
763 1,
764 Some(Duration::from_millis(250)),
765 ));
766 assert_eq!(err.category(), Some(ErrorCategory::TransientServerFailure));
767 assert_eq!(err.correlation_id().as_deref(), Some("corr-1234"));
768 assert_eq!(err.retry_delay(), Some(Duration::from_millis(250)));
769
770 // Plain statuses and non-status errors carry none of it.
771 let plain = Error::from(tonic::Status::unavailable("x"));
772 assert_eq!(plain.category(), None);
773 assert_eq!(plain.correlation_id(), None);
774 assert_eq!(plain.retry_delay(), None);
775 assert_eq!(Error::Timeout.category(), None);
776 }
777
778 #[test]
779 fn statuses_without_a_category_fall_back_to_code_classification() {
780 use tonic_types::{ErrorDetails, StatusExt as _};
781
782 // No details at all: the transient codes still classify.
783 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
784 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
785
786 // An unrecognized category id is ignored (forward compatibility), and
787 // the code fallback applies.
788 let err = Error::from(canton_status(tonic::Code::Unavailable, 99, None));
789 assert_eq!(err.category(), None);
790 assert!(err.is_retriable());
791
792 // A bare RetryInfo (no category) does not turn a permanent code into
793 // a retriable one: it says when, not whether, and a participant that
794 // attaches it to FAILED_PRECONDITION is asking for a retry it will
795 // refuse again. The delay is still reported for callers who ask.
796 let mut details = ErrorDetails::new();
797 details.set_retry_info(Some(std::time::Duration::from_secs(2)));
798 let status =
799 tonic::Status::with_error_details(tonic::Code::FailedPrecondition, "wait", details);
800 let err = Error::from(status);
801 assert!(!err.is_retriable());
802 assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(2)));
803 // …while on a transient code the same detail rides along as before.
804 let mut details = ErrorDetails::new();
805 details.set_retry_info(Some(std::time::Duration::from_secs(2)));
806 let status = tonic::Status::with_error_details(tonic::Code::Unavailable, "wait", details);
807 assert!(Error::from(status).is_retriable());
808 }
809
810 #[test]
811 fn a_retry_info_on_a_permanent_http_status_is_not_retriable() {
812 let err = Error::Http {
813 status: 400,
814 body: r#"{"code":"INVALID_ARGUMENT","cause":"bad","retryInfo":"1 second"}"#.to_string(),
815 url: None,
816 };
817 assert!(!err.is_retriable());
818 assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(1)));
819 let err = Error::Http {
820 status: 503,
821 body: r#"{"cause":"busy","retryInfo":"1 second"}"#.to_string(),
822 url: None,
823 };
824 assert!(err.is_retriable());
825 }
826
827 #[test]
828 fn a_spelled_delay_is_bounded() {
829 use std::time::Duration;
830 // Below Duration::MAX these parse, and come back clamped to the cap.
831 for text in [
832 "1.8e19 seconds",
833 "1.3e19 seconds",
834 "2 days",
835 "48 hours",
836 "200000 days",
837 ] {
838 let parsed = parse_spelled_duration(text).unwrap_or_else(|| panic!("{text}"));
839 assert!(parsed <= MAX_SPELLED_DELAY, "{text}: {parsed:?}");
840 // Whatever it is, the retry schedule's arithmetic on it cannot overflow.
841 let _ = parsed.mul_f64(1.5);
842 }
843 // Beyond what a Duration can hold at all, the parse is refused as before.
844 assert_eq!(parse_spelled_duration("1e15 days"), None);
845 assert_eq!(
846 parse_spelled_duration("2 days"),
847 Some(Duration::from_secs(86_400))
848 );
849 assert_eq!(
850 parse_spelled_duration("12 hours"),
851 Some(Duration::from_secs(43_200))
852 );
853 }
854
855 #[test]
856 fn json_api_error_bodies_classify_by_category() {
857 // A real body captured from a LocalNet participant (JSON Ledger API):
858 // category 12 (seek after end) is retryable although the HTTP status
859 // (400) is not in the transient set — the category verdict wins.
860 let body = r#"{
861 "code": "OFFSET_AFTER_LEDGER_END",
862 "cause": "Begin offset (999999999) is after ledger end (23577)",
863 "correlationId": null,
864 "traceId": "36a33702b2fa7908a7349be166ccfa38",
865 "context": {"participant": "'app-provider'", "category": "12"},
866 "resources": [],
867 "errorCategory": 12,
868 "grpcCodeValue": 11,
869 "retryInfo": "1 second",
870 "definiteAnswer": null
871 }"#;
872 let err = Error::Http {
873 status: 400,
874 body: body.to_string(),
875 url: None,
876 };
877 assert_eq!(
878 err.category(),
879 Some(ErrorCategory::InvalidGivenCurrentSystemStateSeekAfterEnd)
880 );
881 assert!(err.is_retriable());
882 assert_eq!(err.retry_delay(), Some(std::time::Duration::from_secs(1)));
883 // correlationId is null — falls back to traceId.
884 assert_eq!(
885 err.correlation_id().as_deref(),
886 Some("36a33702b2fa7908a7349be166ccfa38")
887 );
888
889 // The same failure over gRPC yields OutOfRange and an ErrorInfo naming
890 // OFFSET_AFTER_LEDGER_END. The JSON body spells both — `grpcCodeValue`
891 // and `code`/`context` — so both accessors must answer here too, or a
892 // caller on this transport is left with the string matching
893 // `error_info` exists to replace.
894 assert_eq!(err.code(), Some(tonic::Code::OutOfRange));
895 let info = err.error_info().expect("the body names the error");
896 assert_eq!(info.reason, "OFFSET_AFTER_LEDGER_END");
897 assert_eq!(
898 info.metadata.get("category").map(String::as_str),
899 Some("12")
900 );
901 assert_eq!(
902 info.metadata.get("participant").map(String::as_str),
903 Some("'app-provider'")
904 );
905
906 // A non-retryable category on a retryable-looking HTTP status: the
907 // category still wins (e.g. a 503 whose body says "invalid argument").
908 let err = Error::Http {
909 status: 503,
910 body: r#"{"errorCategory": 8}"#.to_string(),
911 url: None,
912 };
913 assert_eq!(
914 err.category(),
915 Some(ErrorCategory::InvalidIndependentOfSystemState)
916 );
917 assert!(!err.is_retriable());
918 }
919
920 #[test]
921 fn non_json_http_bodies_fall_back_to_status_code_classification() {
922 let retriable = Error::Http {
923 status: 503,
924 body: "<html>Service Unavailable</html>".to_string(),
925 url: None,
926 };
927 assert!(retriable.is_retriable());
928 assert_eq!(retriable.category(), None);
929 assert_eq!(retriable.retry_delay(), None);
930
931 let terminal = Error::Http {
932 status: 404,
933 body: String::new(),
934 url: None,
935 };
936 assert!(!terminal.is_retriable());
937 assert_eq!(terminal.correlation_id(), None);
938 }
939
940 #[test]
941 fn spelled_durations_parse_and_garbage_is_refused() {
942 use std::time::Duration;
943 for (text, expected) in [
944 ("1 second", Duration::from_secs(1)),
945 ("5 seconds", Duration::from_secs(5)),
946 ("250 milliseconds", Duration::from_millis(250)),
947 ("2 minutes", Duration::from_secs(120)),
948 ("1 hour", Duration::from_secs(3600)),
949 ("0.5 seconds", Duration::from_millis(500)),
950 // The rarer units and the day arm: mutation testing showed these
951 // conversion factors were never exercised, so a wrong multiplier
952 // in day/microsecond/nanosecond would have passed unnoticed.
953 ("0.5 days", Duration::from_secs(43_200)),
954 ("1 day", Duration::from_secs(86_400)),
955 ("500 microseconds", Duration::from_micros(500)),
956 ("250 nanoseconds", Duration::from_nanos(250)),
957 ] {
958 assert_eq!(parse_spelled_duration(text), Some(expected), "{text}");
959 }
960 // A server-supplied number too large for a `Duration` is refused, not
961 // panicked on: this runs while an error is being classified, and the
962 // value is whatever the participant put in the body.
963 for bad in [
964 "",
965 "soon",
966 "1",
967 "1 fortnight",
968 "-1 second",
969 "1 second ago",
970 "1e300 seconds",
971 "1e300 days",
972 "NaN seconds",
973 "inf seconds",
974 ] {
975 assert_eq!(parse_spelled_duration(bad), None, "{bad}");
976 }
977 }
978
979 #[test]
980 fn category_ids_round_trip_and_follow_the_docs_retryability() {
981 for id in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14] {
982 let category = ErrorCategory::from_i32(id).expect("known id");
983 assert_eq!(category.as_i32(), id);
984 // Per the error-code docs: 1, 2, 3 and 12 are the retryable ones.
985 assert_eq!(category.is_retriable(), matches!(id, 1 | 2 | 3 | 12));
986 }
987 assert_eq!(ErrorCategory::from_i32(0), None);
988 // 13 (BackgroundProcessDegradationWarning) never reaches the API.
989 assert_eq!(ErrorCategory::from_i32(13), None);
990 assert_eq!(ErrorCategory::from_i32(15), None);
991 }
992
993 /// Canton strips the detail from security-sensitive failures — auth,
994 /// permission, internal — and tells the caller to ask the operator. Every
995 /// accessor must degrade to "nothing" rather than mislead, and the one
996 /// thing that survives must keep surviving: without the correlation id
997 /// there is no way to ask.
998 ///
999 /// Both fixtures are the real shapes, taken from a live 3.5.7 participant
1000 /// answering with an invalid token.
1001 #[test]
1002 fn a_redacted_status_yields_nothing_except_the_correlation_id() {
1003 use tonic_types::{ErrorDetails, StatusExt as _};
1004
1005 // gRPC: no ErrorInfo, no RetryInfo, no ResourceInfo — only RequestInfo.
1006 let mut details = ErrorDetails::new();
1007 details.set_request_info("93199811c5b2090c51cf45fe8c88060c", "");
1008 let status = tonic::Status::with_error_details(
1009 tonic::Code::Unauthenticated,
1010 "An error occurred. Please contact the operator and inquire about the request \
1011 93199811c5b2090c51cf45fe8c88060c",
1012 details,
1013 );
1014 let err = Error::from(status);
1015
1016 assert_eq!(err.category(), None, "a redacted status has no category");
1017 assert_eq!(err.error_info(), None);
1018 assert!(err.resource_info().is_empty());
1019 assert_eq!(err.retry_delay(), None);
1020 assert_eq!(
1021 err.correlation_id().as_deref(),
1022 Some("93199811c5b2090c51cf45fe8c88060c"),
1023 "the correlation id is the only actionable thing left"
1024 );
1025 // Classification falls back to the code, which is the right answer:
1026 // bad credentials will not become good on a retry.
1027 assert!(!err.is_retriable());
1028
1029 // JSON: the category is reported as -1 rather than omitted, which must
1030 // not be mistaken for a real category.
1031 let body = r#"{"code":"NA","cause":"An error occurred. Please contact the operator",
1032 "errorCategory":-1,"retryInfo":null,"resources":[],
1033 "correlationId":"41f217564e4e76f6cbc853a94a82fa80",
1034 "traceId":"41f217564e4e76f6cbc853a94a82fa80"}"#;
1035 let err = Error::Http {
1036 status: 401,
1037 body: body.to_string(),
1038 url: None,
1039 };
1040
1041 assert_eq!(err.category(), None, "-1 is not a category");
1042 assert!(err.resource_info().is_empty());
1043 assert_eq!(err.retry_delay(), None);
1044 // `"NA"` is Canton saying it withheld the id, not an id spelled "NA".
1045 assert_eq!(err.error_info(), None, "\"NA\" is not an error id");
1046 assert_eq!(
1047 err.correlation_id().as_deref(),
1048 Some("41f217564e4e76f6cbc853a94a82fa80")
1049 );
1050 assert!(!err.is_retriable(), "401 is not transient");
1051 }
1052
1053 #[test]
1054 fn resource_info_names_what_the_error_is_about() {
1055 use tonic_types::{ErrorDetails, StatusExt as _};
1056
1057 // gRPC: one `google.rpc.ResourceInfo` detail is all tonic_types models.
1058 let mut details = ErrorDetails::new();
1059 details.set_resource_info("ErrorResource(CONTRACT_ID)", "00abc", "alice", "not found");
1060 let status = tonic::Status::with_error_details(tonic::Code::NotFound, "gone", details);
1061 let found = Error::from(status).resource_info();
1062 assert_eq!(found.len(), 1);
1063 assert_eq!(found[0].resource_type, "ErrorResource(CONTRACT_ID)");
1064 assert_eq!(found[0].resource_name, "00abc");
1065 assert_eq!(found[0].owner, "alice");
1066
1067 // A status with no such detail yields nothing, not a default-filled entry.
1068 assert!(
1069 Error::from(tonic::Status::not_found("x"))
1070 .resource_info()
1071 .is_empty()
1072 );
1073 assert!(Error::Timeout.resource_info().is_empty());
1074 }
1075
1076 #[test]
1077 fn resource_info_reads_the_json_apis_resources_array() {
1078 // The body shape is verbatim from a live Canton 3.5.7 participant
1079 // answering an exercise on a contract that does not exist.
1080 let body = r#"{"code":"CONTRACT_NOT_FOUND","cause":"…","errorCategory":11,
1081 "resources":[["ErrorResource(CONTRACT_ID)","00ababab"]],"retryInfo":null}"#;
1082 let err = Error::Http {
1083 status: 404,
1084 body: body.to_string(),
1085 url: None,
1086 };
1087 let found = err.resource_info();
1088 assert_eq!(found.len(), 1);
1089 assert_eq!(found[0].resource_type, "ErrorResource(CONTRACT_ID)");
1090 assert_eq!(found[0].resource_name, "00ababab");
1091 // The JSON pairs carry no owner or description; they are empty, not absent.
1092 assert!(found[0].owner.is_empty());
1093
1094 // Several resources — contention names more than one, which is why this
1095 // returns a Vec and not an Option.
1096 let many = Error::Http {
1097 status: 409,
1098 body: r#"{"resources":[["A","1"],["B","2"]]}"#.to_string(),
1099 url: None,
1100 };
1101 assert_eq!(many.resource_info().len(), 2);
1102
1103 // A malformed entry is skipped, and an empty/absent array is not an error:
1104 // a diagnostic accessor must never itself fail.
1105 let ragged = Error::Http {
1106 status: 500,
1107 body: r#"{"resources":[["A"],["B","2"],42,null]}"#.to_string(),
1108 url: None,
1109 };
1110 assert_eq!(ragged.resource_info().len(), 1);
1111 for body in [r#"{"resources":[]}"#, "{}", "not json at all", ""] {
1112 let err = Error::Http {
1113 status: 500,
1114 body: body.to_string(),
1115 url: None,
1116 };
1117 assert!(err.resource_info().is_empty(), "{body}");
1118 }
1119 }
1120
1121 #[test]
1122 fn transient_conditions_are_retriable() {
1123 assert!(Error::Timeout.is_retriable());
1124 assert!(Error::Connection("reset".to_string()).is_retriable());
1125 assert!(Error::from(tonic::Status::unavailable("x")).is_retriable());
1126 assert!(Error::from(tonic::Status::deadline_exceeded("x")).is_retriable());
1127 assert!(Error::from(tonic::Status::resource_exhausted("x")).is_retriable());
1128 assert!(Error::from(tonic::Status::aborted("x")).is_retriable());
1129 }
1130
1131 #[test]
1132 fn a_connection_that_dies_mid_call_is_retriable_but_a_server_unknown_is_not() {
1133 use std::io;
1134
1135 // What tonic hands back when a stream or unary connection dies in
1136 // flight: code Unknown, built *from* the transport error, so the source
1137 // chain is populated. This is the ordinary dropped connection, and the
1138 // resumable stream and the retry pipeline both depend on it being
1139 // retriable — before this, they gave up on the first blip.
1140 let dropped = tonic::Status::from_error(Box::new(io::Error::new(
1141 io::ErrorKind::ConnectionReset,
1142 "h2 protocol error: error reading a body from connection",
1143 )));
1144 assert_eq!(
1145 dropped.code(),
1146 tonic::Code::Unknown,
1147 "from_error yields Unknown"
1148 );
1149 assert!(Error::from(dropped).is_retriable());
1150
1151 // A genuine `Unknown` the participant returned is decoded from response
1152 // trailers and carries no local source. That is the server's verdict,
1153 // and retrying it would just re-ask the same losing question.
1154 let server_unknown = tonic::Status::new(tonic::Code::Unknown, "business rule failed");
1155 assert!(!Error::from(server_unknown).is_retriable());
1156
1157 // `Internal` follows the same rule: transport-built is retriable, a
1158 // bare server Internal is not.
1159 assert!(
1160 Error::from(tonic::Status::from_error(Box::new(io::Error::other(
1161 "broken pipe"
1162 ))))
1163 .is_retriable()
1164 );
1165 assert!(!Error::from(tonic::Status::internal("server bug")).is_retriable());
1166 }
1167
1168 #[test]
1169 fn transient_http_codes_are_retriable_but_client_codes_are_not() {
1170 // The whole 5xx range is transient (per the doc), plus 408/429 — not just
1171 // a hand-picked subset. 501/509/511/520 were previously missed.
1172 for status in [
1173 408, 429, 500, 501, 502, 503, 504, 507, 509, 511, 520, 527, 599,
1174 ] {
1175 assert!(
1176 Error::Http {
1177 status,
1178 body: String::new(),
1179 url: None,
1180 }
1181 .is_retriable(),
1182 "http {status} should be retriable"
1183 );
1184 }
1185 // 4xx (incl. the JSON API's 413 too-large) stay non-retriable.
1186 for status in [400, 401, 403, 404, 409, 413, 422] {
1187 assert!(
1188 !Error::Http {
1189 status,
1190 body: String::new(),
1191 url: None,
1192 }
1193 .is_retriable(),
1194 "http {status} should not be retriable"
1195 );
1196 }
1197 }
1198
1199 #[test]
1200 fn definite_failures_are_not_retriable() {
1201 assert!(!Error::from(tonic::Status::not_found("x")).is_retriable());
1202 assert!(!Error::from(tonic::Status::already_exists("dup")).is_retriable());
1203 assert!(!Error::from(tonic::Status::invalid_argument("x")).is_retriable());
1204 assert!(!Error::InvalidRequest("x".to_string()).is_retriable());
1205 assert!(!Error::Auth("x".to_string()).is_retriable());
1206 assert!(
1207 !Error::CommandRejected {
1208 code: "GrpcStatus".to_string(),
1209 message: "boom".to_string()
1210 }
1211 .is_retriable()
1212 );
1213 assert!(!Error::UnexpectedResponse("x".to_string()).is_retriable());
1214 }
1215
1216 #[test]
1217 fn code_is_exposed_only_for_status_errors() {
1218 assert_eq!(
1219 Error::from(tonic::Status::not_found("x")).code(),
1220 Some(tonic::Code::NotFound)
1221 );
1222 assert_eq!(Error::Timeout.code(), None);
1223 assert_eq!(Error::Connection("x".to_string()).code(), None);
1224 assert_eq!(
1225 Error::Http {
1226 status: 503,
1227 body: String::new(),
1228 url: None,
1229 }
1230 .code(),
1231 None
1232 );
1233 }
1234
1235 #[test]
1236 fn display_messages_are_lowercase_and_informative() {
1237 assert_eq!(Error::Timeout.to_string(), "operation timed out");
1238 assert_eq!(
1239 Error::InvalidRequest("bad uri".to_string()).to_string(),
1240 "invalid request: bad uri"
1241 );
1242 assert_eq!(
1243 Error::Auth("token expired".to_string()).to_string(),
1244 "authentication failed: token expired"
1245 );
1246 assert_eq!(
1247 Error::Http {
1248 status: 503,
1249 body: "down".to_string(),
1250 url: None,
1251 }
1252 .to_string(),
1253 "http 503: down"
1254 );
1255 assert_eq!(
1256 Error::CommandRejected {
1257 code: "INVALID_ARGUMENT".to_string(),
1258 message: "nope".to_string()
1259 }
1260 .to_string(),
1261 "command rejected (INVALID_ARGUMENT): nope"
1262 );
1263 }
1264}