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 backing object store rejected the deployment's credentials. The
24    /// operator must fix the credentials or bucket policy before retrying.
25    StoragePermissionDenied,
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 server cancelled work that exceeded its configured request
44    /// deadline. Reconcile any mutation before deciding whether to retry.
45    DeadlineExceeded,
46    /// Status grouping for conditions served as unavailable.
47    ///
48    /// This kind is not a retry predicate: some grouped conditions require
49    /// maintenance before another attempt can succeed. Use
50    /// [`ErrorCode::retryable_without_operator_action`] for that decision.
51    Unavailable,
52    /// The operation may have committed: its acknowledgment was lost. Retry
53    /// with the same commit id or reconcile against namespace state; do not
54    /// assume failure.
55    OutcomeUnknown,
56    /// Durable state is malformed. Treat this as operator or repair work.
57    DataCorruption,
58    /// LoonFS hit an internal failure. Capture details and report it.
59    Internal,
60}
61
62/// Declares the complete wire error-code registry in one place.
63///
64/// One `Variant => "wire_string"` line emits the enum variant, its
65/// [`ErrorCode::ALL`] entry (in registry order), its `as_str` arm, its
66/// `parse` arm, and the string-backed serde impls — so registering a new
67/// code is one line here plus a [`ErrorCode::kind`] arm and an api.md row.
68macro_rules! error_codes {
69    (@count) => { 0 };
70    (@count $head:ident $($tail:ident)*) => { 1 + error_codes!(@count $($tail)*) };
71    ($($variant:ident => $wire:literal),+ $(,)?) => {
72        /// Stable machine-readable error reason.
73        ///
74        /// This is the complete registry of `code` values carried by
75        /// [`ApiError`](crate::ApiError) bodies and embedded errors. Codes are
76        /// permanent once released: the API spec documents each code's meaning and HTTP
77        /// status, and clients must tolerate codes they do not recognize.
78        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
79        #[non_exhaustive]
80        pub enum ErrorCode {
81            $(
82                #[doc = concat!("Carries the stable wire code `", $wire, "`.")]
83                $variant,
84            )+
85        }
86
87        impl ErrorCode {
88            /// Every registered code, in registry order.
89            pub const ALL: [ErrorCode; error_codes!(@count $($variant)+)] =
90                [$(ErrorCode::$variant,)+];
91
92            /// Returns the stable wire string for this code.
93            pub fn as_str(self) -> &'static str {
94                match self {
95                    $(ErrorCode::$variant => $wire,)+
96                }
97            }
98
99            /// Parses a registered code string, returning `None` for codes this
100            /// build does not know (clients must tolerate those).
101            pub fn parse(value: &str) -> Option<ErrorCode> {
102                match value {
103                    $($wire => Some(ErrorCode::$variant),)+
104                    _ => None,
105                }
106            }
107        }
108
109        impl serde::Serialize for ErrorCode {
110            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
111                serializer.serialize_str(self.as_str())
112            }
113        }
114
115        impl<'de> serde::Deserialize<'de> for ErrorCode {
116            // Strict: unknown codes fail to deserialize. Wire structs carry
117            // codes as plain strings (`ApiError::code`) precisely so unknown
118            // codes stay tolerated; deserialize into `ErrorCode` only where
119            // strictness is intended.
120            fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
121                let value = String::deserialize(deserializer)?;
122                ErrorCode::parse(&value).ok_or_else(|| {
123                    serde::de::Error::custom(format_args!("unknown error code `{value}`"))
124                })
125            }
126        }
127    };
128}
129
130error_codes! {
131    InvalidRequest => "invalid_request",
132    Unauthorized => "unauthorized",
133    StoragePermissionDenied => "storage_permission_denied",
134    ContentTooLarge => "content_too_large",
135    NotSupported => "not_supported",
136    RouteNotFound => "route_not_found",
137    MethodNotAllowed => "method_not_allowed",
138    NamespaceNotFound => "namespace_not_found",
139    NamespaceDeleted => "namespace_deleted",
140    NamespaceExists => "namespace_exists",
141    SnapshotNotFound => "snapshot_not_found",
142    SnapshotGone => "snapshot_gone",
143    SnapshotQuotaExceeded => "snapshot_quota_exceeded",
144    ContentNotPrepared => "content_not_prepared",
145    PathNotFound => "path_not_found",
146    InodeNotFound => "inode_not_found",
147    RevisionNotFound => "revision_not_found",
148    PathConflict => "path_conflict",
149    DirectoryNotEmpty => "directory_not_empty",
150    StaleHead => "stale_head",
151    StaleRevision => "stale_revision",
152    StaleAttributes => "stale_attributes",
153    BindingGenerationMismatch => "binding_generation_mismatch",
154    NotDeleted => "not_deleted",
155    WriterFenced => "writer_fenced",
156    WouldCycle => "would_cycle",
157    CommitIdReuseConflict => "commit_id_reuse_conflict",
158    CommitOutcomeUnknown => "commit_outcome_unknown",
159    CommitQueueFull => "commit_queue_full",
160    ServerBusy => "server_busy",
161    ShuttingDown => "shutting_down",
162    DeadlineExceeded => "deadline_exceeded",
163    CheckpointUnavailable => "checkpoint_unavailable",
164    MaintenanceRequired => "maintenance_required",
165    UploadNotFound => "upload_not_found",
166    UploadAlreadyCompleted => "upload_already_completed",
167    UploadContentConflict => "upload_content_conflict",
168    RebootstrapRequired => "rebootstrap_required",
169    QueryUnindexable => "query_unindexable",
170    IndexLagging => "index_lagging",
171    IndexCorrupt => "index_corrupt",
172    NamespaceCorrupt => "namespace_corrupt",
173    ServerError => "server_error",
174}
175
176impl ErrorCode {
177    /// Returns the caller-action category for this code.
178    ///
179    /// The kind agrees with the HTTP status the api.md error table documents
180    /// for the code; the server derives the served status from it.
181    pub fn kind(self) -> ErrorKind {
182        match self {
183            // A pattern with no required grams is a property of the request,
184            // not the namespace: the caller rewrites the pattern or opts
185            // into a capped scan.
186            ErrorCode::InvalidRequest | ErrorCode::QueryUnindexable => ErrorKind::InvalidRequest,
187            ErrorCode::Unauthorized => ErrorKind::Unauthorized,
188            // This is a deployment storage failure, not a caller
189            // authorization failure.
190            ErrorCode::StoragePermissionDenied => ErrorKind::StoragePermissionDenied,
191            ErrorCode::ContentTooLarge => ErrorKind::ContentTooLarge,
192            ErrorCode::NotSupported => ErrorKind::NotSupported,
193            ErrorCode::NamespaceNotFound
194            | ErrorCode::SnapshotNotFound
195            | ErrorCode::PathNotFound
196            | ErrorCode::InodeNotFound
197            | ErrorCode::RevisionNotFound
198            | ErrorCode::UploadNotFound
199            | ErrorCode::RouteNotFound => ErrorKind::NotFound,
200            ErrorCode::MethodNotAllowed => ErrorKind::MethodNotAllowed,
201            ErrorCode::NamespaceDeleted | ErrorCode::SnapshotGone => ErrorKind::Gone,
202            ErrorCode::NamespaceExists => ErrorKind::AlreadyExists,
203            ErrorCode::DeadlineExceeded => ErrorKind::DeadlineExceeded,
204            ErrorCode::CommitQueueFull
205            | ErrorCode::ServerBusy
206            | ErrorCode::ShuttingDown
207            | ErrorCode::CheckpointUnavailable
208            | ErrorCode::IndexLagging
209            | ErrorCode::MaintenanceRequired => ErrorKind::Unavailable,
210            ErrorCode::CommitOutcomeUnknown => ErrorKind::OutcomeUnknown,
211            ErrorCode::IndexCorrupt | ErrorCode::NamespaceCorrupt => ErrorKind::DataCorruption,
212            ErrorCode::ServerError => ErrorKind::Internal,
213            // The spec deliberately surfaces precondition failures
214            // (`stale_revision`, `stale_head`, `commit_id_reuse_conflict`) as
215            // 409 resource-state conflicts, not 412 (api.md, "Standard error
216            // contract").
217            ErrorCode::ContentNotPrepared
218            | ErrorCode::PathConflict
219            | ErrorCode::DirectoryNotEmpty
220            | ErrorCode::StaleHead
221            | ErrorCode::StaleRevision
222            // An attribute update was decided against a different attribute
223            // revision than the one it wrote from, whether the caller stated
224            // that revision or the update's own guard observed it.
225            | ErrorCode::StaleAttributes
226            | ErrorCode::BindingGenerationMismatch
227            // Undelete's target is not the root of a live deletion: a
228            // state conflict, resolved by re-reading namespace state.
229            | ErrorCode::NotDeleted
230            | ErrorCode::WriterFenced
231            | ErrorCode::WouldCycle
232            | ErrorCode::CommitIdReuseConflict
233            | ErrorCode::UploadAlreadyCompleted
234            | ErrorCode::UploadContentConflict
235            | ErrorCode::RebootstrapRequired
236            | ErrorCode::SnapshotQuotaExceeded => ErrorKind::Conflict,
237        }
238    }
239
240    /// Returns whether this condition can clear without caller or operator action.
241    ///
242    /// This predicate is deliberately narrower than [`ErrorKind::Unavailable`]:
243    /// it includes only admission pressure and shutdown handoff that settle on
244    /// their own. Transport failures are classified separately by clients.
245    /// Reconciliation, request changes, and maintenance are caller or operator
246    /// actions and therefore return `false` here.
247    pub fn retryable_without_operator_action(self) -> bool {
248        match self {
249            ErrorCode::CommitQueueFull | ErrorCode::ServerBusy | ErrorCode::ShuttingDown => true,
250            ErrorCode::InvalidRequest
251            | ErrorCode::Unauthorized
252            | ErrorCode::StoragePermissionDenied
253            | ErrorCode::ContentTooLarge
254            | ErrorCode::NotSupported
255            | ErrorCode::RouteNotFound
256            | ErrorCode::MethodNotAllowed
257            | ErrorCode::NamespaceNotFound
258            | ErrorCode::NamespaceDeleted
259            | ErrorCode::NamespaceExists
260            | ErrorCode::SnapshotNotFound
261            | ErrorCode::SnapshotGone
262            | ErrorCode::SnapshotQuotaExceeded
263            | ErrorCode::ContentNotPrepared
264            | ErrorCode::PathNotFound
265            | ErrorCode::InodeNotFound
266            | ErrorCode::RevisionNotFound
267            | ErrorCode::PathConflict
268            | ErrorCode::DirectoryNotEmpty
269            | ErrorCode::StaleHead
270            | ErrorCode::StaleRevision
271            | ErrorCode::StaleAttributes
272            | ErrorCode::BindingGenerationMismatch
273            | ErrorCode::NotDeleted
274            | ErrorCode::WriterFenced
275            | ErrorCode::WouldCycle
276            | ErrorCode::CommitIdReuseConflict
277            | ErrorCode::CommitOutcomeUnknown
278            | ErrorCode::DeadlineExceeded
279            | ErrorCode::CheckpointUnavailable
280            | ErrorCode::MaintenanceRequired
281            | ErrorCode::UploadNotFound
282            | ErrorCode::UploadAlreadyCompleted
283            | ErrorCode::UploadContentConflict
284            | ErrorCode::RebootstrapRequired
285            | ErrorCode::QueryUnindexable
286            | ErrorCode::IndexLagging
287            | ErrorCode::IndexCorrupt
288            | ErrorCode::NamespaceCorrupt
289            | ErrorCode::ServerError => false,
290        }
291    }
292}
293
294impl fmt::Display for ErrorCode {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        f.write_str(self.as_str())
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::ErrorCode;
303
304    #[test]
305    fn error_codes_serde_uses_the_wire_strings() {
306        for code in ErrorCode::ALL {
307            let value = serde_json::to_value(code).expect("serialize error code");
308            assert_eq!(value, serde_json::Value::String(code.as_str().to_owned()));
309            let parsed: ErrorCode = serde_json::from_value(value).expect("deserialize error code");
310            assert_eq!(parsed, code);
311        }
312        assert!(serde_json::from_str::<ErrorCode>("\"not_a_code\"").is_err());
313    }
314
315    #[test]
316    fn retryability_is_limited_to_self_clearing_admission_conditions() {
317        let retryable: Vec<_> = ErrorCode::ALL
318            .into_iter()
319            .filter(|code| code.retryable_without_operator_action())
320            .collect();
321
322        assert_eq!(
323            retryable,
324            [
325                ErrorCode::CommitQueueFull,
326                ErrorCode::ServerBusy,
327                ErrorCode::ShuttingDown,
328            ]
329        );
330    }
331}