Skip to main content

git_remote_object_store/object_store/
error.rs

1//! Shared error type for every [`ObjectStore`][super::ObjectStore]
2//! implementation.
3//!
4//! Centralises the mapping of backend-specific failure codes onto a small,
5//! finite set of variants so higher layers (push, fetch, doctor, LFS) can
6//! pattern-match without caring whether the underlying SDK returned an
7//! `aws_sdk_s3::error::SdkError` or an `azure_core::error::Error`.
8//!
9//! On the conditional-write path, S3 returns 412 (`PreconditionFailed`)
10//! *and* 409 (`ConditionalRequestConflict`) for the same
11//! `If-None-Match: "*"` contention path; both variants are kept here so
12//! backends can preserve the distinction in diagnostics, while the
13//! `put_if_absent` trait method collapses both into the `Ok(false)`
14//! "lock not acquired" return.
15
16use std::error::Error as StdError;
17
18/// Boxed source error used by [`ObjectStoreError::Network`] and
19/// [`ObjectStoreError::Other`].
20///
21/// `Send + Sync + 'static` so the error can cross task boundaries; this
22/// matches the bounds `tokio::task::JoinHandle` and friends impose.
23pub type BoxError = Box<dyn StdError + Send + Sync + 'static>;
24
25/// Errors returned by every [`ObjectStore`][super::ObjectStore] method.
26///
27/// The `String` payload on the four key-correlated variants names the key
28/// (or, for `list`, the prefix) the operation was attempting, so
29/// `tracing::error!` lines remain actionable without the caller adding
30/// context.
31#[derive(Debug, thiserror::Error)]
32pub enum ObjectStoreError {
33    /// Object (or, for `list`, every object under the prefix) is absent.
34    #[error("object not found: {0}")]
35    NotFound(String),
36
37    /// Authentication succeeded but the principal is not allowed to perform
38    /// the operation. Maps from S3 `AccessDenied` (HTTP 403) and Azure
39    /// `AuthorizationFailure`.
40    #[error("access denied: {0}")]
41    AccessDenied(String),
42
43    /// Conditional request returned 412 — the precondition (typically
44    /// `If-None-Match: "*"`) was not satisfied. `put_if_absent`
45    /// collapses this into `Ok(false)`, so callers should rarely
46    /// observe it directly.
47    #[error("precondition failed: {0}")]
48    PreconditionFailed(String),
49
50    /// Conditional request returned 409. Treated by `put_if_absent` callers
51    /// the same as `PreconditionFailed`, but kept distinct for diagnostics.
52    #[error("conflict: {0}")]
53    Conflict(String),
54
55    /// Upload body exceeded the backend's size ceiling for the API call
56    /// in use. Maps from S3 `EntityTooLarge` (single-PUT > 5 GiB) and
57    /// Azure 413 / `RequestBodyTooLarge` (single Put Blob > 5000 MiB).
58    /// `limit_bytes` is the backend-specific ceiling at the time the
59    /// classifier ran, surfaced so the wire-line message names a concrete
60    /// number rather than dumping an opaque SDK error chain.
61    #[error("upload exceeds backend size limit ({})", format_byte_limit(*limit_bytes))]
62    PayloadTooLarge {
63        /// The backend's documented single-call size ceiling, in bytes.
64        limit_bytes: u64,
65    },
66
67    /// Ranged GET requested a byte range that the backend cannot
68    /// satisfy. Maps from HTTP 416 on both S3 and Azure, and surfaces
69    /// caller-side range bugs (`start > end`) without issuing a network
70    /// call. The `requested` range is the half-open `[start, end)` the
71    /// caller passed to `get_bytes_range`.
72    #[error("range {}..{} not satisfiable for key `{key}`", requested.start, requested.end)]
73    RangeNotSatisfiable {
74        /// Key being read.
75        key: String,
76        /// Range the caller asked for (half-open, end-exclusive).
77        requested: std::ops::Range<u64>,
78    },
79
80    /// Transport-level failure (DNS, TLS, timeout, connection reset).
81    /// Carries the original SDK error as `#[source]` so the chain is
82    /// preserved. The inner error is included in the display so the
83    /// SDK-level detail (e.g. "operation timed out") surfaces in the
84    /// push `error <ref>` wire line without requiring verbose logging.
85    #[error("network error: {0}")]
86    Network(#[source] BoxError),
87
88    /// Operation is not implemented for the backend in use. Used by
89    /// optional [`ObjectStore`](crate::object_store::ObjectStore)
90    /// methods such as `presigned_get_url` that not every backend
91    /// can satisfy (e.g. `MockStore` in tests, or `AzureStore`
92    /// configured with a `TokenCredential` rather than a shared
93    /// account key — the latter cannot generate a service-SAS). The
94    /// payload is the full operator-facing message; the variant
95    /// adds no prefix of its own so chained errors do not double-
96    /// say "not supported".
97    #[error("{0}")]
98    Unsupported(String),
99
100    /// Any backend failure that does not fit the variants above.
101    #[error(transparent)]
102    Other(BoxError),
103}
104
105/// Render a byte ceiling as the largest unit that divides it cleanly
106/// (`5 GiB`, `5000 MiB`, otherwise raw bytes). Used in
107/// [`ObjectStoreError::PayloadTooLarge`]'s `Display` so the wire-line
108/// reads as a familiar quota number rather than "5368709120".
109fn format_byte_limit(bytes: u64) -> String {
110    const GIB: u64 = 1 << 30;
111    const MIB: u64 = 1 << 20;
112    if bytes >= GIB && bytes.is_multiple_of(GIB) {
113        format!("{} GiB", bytes / GIB)
114    } else if bytes >= MIB && bytes.is_multiple_of(MIB) {
115        format!("{} MiB", bytes / MIB)
116    } else {
117        format!("{bytes} B")
118    }
119}
120
121/// Wrap any concrete `std::error::Error` into [`ObjectStoreError::Other`].
122///
123/// Replaces the open-coded `|e| ObjectStoreError::Other(Box::new(e))` closure
124/// that otherwise repeats at every I/O / time-conversion / persist
125/// call site.
126pub(crate) fn other_boxed<E: StdError + Send + Sync + 'static>(e: E) -> ObjectStoreError {
127    ObjectStoreError::Other(Box::new(e))
128}
129
130/// Wrap any concrete `std::error::Error` into [`ObjectStoreError::Network`].
131///
132/// Replaces the open-coded `|e| ObjectStoreError::Network(Box::new(e))`
133/// closure used at every body-streaming / multipart-chunk site that
134/// surfaces a transport failure.
135pub(crate) fn network_boxed<E: StdError + Send + Sync + 'static>(e: E) -> ObjectStoreError {
136    ObjectStoreError::Network(Box::new(e))
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn boxed_io(message: &str) -> BoxError {
144        Box::new(std::io::Error::other(message.to_string()))
145    }
146
147    #[test]
148    fn display_names_the_key() {
149        assert_eq!(
150            ObjectStoreError::NotFound("a/b".into()).to_string(),
151            "object not found: a/b"
152        );
153        assert_eq!(
154            ObjectStoreError::AccessDenied("a/b".into()).to_string(),
155            "access denied: a/b"
156        );
157        assert_eq!(
158            ObjectStoreError::PreconditionFailed("a/b".into()).to_string(),
159            "precondition failed: a/b"
160        );
161        assert_eq!(
162            ObjectStoreError::Conflict("a/b".into()).to_string(),
163            "conflict: a/b"
164        );
165    }
166
167    #[test]
168    fn network_preserves_source_chain() {
169        let err = ObjectStoreError::Network(boxed_io("dns failure"));
170        assert_eq!(err.to_string(), "network error: dns failure");
171        let source = err.source().expect("Network exposes its #[source]");
172        assert_eq!(source.to_string(), "dns failure");
173    }
174
175    #[test]
176    fn other_is_transparent() {
177        let err = ObjectStoreError::Other(boxed_io("boom"));
178        // `transparent` forwards Display to the inner error.
179        assert_eq!(err.to_string(), "boom");
180    }
181
182    #[test]
183    fn payload_too_large_renders_gib_when_exact() {
184        let err = ObjectStoreError::PayloadTooLarge {
185            limit_bytes: 5 * (1 << 30),
186        };
187        assert_eq!(err.to_string(), "upload exceeds backend size limit (5 GiB)");
188    }
189
190    #[test]
191    fn payload_too_large_renders_mib_when_not_a_clean_gib() {
192        // Azure single-PUT ceiling is 5000 MiB — close to but not 5 GiB.
193        let err = ObjectStoreError::PayloadTooLarge {
194            limit_bytes: 5_000 * (1 << 20),
195        };
196        assert_eq!(
197            err.to_string(),
198            "upload exceeds backend size limit (5000 MiB)"
199        );
200    }
201
202    #[test]
203    fn payload_too_large_falls_back_to_raw_bytes() {
204        let err = ObjectStoreError::PayloadTooLarge { limit_bytes: 1_234 };
205        assert_eq!(
206            err.to_string(),
207            "upload exceeds backend size limit (1234 B)"
208        );
209    }
210
211    #[test]
212    fn range_not_satisfiable_names_key_and_range() {
213        let err = ObjectStoreError::RangeNotSatisfiable {
214            key: "packs/abc.pack".to_string(),
215            requested: 100..200,
216        };
217        assert_eq!(
218            err.to_string(),
219            "range 100..200 not satisfiable for key `packs/abc.pack`"
220        );
221    }
222}