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 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 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 #[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 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}