Skip to main content

loonfs_api/
error.rs

1//! The wire error registry: every stable machine-readable error code and
2//! its caller-action category.
3
4use std::fmt;
5
6/// Broad error category for caller or operator action.
7///
8/// Each kind implies one served HTTP status (`status_for_error_kind` in
9/// `loonfs-server`); a code's kind and its documented status in the API spec
10/// must agree in spirit, and the server's spec-table sync test enforces the
11/// composition exactly.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum ErrorKind {
15    /// Fix the request before retrying.
16    InvalidRequest,
17    /// The request was not authorized. Fix credentials before retrying.
18    Unauthorized,
19    /// The request body exceeds the deployment's size limit for this
20    /// operation. Send a smaller payload (for uploads, prefer `direct_put`);
21    /// retrying unchanged will not succeed.
22    ContentTooLarge,
23    /// The caller may not perform this operation. Request access; retrying
24    /// unchanged will not succeed.
25    PermissionDenied,
26    /// The deployment does not implement this operation. Gate on the
27    /// capability document instead of retrying.
28    NotSupported,
29    /// The requested object does not exist. Refresh state or choose another target.
30    NotFound,
31    /// The path routes somewhere, but not for this HTTP method. Fix the
32    /// request; retrying unchanged will not succeed.
33    MethodNotAllowed,
34    /// The target was deleted and its id is permanently retired. Do not
35    /// retry; choose another target.
36    Gone,
37    /// The create target already exists. Pick another id or treat this as idempotent.
38    AlreadyExists,
39    /// The request raced with current namespace state, or a caller-supplied
40    /// precondition (base revision, expected head) no longer holds. Re-read
41    /// fresh state, re-plan, and retry if desired.
42    Conflict,
43    /// The system is temporarily unavailable. Back off and retry.
44    Unavailable,
45    /// The operation may have committed: its acknowledgment was lost. Retry
46    /// with the same commit id or reconcile against namespace state; do not
47    /// assume failure.
48    OutcomeUnknown,
49    /// Durable state is malformed. Treat this as operator or repair work.
50    DataCorruption,
51    /// LoonFS hit an internal failure. Capture details and report it.
52    Internal,
53}
54
55/// Declares the complete wire error-code registry in one place.
56///
57/// One `Variant => "wire_string"` line emits the enum variant, its
58/// [`ErrorCode::ALL`] entry (in registry order), its `as_str` arm, its
59/// `parse` arm, and the string-backed serde impls — so registering a new
60/// code is one line here plus a [`ErrorCode::kind`] arm and an api.md row.
61macro_rules! error_codes {
62    (@count) => { 0 };
63    (@count $head:ident $($tail:ident)*) => { 1 + error_codes!(@count $($tail)*) };
64    ($($variant:ident => $wire:literal),+ $(,)?) => {
65        /// Stable machine-readable error reason.
66        ///
67        /// This is the complete registry of `code` values carried by
68        /// [`ApiError`](crate::ApiError) bodies and embedded errors. Codes are
69        /// permanent once released: the API spec documents each code's meaning and HTTP
70        /// status, and clients must tolerate codes they do not recognize.
71        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
72        #[non_exhaustive]
73        pub enum ErrorCode {
74            $(
75                #[doc = concat!("Carries the stable wire code `", $wire, "`.")]
76                $variant,
77            )+
78        }
79
80        impl ErrorCode {
81            /// Every registered code, in registry order.
82            pub const ALL: [ErrorCode; error_codes!(@count $($variant)+)] =
83                [$(ErrorCode::$variant,)+];
84
85            /// Returns the stable wire string for this code.
86            pub fn as_str(self) -> &'static str {
87                match self {
88                    $(ErrorCode::$variant => $wire,)+
89                }
90            }
91
92            /// Parses a registered code string, returning `None` for codes this
93            /// build does not know (clients must tolerate those).
94            pub fn parse(value: &str) -> Option<ErrorCode> {
95                match value {
96                    $($wire => Some(ErrorCode::$variant),)+
97                    _ => None,
98                }
99            }
100        }
101
102        impl serde::Serialize for ErrorCode {
103            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
104                serializer.serialize_str(self.as_str())
105            }
106        }
107
108        impl<'de> serde::Deserialize<'de> for ErrorCode {
109            // Strict: unknown codes fail to deserialize. Wire structs carry
110            // codes as plain strings (`ApiError::code`) precisely so unknown
111            // codes stay tolerated; deserialize into `ErrorCode` only where
112            // strictness is intended.
113            fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
114                let value = String::deserialize(deserializer)?;
115                ErrorCode::parse(&value).ok_or_else(|| {
116                    serde::de::Error::custom(format_args!("unknown error code `{value}`"))
117                })
118            }
119        }
120    };
121}
122
123error_codes! {
124    InvalidRequest => "invalid_request",
125    Unauthorized => "unauthorized",
126    PermissionDenied => "permission_denied",
127    ContentTooLarge => "content_too_large",
128    NotSupported => "not_supported",
129    RouteNotFound => "route_not_found",
130    MethodNotAllowed => "method_not_allowed",
131    NamespaceNotFound => "namespace_not_found",
132    NamespaceDeleted => "namespace_deleted",
133    NamespaceExists => "namespace_exists",
134    ContentNotPrepared => "content_not_prepared",
135    PathNotFound => "path_not_found",
136    RevisionNotFound => "revision_not_found",
137    PathConflict => "path_conflict",
138    DirectoryNotEmpty => "directory_not_empty",
139    StaleHead => "stale_head",
140    StaleRevision => "stale_revision",
141    NotDeleted => "not_deleted",
142    WriterFenced => "writer_fenced",
143    WouldCycle => "would_cycle",
144    CommitIdReuseConflict => "commit_id_reuse_conflict",
145    CommitOutcomeUnknown => "commit_outcome_unknown",
146    CommitQueueFull => "commit_queue_full",
147    ServerBusy => "server_busy",
148    ShuttingDown => "shutting_down",
149    CheckpointUnavailable => "checkpoint_unavailable",
150    MaintenanceRequired => "maintenance_required",
151    UploadNotFound => "upload_not_found",
152    UploadAlreadyCompleted => "upload_already_completed",
153    UploadContentConflict => "upload_content_conflict",
154    RebootstrapRequired => "rebootstrap_required",
155    QueryUnindexable => "query_unindexable",
156    IndexLagging => "index_lagging",
157    IndexCorrupt => "index_corrupt",
158    NamespaceCorrupt => "namespace_corrupt",
159    ServerError => "server_error",
160}
161
162impl ErrorCode {
163    /// Returns the caller-action category for this code.
164    ///
165    /// The kind agrees with the HTTP status the api.md error table documents
166    /// for the code; the server derives the served status from it.
167    pub fn kind(self) -> ErrorKind {
168        match self {
169            // A pattern with no required grams is a property of the request,
170            // not the namespace: the caller rewrites the pattern or opts
171            // into a capped scan.
172            ErrorCode::InvalidRequest | ErrorCode::QueryUnindexable => ErrorKind::InvalidRequest,
173            ErrorCode::Unauthorized => ErrorKind::Unauthorized,
174            // Produced when the backing object store rejects the
175            // deployment's credentials: operator-actionable and never
176            // transient, exactly the kind's contract.
177            ErrorCode::PermissionDenied => ErrorKind::PermissionDenied,
178            ErrorCode::ContentTooLarge => ErrorKind::ContentTooLarge,
179            ErrorCode::NotSupported => ErrorKind::NotSupported,
180            ErrorCode::NamespaceNotFound
181            | ErrorCode::PathNotFound
182            | ErrorCode::RevisionNotFound
183            | ErrorCode::UploadNotFound
184            | ErrorCode::RouteNotFound => ErrorKind::NotFound,
185            ErrorCode::MethodNotAllowed => ErrorKind::MethodNotAllowed,
186            ErrorCode::NamespaceDeleted => ErrorKind::Gone,
187            ErrorCode::NamespaceExists => ErrorKind::AlreadyExists,
188            // `index_lagging` clears once maintenance catches the index up,
189            // so it is served as retryable unavailability.
190            ErrorCode::CommitQueueFull
191            | ErrorCode::ServerBusy
192            | ErrorCode::ShuttingDown
193            | ErrorCode::CheckpointUnavailable
194            | ErrorCode::IndexLagging
195            | ErrorCode::MaintenanceRequired => ErrorKind::Unavailable,
196            ErrorCode::CommitOutcomeUnknown => ErrorKind::OutcomeUnknown,
197            ErrorCode::IndexCorrupt | ErrorCode::NamespaceCorrupt => ErrorKind::DataCorruption,
198            ErrorCode::ServerError => ErrorKind::Internal,
199            // The spec deliberately surfaces precondition failures
200            // (`stale_revision`, `stale_head`, `commit_id_reuse_conflict`) as
201            // 409 resource-state conflicts, not 412 (api.md, "Standard error
202            // contract").
203            ErrorCode::ContentNotPrepared
204            | ErrorCode::PathConflict
205            | ErrorCode::DirectoryNotEmpty
206            | ErrorCode::StaleHead
207            | ErrorCode::StaleRevision
208            // Undelete's target is not the root of a live deletion: a
209            // state conflict, resolved by re-reading namespace state.
210            | ErrorCode::NotDeleted
211            | ErrorCode::WriterFenced
212            | ErrorCode::WouldCycle
213            | ErrorCode::CommitIdReuseConflict
214            | ErrorCode::UploadAlreadyCompleted
215            | ErrorCode::UploadContentConflict
216            | ErrorCode::RebootstrapRequired => ErrorKind::Conflict,
217        }
218    }
219}
220
221impl fmt::Display for ErrorCode {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        f.write_str(self.as_str())
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::ErrorCode;
230
231    #[test]
232    fn error_codes_round_trip_through_their_strings() {
233        for code in ErrorCode::ALL {
234            assert_eq!(ErrorCode::parse(code.as_str()), Some(code));
235        }
236        assert_eq!(ErrorCode::parse("not_a_code"), None);
237    }
238
239    #[test]
240    fn error_codes_serde_uses_the_wire_strings() {
241        for code in ErrorCode::ALL {
242            let value = serde_json::to_value(code).expect("serialize error code");
243            assert_eq!(value, serde_json::Value::String(code.as_str().to_owned()));
244            let parsed: ErrorCode = serde_json::from_value(value).expect("deserialize error code");
245            assert_eq!(parsed, code);
246        }
247        assert!(serde_json::from_str::<ErrorCode>("\"not_a_code\"").is_err());
248    }
249}