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    /// A code from a newer peer this build does not name. Decodes and re-encodes
74    /// byte-for-byte so an old build relays it rather than failing. Only a value
75    /// outside the named range (19 and up) should ever appear here: `from_code`
76    /// never produces `Unrecognized` for `0..=18`, which map to the named
77    /// variants.
78    Unrecognized(u16),
79}
80
81impl ResultCode {
82    /// The pinned numeric code, stable across repos and language ports.
83    pub const fn code(self) -> u16 {
84        match self {
85            ResultCode::Ok => 0,
86            ResultCode::Unsupported => 1,
87            ResultCode::NotFound => 2,
88            ResultCode::InvalidArgument => 3,
89            ResultCode::TooLarge => 4,
90            ResultCode::Conflict => 5,
91            ResultCode::Stale => 6,
92            ResultCode::VersionSkew => 7,
93            ResultCode::Unauthenticated => 8,
94            ResultCode::Backend => 9,
95            ResultCode::Forbidden => 10,
96            ResultCode::StepUpRequired => 11,
97            ResultCode::Unavailable => 12,
98            ResultCode::ResourceLimit => 13,
99            ResultCode::Cancelled => 14,
100            ResultCode::DeadlineExceeded => 15,
101            ResultCode::ExpiredSnapshot => 16,
102            ResultCode::StaleGeneration => 17,
103            ResultCode::TargetUnavailable => 18,
104            ResultCode::Unrecognized(code) => code,
105        }
106    }
107
108    /// The code for a pinned numeric value, where an unknown value becomes
109    /// `Unrecognized` rather than an error.
110    pub const fn from_code(code: u16) -> Self {
111        match code {
112            0 => ResultCode::Ok,
113            1 => ResultCode::Unsupported,
114            2 => ResultCode::NotFound,
115            3 => ResultCode::InvalidArgument,
116            4 => ResultCode::TooLarge,
117            5 => ResultCode::Conflict,
118            6 => ResultCode::Stale,
119            7 => ResultCode::VersionSkew,
120            8 => ResultCode::Unauthenticated,
121            9 => ResultCode::Backend,
122            10 => ResultCode::Forbidden,
123            11 => ResultCode::StepUpRequired,
124            12 => ResultCode::Unavailable,
125            13 => ResultCode::ResourceLimit,
126            14 => ResultCode::Cancelled,
127            15 => ResultCode::DeadlineExceeded,
128            16 => ResultCode::ExpiredSnapshot,
129            17 => ResultCode::StaleGeneration,
130            18 => ResultCode::TargetUnavailable,
131            other => ResultCode::Unrecognized(other),
132        }
133    }
134
135    /// The HTTP status this code maps to, the one mapping every surface shares,
136    /// so a status need not be decided per surface or per route.
137    pub const fn http_status(self) -> u16 {
138        match self {
139            ResultCode::Ok => 200,
140            ResultCode::Unsupported => 501,
141            ResultCode::NotFound => 404,
142            ResultCode::InvalidArgument => 400,
143            ResultCode::TooLarge => 413,
144            ResultCode::Conflict => 409,
145            ResultCode::Stale => 503,
146            ResultCode::VersionSkew => 400,
147            ResultCode::Unauthenticated => 401,
148            ResultCode::Backend => 502,
149            // Authenticated-but-forbidden is 403, distinct from the 401 an
150            // unauthenticated caller gets. Step-up also lands on 403 unless the
151            // HTTP layer has a better challenge status for the chosen scheme.
152            ResultCode::Forbidden => 403,
153            ResultCode::StepUpRequired => 403,
154            // Retry-after territory, the same status a lagging read model gets.
155            ResultCode::Unavailable => 503,
156            ResultCode::ResourceLimit => 429,
157            ResultCode::Cancelled => 409,
158            ResultCode::DeadlineExceeded => 408,
159            ResultCode::ExpiredSnapshot => 410,
160            ResultCode::StaleGeneration => 409,
161            ResultCode::TargetUnavailable => 503,
162            ResultCode::Unrecognized(_) => 500,
163        }
164    }
165
166    /// Whether a caller may retry the identical request and reasonably expect a
167    /// different outcome. True only for the two transient classes: the store was
168    /// momentarily unreachable, or the read model had not caught up yet. Every
169    /// other code needs the request, the credential, or the data to change first,
170    /// so retrying it unchanged only wastes the attempt.
171    pub const fn is_retryable(self) -> bool {
172        matches!(
173            self,
174            ResultCode::Unavailable | ResultCode::Stale | ResultCode::TargetUnavailable
175        )
176    }
177}
178
179/// The canonical surface-agnostic error reply. Every managed surface has its own
180/// typed reply enum (`QueryReply`, `KvReply`, `ForkReply`, `BrowseReply`), but a
181/// server that receives a command code it does not handle (a forwarded
182/// `AGDX_KV_CAS` on a build without compare-and-swap, or any future additive
183/// code) has no one surface to answer in: a query-shaped error reply fails to
184/// decode in a client awaiting a key-value reply, and surfaces as an opaque
185/// transport error instead of a clean classification. This is that fallback. A
186/// server answers an unhandled or unsupported code with a `CommandError`, and a
187/// client that fails to decode the surface's typed reply tries `CommandError`
188/// next, turning the wrong-surface reply into a typed [`ResultCode`].
189#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
190pub struct CommandError {
191    pub code: ResultCode,
192    pub message: String,
193}
194
195impl CommandError {
196    /// A command error from a classified code and a human message.
197    pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
198        Self {
199            code,
200            message: message.into(),
201        }
202    }
203
204    /// The reply for a command code this server does not handle.
205    pub fn unsupported(message: impl Into<String>) -> Self {
206        Self::new(ResultCode::Unsupported, message)
207    }
208}
209
210impl From<&QueryError> for ResultCode {
211    fn from(error: &QueryError) -> Self {
212        match error {
213            QueryError::Unsupported(_) => ResultCode::Unsupported,
214            // The per-source DSL check refused the query: the caller authenticated
215            // but lacks the grant for the named resource, so forbidden not 401.
216            QueryError::Unauthorized(_) => ResultCode::Forbidden,
217            QueryError::IndexNotFound(_) | QueryError::ForkNotFound(_) => ResultCode::NotFound,
218            QueryError::Backend(_) => ResultCode::Backend,
219            QueryError::Unavailable(_) => ResultCode::Unavailable,
220            QueryError::TooLarge { .. } => ResultCode::TooLarge,
221            QueryError::Version { .. } => ResultCode::VersionSkew,
222            QueryError::Stale { .. } => ResultCode::Stale,
223            QueryError::Cancelled { .. } => ResultCode::Cancelled,
224            QueryError::DeadlineExceeded { .. } => ResultCode::DeadlineExceeded,
225            QueryError::ExpiredSnapshot { .. } => ResultCode::ExpiredSnapshot,
226            QueryError::StaleGeneration { .. } => ResultCode::StaleGeneration,
227            QueryError::TargetUnavailable { .. } => ResultCode::TargetUnavailable,
228            QueryError::ResourceLimit { .. } => ResultCode::ResourceLimit,
229        }
230    }
231}
232
233impl From<&KvError> for ResultCode {
234    fn from(error: &KvError) -> Self {
235        match error {
236            KvError::Unsupported(_) => ResultCode::Unsupported,
237            KvError::InvalidKey(_) => ResultCode::InvalidArgument,
238            KvError::InvalidNamespace(_) => ResultCode::InvalidArgument,
239            KvError::TooLarge { .. } => ResultCode::TooLarge,
240            KvError::Backend(_) => ResultCode::Backend,
241            KvError::Unavailable(_) => ResultCode::Unavailable,
242            KvError::Version { .. } => ResultCode::VersionSkew,
243            KvError::VersionConflict { .. } => ResultCode::Conflict,
244            KvError::LeaseLost => ResultCode::Conflict,
245            KvError::NotFound => ResultCode::NotFound,
246            KvError::NotLeader => ResultCode::Unavailable,
247            KvError::Stale { .. } => ResultCode::Stale,
248        }
249    }
250}
251
252impl From<&ForkError> for ResultCode {
253    fn from(error: &ForkError) -> Self {
254        match error {
255            ForkError::Unsupported(_) => ResultCode::Unsupported,
256            ForkError::NotFound(_) => ResultCode::NotFound,
257            ForkError::InvalidFork(_) => ResultCode::InvalidArgument,
258            ForkError::Conflict(_) => ResultCode::Conflict,
259            ForkError::Backend(_) => ResultCode::Backend,
260            ForkError::Unavailable(_) => ResultCode::Unavailable,
261            ForkError::Version { .. } => ResultCode::VersionSkew,
262            ForkError::NotLeader => ResultCode::Unavailable,
263        }
264    }
265}
266
267impl From<&GraphError> for ResultCode {
268    fn from(error: &GraphError) -> Self {
269        match error {
270            GraphError::Unsupported(_) => ResultCode::Unsupported,
271            GraphError::Unauthorized(_) => ResultCode::Forbidden,
272            GraphError::InvalidName(_) => ResultCode::InvalidArgument,
273            GraphError::NotFound(_) => ResultCode::NotFound,
274            GraphError::TooLarge { .. } => ResultCode::TooLarge,
275            GraphError::Backend(_) => ResultCode::Backend,
276            GraphError::Unavailable(_) => ResultCode::Unavailable,
277            GraphError::Version { .. } => ResultCode::VersionSkew,
278        }
279    }
280}
281
282impl From<&AuthzError> for ResultCode {
283    fn from(error: &AuthzError) -> Self {
284        match error {
285            AuthzError::Unsupported(_) => ResultCode::Unsupported,
286            AuthzError::Unauthorized => ResultCode::Forbidden,
287            AuthzError::UnknownRole(_) => ResultCode::NotFound,
288            AuthzError::InvalidName(_) | AuthzError::TenancyViolation(_) => {
289                ResultCode::InvalidArgument
290            }
291            AuthzError::Conflict { .. } => ResultCode::Conflict,
292            AuthzError::Version { .. } => ResultCode::VersionSkew,
293        }
294    }
295}
296
297impl From<&SessionError> for ResultCode {
298    fn from(error: &SessionError) -> Self {
299        match error {
300            SessionError::Unsupported(_) => ResultCode::Unsupported,
301            SessionError::NotFound(_) | SessionError::NotRegistered(_) => ResultCode::NotFound,
302            SessionError::Invalid(_) => ResultCode::InvalidArgument,
303            SessionError::Unauthorized(_) => ResultCode::Forbidden,
304            SessionError::Stale(_) => ResultCode::StaleGeneration,
305            SessionError::Backend(_) => ResultCode::Backend,
306            SessionError::Unavailable(_) => ResultCode::Unavailable,
307        }
308    }
309}
310
311impl From<&CheckpointError> for ResultCode {
312    fn from(error: &CheckpointError) -> Self {
313        match error {
314            CheckpointError::Invalid(_) => ResultCode::InvalidArgument,
315            CheckpointError::NotFound => ResultCode::NotFound,
316            CheckpointError::Conflict { .. } | CheckpointError::LeaseLost => ResultCode::Conflict,
317            CheckpointError::Unauthorized => ResultCode::Forbidden,
318            CheckpointError::Unavailable(_) => ResultCode::Unavailable,
319            CheckpointError::Version { .. } => ResultCode::VersionSkew,
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn given_result_codes_when_mapped_then_should_round_trip_through_the_numeric_value() {
330        for code in [
331            ResultCode::Ok,
332            ResultCode::Unsupported,
333            ResultCode::NotFound,
334            ResultCode::InvalidArgument,
335            ResultCode::TooLarge,
336            ResultCode::Conflict,
337            ResultCode::Stale,
338            ResultCode::VersionSkew,
339            ResultCode::Unauthenticated,
340            ResultCode::Backend,
341            ResultCode::Forbidden,
342            ResultCode::StepUpRequired,
343            ResultCode::Unavailable,
344        ] {
345            assert_eq!(ResultCode::from_code(code.code()), code);
346        }
347        // An unknown numeric code rides through as Unrecognized.
348        assert_eq!(ResultCode::from_code(900), ResultCode::Unrecognized(900));
349        assert_eq!(ResultCode::Unrecognized(900).code(), 900);
350    }
351
352    #[test]
353    fn given_surface_errors_when_classified_then_should_map_to_the_shared_code() {
354        assert_eq!(
355            ResultCode::from(&QueryError::IndexNotFound("readings".to_owned())),
356            ResultCode::NotFound
357        );
358        assert_eq!(
359            ResultCode::from(&QueryError::Stale {
360                what: "readings".to_owned(),
361                applied: 4,
362                required: 9,
363            }),
364            ResultCode::Stale
365        );
366        assert_eq!(
367            ResultCode::from(&KvError::VersionConflict { current: Some(3) }),
368            ResultCode::Conflict
369        );
370        assert_eq!(
371            ResultCode::from(&ForkError::Conflict("open".to_owned())),
372            ResultCode::Conflict
373        );
374        assert_eq!(
375            ResultCode::from(&KvError::NotLeader),
376            ResultCode::Unavailable
377        );
378        assert_eq!(
379            ResultCode::from(&ForkError::NotLeader),
380            ResultCode::Unavailable
381        );
382        assert_eq!(
383            ResultCode::from(&AuthzError::TenancyViolation("prefix acme".to_owned())),
384            ResultCode::InvalidArgument
385        );
386        assert_eq!(
387            ResultCode::from(&AuthzError::Unauthorized),
388            ResultCode::Forbidden
389        );
390    }
391
392    // A transient outcome must be distinguishable from a fault without parsing
393    // the message, so a generic client can back off and retry exactly these.
394    #[test]
395    fn given_transient_outcomes_when_classified_then_should_be_the_only_retryable_codes() {
396        for surface in [
397            ResultCode::from(&QueryError::Unavailable("dropped".to_owned())),
398            ResultCode::from(&KvError::Unavailable("dropped".to_owned())),
399            ResultCode::from(&ForkError::Unavailable("dropped".to_owned())),
400        ] {
401            assert_eq!(surface, ResultCode::Unavailable);
402        }
403        assert_eq!(ResultCode::Unavailable.http_status(), 503);
404        assert!(ResultCode::Unavailable.is_retryable());
405        assert!(ResultCode::Stale.is_retryable());
406        for code in [
407            ResultCode::Ok,
408            ResultCode::Unsupported,
409            ResultCode::NotFound,
410            ResultCode::InvalidArgument,
411            ResultCode::TooLarge,
412            ResultCode::Conflict,
413            ResultCode::VersionSkew,
414            ResultCode::Unauthenticated,
415            ResultCode::Backend,
416            ResultCode::Forbidden,
417            ResultCode::StepUpRequired,
418            ResultCode::Unrecognized(900),
419        ] {
420            assert!(
421                !code.is_retryable(),
422                "{code:?} must not invite a blind retry"
423            );
424        }
425    }
426
427    #[test]
428    fn given_result_codes_when_mapped_to_http_then_should_match_the_binding_table() {
429        assert_eq!(ResultCode::NotFound.http_status(), 404);
430        assert_eq!(ResultCode::Unsupported.http_status(), 501);
431        assert_eq!(ResultCode::TooLarge.http_status(), 413);
432        assert_eq!(ResultCode::Conflict.http_status(), 409);
433        assert_eq!(ResultCode::Stale.http_status(), 503);
434        assert_eq!(ResultCode::Unauthenticated.http_status(), 401);
435        assert_eq!(ResultCode::Forbidden.http_status(), 403);
436        assert_eq!(ResultCode::StepUpRequired.http_status(), 403);
437        assert_eq!(ResultCode::Backend.http_status(), 502);
438        // An unrecognized code from a newer peer maps to a generic 500 rather
439        // than panicking, and keeps its raw numeric.
440        assert_eq!(ResultCode::Unrecognized(777).http_status(), 500);
441        assert_eq!(ResultCode::Unrecognized(777).code(), 777);
442    }
443
444    #[cfg(feature = "cbor")]
445    #[test]
446    fn given_a_result_code_when_round_tripped_through_cbor_then_should_preserve_the_variant() {
447        use crate::framing::{decode_named, encode_named};
448        for code in [
449            ResultCode::Ok,
450            ResultCode::Conflict,
451            ResultCode::Stale,
452            ResultCode::Unrecognized(4242),
453        ] {
454            let bytes = encode_named(&code).expect("serializes");
455            let back: ResultCode = decode_named(&bytes).expect("deserializes");
456            assert_eq!(back, code);
457        }
458    }
459
460    #[cfg(feature = "cbor")]
461    #[test]
462    fn given_a_command_error_when_round_tripped_then_should_preserve_code_and_message() {
463        use crate::framing::{decode_named, encode_named};
464        let error = CommandError::unsupported("AGDX_KV_CAS not served on this build");
465        assert_eq!(error.code, ResultCode::Unsupported);
466        let bytes = encode_named(&error).expect("serializes");
467        let back: CommandError = decode_named(&bytes).expect("deserializes");
468        assert_eq!(back, error);
469    }
470}