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