Skip to main content

laser_wire/
result.rs

1// The unified result-code space. Each managed surface keeps its own typed error
2// for the detail a caller needs, but every one of those errors also projects
3// onto one logical `ResultCode` here, so a generic client, the HTTP status
4// mapper, and a cross-language port all dispatch on one small dictionary
5// instead of parsing per-surface strings. The codes are a pinned cross-repo
6// contract, and an unknown code from a newer peer rides through as
7// `Unrecognized` rather than failing, the same forward-compat shape the growable
8// u8 dictionaries use.
9
10use crate::authz::AuthzError;
11use crate::fork::ForkError;
12use crate::graph::GraphError;
13use crate::kv::KvError;
14use crate::query::QueryError;
15use crate::session::SessionError;
16use serde::{Deserialize, Serialize};
17
18/// One logical outcome code spanning query, key-value, fork, and browse. Built
19/// from a surface's typed error via the `From` impls below. The typed error
20/// keeps the detail, this is the shared classification.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum ResultCode {
25    /// The operation succeeded (no error to classify).
26    Ok,
27    /// The operation, or the managed surface, is not available here.
28    Unsupported,
29    /// A named entity (index, fork, key) does not exist.
30    NotFound,
31    /// The request was malformed or a field was out of range.
32    InvalidArgument,
33    /// A result or value exceeded a size cap.
34    TooLarge,
35    /// A precondition lost a race (a compare-and-swap version mismatch, a fork
36    /// promote/squash conflict).
37    Conflict,
38    /// A consistency level could not be met within the deadline (the read model
39    /// is still catching up).
40    Stale,
41    /// The wire op version is not accepted by this peer.
42    VersionSkew,
43    /// No credential, or an invalid one: the caller is not authenticated.
44    Unauthenticated,
45    /// The managed backend failed or was unreachable.
46    Backend,
47    /// Authenticated, but the grant needed for the operation is missing.
48    Forbidden,
49    /// Authenticated, but the operation needs a stronger authentication (a
50    /// step-up) than the caller currently holds.
51    StepUpRequired,
52    /// The request could not be served right now, and the same request may
53    /// succeed on a later attempt: the store was momentarily out of reach, a
54    /// connection was refused or dropped, a concurrency conflict was aborted, or
55    /// a resource limit was hit. Distinct from [`Backend`](Self::Backend), which
56    /// is a fault the caller cannot retry away. This is the one code a generic
57    /// client may safely retry with backoff, so it never has to parse a message
58    /// to decide (see [`is_retryable`](Self::is_retryable)).
59    Unavailable,
60    /// The query exceeded a server-enforced resource budget.
61    ResourceLimit,
62    /// The query was cancelled through its execution identity.
63    Cancelled,
64    /// The query did not finish before its absolute deadline.
65    DeadlineExceeded,
66    /// The requested historical snapshot is no longer retained.
67    ExpiredSnapshot,
68    /// The requested destination or backend generation is no longer current.
69    StaleGeneration,
70    /// The resolved target is not ready to serve queries.
71    TargetUnavailable,
72    /// The mutation was accepted into the log, but its outcome is not known
73    /// yet. Only a retry with the same operation identity may follow: it
74    /// returns the recorded outcome once the mutation has applied. A new
75    /// identity could apply the change a second time.
76    Pending,
77    /// The node could not reach its managed backend, or the backend did not
78    /// answer in time. The request may still apply, so a mutation repeats
79    /// only under the same operation identity, on this node or another one.
80    BackendUnavailable,
81    /// A code from a newer peer this build does not name. Decodes and re-encodes
82    /// byte-for-byte so an old build relays it rather than failing. Only a value
83    /// outside the named range (21 and up) should ever appear here: `from_code`
84    /// never produces `Unrecognized` for `0..=20`, which map to the named
85    /// variants.
86    Unrecognized(u16),
87}
88
89impl ResultCode {
90    /// The pinned numeric code, stable across repos and language ports.
91    pub const fn code(self) -> u16 {
92        match self {
93            ResultCode::Ok => 0,
94            ResultCode::Unsupported => 1,
95            ResultCode::NotFound => 2,
96            ResultCode::InvalidArgument => 3,
97            ResultCode::TooLarge => 4,
98            ResultCode::Conflict => 5,
99            ResultCode::Stale => 6,
100            ResultCode::VersionSkew => 7,
101            ResultCode::Unauthenticated => 8,
102            ResultCode::Backend => 9,
103            ResultCode::Forbidden => 10,
104            ResultCode::StepUpRequired => 11,
105            ResultCode::Unavailable => 12,
106            ResultCode::ResourceLimit => 13,
107            ResultCode::Cancelled => 14,
108            ResultCode::DeadlineExceeded => 15,
109            ResultCode::ExpiredSnapshot => 16,
110            ResultCode::StaleGeneration => 17,
111            ResultCode::TargetUnavailable => 18,
112            ResultCode::Pending => 19,
113            ResultCode::BackendUnavailable => 20,
114            ResultCode::Unrecognized(code) => code,
115        }
116    }
117
118    /// The code for a pinned numeric value, where an unknown value becomes
119    /// `Unrecognized` rather than an error.
120    pub const fn from_code(code: u16) -> Self {
121        match code {
122            0 => ResultCode::Ok,
123            1 => ResultCode::Unsupported,
124            2 => ResultCode::NotFound,
125            3 => ResultCode::InvalidArgument,
126            4 => ResultCode::TooLarge,
127            5 => ResultCode::Conflict,
128            6 => ResultCode::Stale,
129            7 => ResultCode::VersionSkew,
130            8 => ResultCode::Unauthenticated,
131            9 => ResultCode::Backend,
132            10 => ResultCode::Forbidden,
133            11 => ResultCode::StepUpRequired,
134            12 => ResultCode::Unavailable,
135            13 => ResultCode::ResourceLimit,
136            14 => ResultCode::Cancelled,
137            15 => ResultCode::DeadlineExceeded,
138            16 => ResultCode::ExpiredSnapshot,
139            17 => ResultCode::StaleGeneration,
140            18 => ResultCode::TargetUnavailable,
141            19 => ResultCode::Pending,
142            20 => ResultCode::BackendUnavailable,
143            other => ResultCode::Unrecognized(other),
144        }
145    }
146
147    /// The HTTP status this code maps to, the one mapping every surface shares,
148    /// so a status need not be decided per surface or per route.
149    pub const fn http_status(self) -> u16 {
150        match self {
151            ResultCode::Ok => 200,
152            ResultCode::Unsupported => 501,
153            ResultCode::NotFound => 404,
154            ResultCode::InvalidArgument => 400,
155            ResultCode::TooLarge => 413,
156            ResultCode::Conflict => 409,
157            ResultCode::Stale => 503,
158            ResultCode::VersionSkew => 400,
159            ResultCode::Unauthenticated => 401,
160            ResultCode::Backend => 502,
161            // Authenticated-but-forbidden is 403, distinct from the 401 an
162            // unauthenticated caller gets. Step-up also lands on 403 unless the
163            // HTTP layer has a better challenge status for the chosen scheme.
164            ResultCode::Forbidden => 403,
165            ResultCode::StepUpRequired => 403,
166            // Retry-after territory, the same status a lagging read model gets.
167            ResultCode::Unavailable => 503,
168            ResultCode::ResourceLimit => 429,
169            ResultCode::Cancelled => 409,
170            ResultCode::DeadlineExceeded => 408,
171            ResultCode::ExpiredSnapshot => 410,
172            ResultCode::StaleGeneration => 409,
173            ResultCode::TargetUnavailable => 503,
174            // The outcome is still being decided, so the same request comes
175            // back later. It is not generically retryable, see `is_retryable`.
176            ResultCode::Pending => 503,
177            ResultCode::BackendUnavailable => 503,
178            ResultCode::Unrecognized(_) => 500,
179        }
180    }
181
182    /// Whether a caller may retry the identical request and reasonably expect a
183    /// different outcome. True only for the two transient classes: the store was
184    /// momentarily unreachable, or the read model had not caught up yet. Every
185    /// other code needs the request, the credential, or the data to change first,
186    /// so retrying it unchanged only wastes the attempt. [`Pending`](Self::Pending)
187    /// is not in this set: only the original operation identity may be
188    /// retried, see [`is_pending`](Self::is_pending).
189    pub const fn is_retryable(self) -> bool {
190        matches!(
191            self,
192            ResultCode::Unavailable
193                | ResultCode::Stale
194                | ResultCode::TargetUnavailable
195                | ResultCode::BackendUnavailable
196        )
197    }
198
199    /// Whether the request could not be served right now: the retryable codes
200    /// other than a lagging read model. The broad `is_unavailable` classifier
201    /// of every client reads this.
202    pub const fn is_unavailable(self) -> bool {
203        matches!(
204            self,
205            ResultCode::Unavailable
206                | ResultCode::TargetUnavailable
207                | ResultCode::BackendUnavailable
208        )
209    }
210
211    /// Whether the caller's credential or grant was refused: no credential or
212    /// an unverified one, or a missing grant. The fix is a credential or a
213    /// grant, never a retry.
214    pub const fn is_permission_denied(self) -> bool {
215        matches!(self, ResultCode::Forbidden | ResultCode::Unauthenticated)
216    }
217
218    /// Whether the mutation was accepted and its outcome is still unknown. The
219    /// caller repeats the exact request with the same operation identity.
220    pub const fn is_pending(self) -> bool {
221        matches!(self, ResultCode::Pending)
222    }
223
224    /// Whether the node could not reach its managed backend, or the backend
225    /// did not answer in time. The request may still apply. A client
226    /// repeats a mutation under the same operation identity, and may send it
227    /// to another node. Unlike [`Unavailable`](Self::Unavailable), which a
228    /// busy node or an unreachable destination answers, this one names the
229    /// node itself as the problem.
230    pub const fn is_backend_unavailable(self) -> bool {
231        matches!(self, ResultCode::BackendUnavailable)
232    }
233}
234
235/// The canonical surface-agnostic error reply. Every managed surface has its own
236/// typed reply enum (`QueryReply`, `KvReply`, `ForkReply`, `BrowseReply`), but a
237/// server that receives a command code it does not handle (a forwarded
238/// `AGDX_KV_CAS` on a build without compare-and-swap, or any future additive
239/// code) has no one surface to answer in: a query-shaped error reply fails to
240/// decode in a client awaiting a key-value reply, and surfaces as an opaque
241/// transport error instead of a clean classification. This is that fallback. A
242/// server answers an unhandled or unsupported code with a `CommandError`, and a
243/// client that fails to decode the surface's typed reply tries `CommandError`
244/// next, turning the wrong-surface reply into a typed [`ResultCode`].
245#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246pub struct CommandError {
247    pub code: ResultCode,
248    pub message: String,
249}
250
251impl CommandError {
252    /// A command error from a classified code and a human message.
253    pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
254        Self {
255            code,
256            message: message.into(),
257        }
258    }
259
260    /// The reply for a command code this server does not handle.
261    pub fn unsupported(message: impl Into<String>) -> Self {
262        Self::new(ResultCode::Unsupported, message)
263    }
264}
265
266impl From<&QueryError> for ResultCode {
267    fn from(error: &QueryError) -> Self {
268        match error {
269            QueryError::Unsupported(_) => ResultCode::Unsupported,
270            QueryError::Invalid(_) => ResultCode::InvalidArgument,
271            // The per-source DSL check refused the query: the caller authenticated
272            // but lacks the grant for the named resource, so forbidden not 401.
273            QueryError::Unauthorized(_) => ResultCode::Forbidden,
274            QueryError::IndexNotFound(_)
275            | QueryError::ForkNotFound(_)
276            | QueryError::SchemaNotFound(_) => ResultCode::NotFound,
277            QueryError::Backend(_) => ResultCode::Backend,
278            QueryError::Unavailable(_) => ResultCode::Unavailable,
279            QueryError::TooLarge { .. } => ResultCode::TooLarge,
280            QueryError::Version { .. } => ResultCode::VersionSkew,
281            QueryError::Stale { .. } => ResultCode::Stale,
282            QueryError::DeadlineExceeded { .. } => ResultCode::DeadlineExceeded,
283            QueryError::StaleGeneration { .. } => ResultCode::StaleGeneration,
284        }
285    }
286}
287
288impl From<&KvError> for ResultCode {
289    fn from(error: &KvError) -> Self {
290        match error {
291            KvError::Unsupported(_) => ResultCode::Unsupported,
292            KvError::InvalidKey(_) => ResultCode::InvalidArgument,
293            KvError::InvalidNamespace(_) => ResultCode::InvalidArgument,
294            KvError::TooLarge { .. } => ResultCode::TooLarge,
295            KvError::Backend(_) => ResultCode::Backend,
296            KvError::Unavailable(_) => ResultCode::Unavailable,
297            KvError::Version { .. } => ResultCode::VersionSkew,
298            KvError::VersionConflict { .. } => ResultCode::Conflict,
299            KvError::LeaseLost => ResultCode::Conflict,
300            KvError::LeaseHeld { .. } => ResultCode::Conflict,
301            KvError::BarrierUnreachable { .. } => ResultCode::StaleGeneration,
302            KvError::NotFound => ResultCode::NotFound,
303            KvError::NotLeader => ResultCode::Unavailable,
304            KvError::Stale { .. } => ResultCode::Stale,
305            KvError::Forbidden(_) => ResultCode::Forbidden,
306            KvError::InvalidExpiry(_) => ResultCode::InvalidArgument,
307        }
308    }
309}
310
311impl From<&ForkError> for ResultCode {
312    fn from(error: &ForkError) -> Self {
313        match error {
314            ForkError::Unsupported(_) => ResultCode::Unsupported,
315            ForkError::NotFound(_) => ResultCode::NotFound,
316            ForkError::InvalidFork(_) => ResultCode::InvalidArgument,
317            ForkError::Conflict(_) => ResultCode::Conflict,
318            ForkError::Backend(_) => ResultCode::Backend,
319            ForkError::Unavailable(_) => ResultCode::Unavailable,
320            ForkError::Version { .. } => ResultCode::VersionSkew,
321            ForkError::NotLeader => ResultCode::Unavailable,
322        }
323    }
324}
325
326impl From<&GraphError> for ResultCode {
327    fn from(error: &GraphError) -> Self {
328        match error {
329            GraphError::Unsupported(_) => ResultCode::Unsupported,
330            GraphError::Unauthorized(_) => ResultCode::Forbidden,
331            GraphError::InvalidName(_) => ResultCode::InvalidArgument,
332            GraphError::NotFound(_) => ResultCode::NotFound,
333            GraphError::TooLarge { .. } => ResultCode::TooLarge,
334            GraphError::Backend(_) => ResultCode::Backend,
335            GraphError::Unavailable(_) => ResultCode::Unavailable,
336            GraphError::Version { .. } => ResultCode::VersionSkew,
337        }
338    }
339}
340
341impl From<&AuthzError> for ResultCode {
342    fn from(error: &AuthzError) -> Self {
343        match error {
344            AuthzError::Unsupported(_) => ResultCode::Unsupported,
345            AuthzError::Unauthorized => ResultCode::Forbidden,
346            AuthzError::UnknownRole(_) => ResultCode::NotFound,
347            AuthzError::InvalidName(_)
348            | AuthzError::InvalidGrant(_)
349            | AuthzError::TenancyViolation(_) => ResultCode::InvalidArgument,
350            AuthzError::Conflict { .. } | AuthzError::MutationConflict(_) => ResultCode::Conflict,
351            AuthzError::Version { .. } => ResultCode::VersionSkew,
352            AuthzError::ResourceLimit(_) => ResultCode::ResourceLimit,
353        }
354    }
355}
356
357impl From<&SessionError> for ResultCode {
358    fn from(error: &SessionError) -> Self {
359        match error {
360            SessionError::Unsupported(_) => ResultCode::Unsupported,
361            SessionError::NotFound(_) | SessionError::NotRegistered(_) => ResultCode::NotFound,
362            SessionError::Invalid(_) => ResultCode::InvalidArgument,
363            SessionError::Unauthorized(_) => ResultCode::Forbidden,
364            SessionError::Stale(_) => ResultCode::StaleGeneration,
365            SessionError::Backend(_) => ResultCode::Backend,
366            SessionError::Unavailable(_) => ResultCode::Unavailable,
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn given_result_codes_when_mapped_then_should_round_trip_through_the_numeric_value() {
377        for code in [
378            ResultCode::Ok,
379            ResultCode::Unsupported,
380            ResultCode::NotFound,
381            ResultCode::InvalidArgument,
382            ResultCode::TooLarge,
383            ResultCode::Conflict,
384            ResultCode::Stale,
385            ResultCode::VersionSkew,
386            ResultCode::Unauthenticated,
387            ResultCode::Backend,
388            ResultCode::Forbidden,
389            ResultCode::StepUpRequired,
390            ResultCode::Unavailable,
391            ResultCode::TargetUnavailable,
392            ResultCode::Pending,
393            ResultCode::BackendUnavailable,
394        ] {
395            assert_eq!(ResultCode::from_code(code.code()), code);
396        }
397        assert_eq!(ResultCode::Pending.code(), 19);
398        assert_eq!(ResultCode::BackendUnavailable.code(), 20);
399        assert!(ResultCode::BackendUnavailable.is_retryable());
400        assert!(ResultCode::BackendUnavailable.is_backend_unavailable());
401        assert!(!ResultCode::Unavailable.is_backend_unavailable());
402        assert_eq!(ResultCode::from_code(21), ResultCode::Unrecognized(21));
403        // An unknown numeric code rides through as Unrecognized.
404        assert_eq!(ResultCode::from_code(900), ResultCode::Unrecognized(900));
405        assert_eq!(ResultCode::Unrecognized(900).code(), 900);
406    }
407
408    #[test]
409    fn given_surface_errors_when_classified_then_should_map_to_the_shared_code() {
410        assert_eq!(
411            ResultCode::from(&QueryError::IndexNotFound("readings".to_owned())),
412            ResultCode::NotFound
413        );
414        assert_eq!(
415            ResultCode::from(&QueryError::Stale {
416                what: "readings".to_owned(),
417                applied: 4,
418                required: 9,
419            }),
420            ResultCode::Stale
421        );
422        assert_eq!(
423            ResultCode::from(&KvError::VersionConflict { current: Some(3) }),
424            ResultCode::Conflict
425        );
426        assert_eq!(
427            ResultCode::from(&ForkError::Conflict("open".to_owned())),
428            ResultCode::Conflict
429        );
430        assert_eq!(
431            ResultCode::from(&KvError::NotLeader),
432            ResultCode::Unavailable
433        );
434        assert_eq!(
435            ResultCode::from(&ForkError::NotLeader),
436            ResultCode::Unavailable
437        );
438        assert_eq!(
439            ResultCode::from(&AuthzError::TenancyViolation("prefix acme".to_owned())),
440            ResultCode::InvalidArgument
441        );
442        assert_eq!(
443            ResultCode::from(&AuthzError::Unauthorized),
444            ResultCode::Forbidden
445        );
446    }
447
448    // One code per condition on every surface. A surface without its own
449    // variant for a condition answers the surface-neutral `CommandError`.
450    #[test]
451    fn given_one_condition_on_every_surface_when_classified_then_should_share_one_code() {
452        let text = || "x".to_owned();
453        let table: [(ResultCode, Vec<ResultCode>); 6] = [
454            (
455                ResultCode::InvalidArgument,
456                vec![
457                    ResultCode::from(&KvError::InvalidKey(text())),
458                    ResultCode::from(&KvError::InvalidNamespace(text())),
459                    ResultCode::from(&ForkError::InvalidFork(text())),
460                    ResultCode::from(&GraphError::InvalidName(text())),
461                    ResultCode::from(&AuthzError::InvalidName(text())),
462                    ResultCode::from(&AuthzError::InvalidGrant(text())),
463                    ResultCode::from(&SessionError::Invalid(text())),
464                    ResultCode::from(&QueryError::Invalid(text())),
465                ],
466            ),
467            (
468                ResultCode::Forbidden,
469                vec![
470                    ResultCode::from(&QueryError::Unauthorized(text())),
471                    ResultCode::from(&GraphError::Unauthorized(text())),
472                    ResultCode::from(&AuthzError::Unauthorized),
473                    ResultCode::from(&SessionError::Unauthorized(text())),
474                ],
475            ),
476            (
477                ResultCode::NotFound,
478                vec![
479                    ResultCode::from(&QueryError::IndexNotFound(text())),
480                    ResultCode::from(&KvError::NotFound),
481                    ResultCode::from(&ForkError::NotFound(text())),
482                    ResultCode::from(&GraphError::NotFound(text())),
483                    ResultCode::from(&AuthzError::UnknownRole(text())),
484                    ResultCode::from(&SessionError::NotFound(text())),
485                ],
486            ),
487            (
488                ResultCode::Unavailable,
489                vec![
490                    ResultCode::from(&QueryError::Unavailable(text())),
491                    ResultCode::from(&KvError::Unavailable(text())),
492                    ResultCode::from(&ForkError::Unavailable(text())),
493                    ResultCode::from(&GraphError::Unavailable(text())),
494                    ResultCode::from(&SessionError::Unavailable(text())),
495                ],
496            ),
497            (
498                ResultCode::Unsupported,
499                vec![
500                    ResultCode::from(&QueryError::Unsupported(text())),
501                    ResultCode::from(&KvError::Unsupported(text())),
502                    ResultCode::from(&ForkError::Unsupported(text())),
503                    ResultCode::from(&GraphError::Unsupported(text())),
504                    ResultCode::from(&AuthzError::Unsupported(text())),
505                    ResultCode::from(&SessionError::Unsupported(text())),
506                ],
507            ),
508            (
509                ResultCode::Backend,
510                vec![
511                    ResultCode::from(&QueryError::Backend(text())),
512                    ResultCode::from(&KvError::Backend(text())),
513                    ResultCode::from(&ForkError::Backend(text())),
514                    ResultCode::from(&GraphError::Backend(text())),
515                    ResultCode::from(&SessionError::Backend(text())),
516                ],
517            ),
518        ];
519        for (expected, codes) in table {
520            for (surface, code) in codes.into_iter().enumerate() {
521                assert_eq!(code, expected, "surface {surface} of {expected:?}");
522            }
523            assert!(
524                !expected.is_retryable() || expected == ResultCode::Unavailable,
525                "only unavailable is retryable among these"
526            );
527        }
528    }
529
530    // A transient outcome must be distinguishable from a fault without parsing
531    // the message, so a generic client can back off and retry exactly these.
532    #[test]
533    fn given_transient_outcomes_when_classified_then_should_be_the_only_retryable_codes() {
534        for surface in [
535            ResultCode::from(&QueryError::Unavailable("dropped".to_owned())),
536            ResultCode::from(&KvError::Unavailable("dropped".to_owned())),
537            ResultCode::from(&ForkError::Unavailable("dropped".to_owned())),
538        ] {
539            assert_eq!(surface, ResultCode::Unavailable);
540        }
541        assert_eq!(ResultCode::Unavailable.http_status(), 503);
542        assert!(ResultCode::Unavailable.is_retryable());
543        assert!(ResultCode::Stale.is_retryable());
544        for code in [
545            ResultCode::Ok,
546            ResultCode::Unsupported,
547            ResultCode::NotFound,
548            ResultCode::InvalidArgument,
549            ResultCode::TooLarge,
550            ResultCode::Conflict,
551            ResultCode::VersionSkew,
552            ResultCode::Unauthenticated,
553            ResultCode::Backend,
554            ResultCode::Forbidden,
555            ResultCode::StepUpRequired,
556            ResultCode::Pending,
557            ResultCode::Unrecognized(900),
558        ] {
559            assert!(
560                !code.is_retryable(),
561                "{code:?} must not invite a blind retry"
562            );
563        }
564    }
565
566    #[test]
567    fn given_result_codes_when_mapped_to_http_then_should_match_the_binding_table() {
568        assert_eq!(ResultCode::NotFound.http_status(), 404);
569        assert_eq!(ResultCode::Unsupported.http_status(), 501);
570        assert_eq!(ResultCode::TooLarge.http_status(), 413);
571        assert_eq!(ResultCode::Conflict.http_status(), 409);
572        assert_eq!(ResultCode::Stale.http_status(), 503);
573        assert_eq!(ResultCode::Unauthenticated.http_status(), 401);
574        assert_eq!(ResultCode::Forbidden.http_status(), 403);
575        assert_eq!(ResultCode::StepUpRequired.http_status(), 403);
576        assert_eq!(ResultCode::Backend.http_status(), 502);
577        // An unrecognized code from a newer peer maps to a generic 500 rather
578        // than panicking, and keeps its raw numeric.
579        assert_eq!(ResultCode::Unrecognized(777).http_status(), 500);
580        assert_eq!(ResultCode::Unrecognized(777).code(), 777);
581    }
582
583    #[cfg(feature = "cbor")]
584    #[test]
585    fn given_a_result_code_when_round_tripped_through_cbor_then_should_preserve_the_variant() {
586        use crate::framing::{decode_named, encode_named};
587        for code in [
588            ResultCode::Ok,
589            ResultCode::Conflict,
590            ResultCode::Stale,
591            ResultCode::Unrecognized(4242),
592        ] {
593            let bytes = encode_named(&code).expect("serializes");
594            let back: ResultCode = decode_named(&bytes).expect("deserializes");
595            assert_eq!(back, code);
596        }
597    }
598
599    #[test]
600    fn given_backend_unavailable_when_serialized_then_should_use_the_backend_unavailable_string() {
601        let json = serde_json::to_string(&ResultCode::BackendUnavailable).expect("serializes");
602        assert_eq!(json, "\"backend_unavailable\"");
603        let back: ResultCode =
604            serde_json::from_str("\"backend_unavailable\"").expect("deserializes");
605        assert_eq!(back, ResultCode::BackendUnavailable);
606    }
607
608    #[cfg(feature = "cbor")]
609    #[test]
610    fn given_a_command_error_when_round_tripped_then_should_preserve_code_and_message() {
611        use crate::framing::{decode_named, encode_named};
612        let error = CommandError::unsupported("AGDX_KV_CAS not served on this build");
613        assert_eq!(error.code, ResultCode::Unsupported);
614        let bytes = encode_named(&error).expect("serializes");
615        let back: CommandError = decode_named(&bytes).expect("deserializes");
616        assert_eq!(back, error);
617    }
618}