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