1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum ResultCode {
25 Ok,
27 Unsupported,
29 NotFound,
31 InvalidArgument,
33 TooLarge,
35 Conflict,
38 Stale,
41 VersionSkew,
43 Unauthenticated,
45 Backend,
47 Forbidden,
49 StepUpRequired,
52 Unavailable,
60 ResourceLimit,
62 Cancelled,
64 DeadlineExceeded,
66 ExpiredSnapshot,
68 StaleGeneration,
70 TargetUnavailable,
72 Unrecognized(u16),
78}
79
80impl ResultCode {
81 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 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 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 ResultCode::Forbidden => 403,
152 ResultCode::StepUpRequired => 403,
153 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 pub const fn is_retryable(self) -> bool {
171 matches!(
172 self,
173 ResultCode::Unavailable | ResultCode::Stale | ResultCode::TargetUnavailable
174 )
175 }
176}
177
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
189pub struct CommandError {
190 pub code: ResultCode,
191 pub message: String,
192}
193
194impl CommandError {
195 pub fn new(code: ResultCode, message: impl Into<String>) -> Self {
197 Self {
198 code,
199 message: message.into(),
200 }
201 }
202
203 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 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 }
247 }
248}
249
250impl From<&ForkError> for ResultCode {
251 fn from(error: &ForkError) -> Self {
252 match error {
253 ForkError::Unsupported(_) => ResultCode::Unsupported,
254 ForkError::NotFound(_) => ResultCode::NotFound,
255 ForkError::InvalidFork(_) => ResultCode::InvalidArgument,
256 ForkError::Conflict(_) => ResultCode::Conflict,
257 ForkError::Backend(_) => ResultCode::Backend,
258 ForkError::Unavailable(_) => ResultCode::Unavailable,
259 ForkError::Version { .. } => ResultCode::VersionSkew,
260 ForkError::NotLeader => ResultCode::Unavailable,
261 }
262 }
263}
264
265impl From<&GraphError> for ResultCode {
266 fn from(error: &GraphError) -> Self {
267 match error {
268 GraphError::Unsupported(_) => ResultCode::Unsupported,
269 GraphError::Unauthorized(_) => ResultCode::Forbidden,
270 GraphError::InvalidName(_) => ResultCode::InvalidArgument,
271 GraphError::NotFound(_) => ResultCode::NotFound,
272 GraphError::TooLarge { .. } => ResultCode::TooLarge,
273 GraphError::Backend(_) => ResultCode::Backend,
274 GraphError::Unavailable(_) => ResultCode::Unavailable,
275 GraphError::Version { .. } => ResultCode::VersionSkew,
276 }
277 }
278}
279
280impl From<&AgentError> for ResultCode {
281 fn from(error: &AgentError) -> Self {
282 match error {
283 AgentError::Unsupported(_) => ResultCode::Unsupported,
284 AgentError::NotFound(_) => ResultCode::NotFound,
285 AgentError::Invalid(_) => ResultCode::InvalidArgument,
286 AgentError::Backend(_) => ResultCode::Backend,
287 AgentError::Unavailable(_) => ResultCode::Unavailable,
288 AgentError::Version { .. } => ResultCode::VersionSkew,
289 AgentError::NotLeader => ResultCode::Unavailable,
290 }
291 }
292}
293
294impl From<&CheckpointError> for ResultCode {
295 fn from(error: &CheckpointError) -> Self {
296 match error {
297 CheckpointError::Invalid(_) => ResultCode::InvalidArgument,
298 CheckpointError::NotFound => ResultCode::NotFound,
299 CheckpointError::Conflict { .. } | CheckpointError::LeaseLost => ResultCode::Conflict,
300 CheckpointError::Unauthorized => ResultCode::Forbidden,
301 CheckpointError::Unavailable(_) => ResultCode::Unavailable,
302 CheckpointError::Version { .. } => ResultCode::VersionSkew,
303 }
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn given_result_codes_when_mapped_then_should_round_trip_through_the_numeric_value() {
313 for code in [
314 ResultCode::Ok,
315 ResultCode::Unsupported,
316 ResultCode::NotFound,
317 ResultCode::InvalidArgument,
318 ResultCode::TooLarge,
319 ResultCode::Conflict,
320 ResultCode::Stale,
321 ResultCode::VersionSkew,
322 ResultCode::Unauthenticated,
323 ResultCode::Backend,
324 ResultCode::Forbidden,
325 ResultCode::StepUpRequired,
326 ResultCode::Unavailable,
327 ] {
328 assert_eq!(ResultCode::from_code(code.code()), code);
329 }
330 assert_eq!(ResultCode::from_code(900), ResultCode::Unrecognized(900));
332 assert_eq!(ResultCode::Unrecognized(900).code(), 900);
333 }
334
335 #[test]
336 fn given_surface_errors_when_classified_then_should_map_to_the_shared_code() {
337 assert_eq!(
338 ResultCode::from(&QueryError::IndexNotFound("orders".to_owned())),
339 ResultCode::NotFound
340 );
341 assert_eq!(
342 ResultCode::from(&QueryError::Stale {
343 what: "orders".to_owned(),
344 applied: 4,
345 required: 9,
346 }),
347 ResultCode::Stale
348 );
349 assert_eq!(
350 ResultCode::from(&KvError::VersionConflict { current: Some(3) }),
351 ResultCode::Conflict
352 );
353 assert_eq!(
354 ResultCode::from(&ForkError::Conflict("open".to_owned())),
355 ResultCode::Conflict
356 );
357 assert_eq!(
358 ResultCode::from(&KvError::NotLeader),
359 ResultCode::Unavailable
360 );
361 assert_eq!(
362 ResultCode::from(&ForkError::NotLeader),
363 ResultCode::Unavailable
364 );
365 assert_eq!(
366 ResultCode::from(&AgentError::NotLeader),
367 ResultCode::Unavailable
368 );
369 }
370
371 #[test]
374 fn given_transient_outcomes_when_classified_then_should_be_the_only_retryable_codes() {
375 for surface in [
376 ResultCode::from(&QueryError::Unavailable("dropped".to_owned())),
377 ResultCode::from(&KvError::Unavailable("dropped".to_owned())),
378 ResultCode::from(&ForkError::Unavailable("dropped".to_owned())),
379 ResultCode::from(&AgentError::Unavailable("dropped".to_owned())),
380 ] {
381 assert_eq!(surface, ResultCode::Unavailable);
382 }
383 assert_eq!(ResultCode::Unavailable.http_status(), 503);
384 assert!(ResultCode::Unavailable.is_retryable());
385 assert!(ResultCode::Stale.is_retryable());
386 for code in [
387 ResultCode::Ok,
388 ResultCode::Unsupported,
389 ResultCode::NotFound,
390 ResultCode::InvalidArgument,
391 ResultCode::TooLarge,
392 ResultCode::Conflict,
393 ResultCode::VersionSkew,
394 ResultCode::Unauthenticated,
395 ResultCode::Backend,
396 ResultCode::Forbidden,
397 ResultCode::StepUpRequired,
398 ResultCode::Unrecognized(900),
399 ] {
400 assert!(
401 !code.is_retryable(),
402 "{code:?} must not invite a blind retry"
403 );
404 }
405 }
406
407 #[test]
408 fn given_result_codes_when_mapped_to_http_then_should_match_the_binding_table() {
409 assert_eq!(ResultCode::NotFound.http_status(), 404);
410 assert_eq!(ResultCode::Unsupported.http_status(), 501);
411 assert_eq!(ResultCode::TooLarge.http_status(), 413);
412 assert_eq!(ResultCode::Conflict.http_status(), 409);
413 assert_eq!(ResultCode::Stale.http_status(), 503);
414 assert_eq!(ResultCode::Unauthenticated.http_status(), 401);
415 assert_eq!(ResultCode::Forbidden.http_status(), 403);
416 assert_eq!(ResultCode::StepUpRequired.http_status(), 403);
417 assert_eq!(ResultCode::Backend.http_status(), 502);
418 assert_eq!(ResultCode::Unrecognized(777).http_status(), 500);
421 assert_eq!(ResultCode::Unrecognized(777).code(), 777);
422 }
423
424 #[cfg(feature = "cbor")]
425 #[test]
426 fn given_a_result_code_when_round_tripped_through_cbor_then_should_preserve_the_variant() {
427 use crate::framing::{decode_named, encode_named};
428 for code in [
429 ResultCode::Ok,
430 ResultCode::Conflict,
431 ResultCode::Stale,
432 ResultCode::Unrecognized(4242),
433 ] {
434 let bytes = encode_named(&code).expect("serializes");
435 let back: ResultCode = decode_named(&bytes).expect("deserializes");
436 assert_eq!(back, code);
437 }
438 }
439
440 #[cfg(feature = "cbor")]
441 #[test]
442 fn given_a_command_error_when_round_tripped_then_should_preserve_code_and_message() {
443 use crate::framing::{decode_named, encode_named};
444 let error = CommandError::unsupported("AGDX_KV_CAS not served on this build");
445 assert_eq!(error.code, ResultCode::Unsupported);
446 let bytes = encode_named(&error).expect("serializes");
447 let back: CommandError = decode_named(&bytes).expect("deserializes");
448 assert_eq!(back, error);
449 }
450}