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