Skip to main content

loonfs_api/
error.rs

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