Skip to main content

notedthat_write/
error.rs

1//! Error types for the shared write path.
2
3use notedthat_core::{Error as CoreError, StorageError};
4
5/// What a write had already done to storage when a later step failed.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum WriteEffect {
8    /// The bytes are stored under the key.
9    Stored,
10    /// The key is gone.
11    Deleted,
12}
13
14impl std::fmt::Display for WriteEffect {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        f.write_str(match self {
17            Self::Stored => "stored",
18            Self::Deleted => "deleted",
19        })
20    }
21}
22
23/// Errors returned by shared write operations.
24#[derive(Debug, thiserror::Error)]
25pub enum WriteError {
26    /// Storage-layer failure.
27    #[error(transparent)]
28    Storage(StorageError),
29    /// Upload size exceeded the configured limit.
30    #[error("payload too large: {size} bytes (limit {limit})")]
31    TooLarge {
32        /// Actual byte size.
33        size: u64,
34        /// Maximum allowed byte size.
35        limit: u64,
36    },
37    /// Path/domain validation failure.
38    #[error(transparent)]
39    Path(CoreError),
40    /// Indexer queue was full while enqueueing an upsert.
41    #[error("indexer queue full during upsert")]
42    IndexerBackpressureUpsert,
43    /// Indexer queue was full while enqueueing a tombstone.
44    #[error("indexer queue full during tombstone")]
45    IndexerBackpressureTombstone,
46    /// The event log refused the change after storage had already taken it.
47    ///
48    /// Mirrors the indexer backpressure variants: the caller answers 503 with
49    /// `Retry-After` and a retried write publishes the event (D38, D55).
50    #[error("change event not published after the object was {after}")]
51    EventPublishFailed {
52        /// What storage had already done by the time publishing failed.
53        after: WriteEffect,
54    },
55    /// Object body exceeds the `NOTEDTHAT_MAX_PATCHABLE_SIZE` limit before or after splice.
56    #[error("patch payload too large: {size} bytes (limit {limit})")]
57    PatchTooLarge {
58        /// Actual byte size.
59        size: u64,
60        /// Maximum allowed byte size.
61        limit: u64,
62    },
63    /// Requested line range is beyond the end of the object at server-side splice time.
64    #[error("line range {first}..{last} out of range (total {total_lines} lines)")]
65    PatchLineOutOfRange {
66        /// Requested first line.
67        first: u64,
68        /// Requested last line.
69        last: u64,
70        /// Total line count in the object.
71        total_lines: u64,
72        /// Total byte count in the object (for X-Content-Range-Bytes).
73        total_bytes: u64,
74    },
75    /// Invalid range, mode contradiction, or missing If-Match for PATCH.
76    #[error("invalid patch request: {message}")]
77    PatchInvalidRange {
78        /// Human-readable reason.
79        message: String,
80    },
81    /// Replace operation found no occurrence of the requested old string.
82    #[error("replace: no match found for old_string")]
83    ReplaceNoMatch,
84    /// Replace operation found multiple occurrences, making single replace ambiguous.
85    #[error("replace: found {count} matches; use replace_all to replace them all")]
86    ReplaceAmbiguous {
87        /// Number of matches found in the object body.
88        count: u64,
89    },
90}
91
92impl From<StorageError> for WriteError {
93    fn from(err: StorageError) -> Self {
94        Self::Storage(err)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::WriteError;
101
102    #[test]
103    fn patch_too_large_displays_size_limit_when_constructed() {
104        let err = WriteError::PatchTooLarge {
105            size: 200 * 1024 * 1024,
106            limit: 100 * 1024 * 1024,
107        };
108
109        assert!(err.to_string().contains("too large"));
110    }
111
112    #[test]
113    fn patch_line_out_of_range_constructs_with_line_and_byte_totals() {
114        let err = WriteError::PatchLineOutOfRange {
115            first: 999,
116            last: 1000,
117            total_lines: 20,
118            total_bytes: 100,
119        };
120
121        assert!(err.to_string().contains("out of range"));
122    }
123
124    #[test]
125    fn patch_invalid_range_displays_reason_when_constructed() {
126        let err = WriteError::PatchInvalidRange {
127            message: "test".into(),
128        };
129
130        assert!(err.to_string().contains("invalid patch"));
131    }
132
133    #[test]
134    fn replace_ambiguous_displays_match_count_when_constructed() {
135        let err = WriteError::ReplaceAmbiguous { count: 3 };
136
137        assert_eq!(
138            err.to_string(),
139            "replace: found 3 matches; use replace_all to replace them all"
140        );
141    }
142}