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