Skip to main content

unitycatalog_storage_proxy/
error.rs

1//! The storage-proxy error contract, decoupled from any server's internal error.
2//!
3//! [`ProxyError`] is the error half of every handler `Result`. A backend
4//! [port](crate::backend::StorageProxyBackend) returns it directly (a server
5//! adapter maps its own internal error into one of these variants), and its
6//! [`IntoResponse`] emits the HTTP status the wire contract mandates.
7//!
8//! [`object_store::Error`] outcomes flow through the [`ProxyError::Storage`]
9//! variant, which is the single place cloud-storage results are mapped to a
10//! status — most importantly the `If-Match` relay: a conditional-write mismatch
11//! surfaces as [`object_store::Error::Precondition`] and becomes
12//! `412 Precondition Failed`.
13
14use axum::http::{StatusCode, header};
15use axum::response::{IntoResponse, Response};
16
17/// Result type used across the crate's handler surface and backend port.
18pub type ProxyResult<T> = Result<T, ProxyError>;
19
20/// An error from a storage-proxy operation.
21///
22/// Each variant maps to a fixed HTTP status via [`IntoResponse`]. The response
23/// body is a small JSON envelope (`{ "error": <message> }`); the wasm client
24/// keys off the status code, not the body.
25#[derive(Debug, thiserror::Error)]
26pub enum ProxyError {
27    /// The requested object or securable does not exist. → 404.
28    #[error("not found: {0}")]
29    NotFound(String),
30
31    /// The caller is not authenticated. → 401.
32    #[error("unauthenticated: {0}")]
33    Unauthenticated(String),
34
35    /// The caller is authenticated but not permitted for this verb/securable. → 403.
36    #[error("permission denied: {0}")]
37    PermissionDenied(String),
38
39    /// The request addresses a key outside the securable's authorized scope
40    /// (confused-deputy guard). → 403.
41    #[error("out of scope: {0}")]
42    OutOfScope(String),
43
44    /// The request is malformed (bad securable, unparsable header, `If-Match: *`). → 400.
45    #[error("invalid request: {0}")]
46    InvalidArgument(String),
47
48    /// A conditional write's `If-Match` did not match the current object. → 412.
49    #[error("precondition failed: {0}")]
50    PreconditionFailed(String),
51
52    /// The requested byte range cannot be satisfied. → 416.
53    #[error("range not satisfiable: {0}")]
54    RangeNotSatisfiable(String),
55
56    /// A `PUT` body exceeded the in-proxy buffering cap for a conditional write. → 413.
57    #[error("payload too large: {0}")]
58    PayloadTooLarge(String),
59
60    /// The requested functionality is not implemented (e.g. GCP credential
61    /// vending in the local arm). → 501.
62    #[error("not implemented: {0}")]
63    NotImplemented(&'static str),
64
65    /// An outcome from the underlying object store. Mapped to a status by
66    /// matching the [`object_store::Error`] variant (see [`ProxyError::storage_parts`]).
67    #[error(transparent)]
68    Storage(#[from] object_store::Error),
69
70    /// An unexpected internal failure. → 500.
71    #[error("internal error: {0}")]
72    Internal(String),
73}
74
75impl ProxyError {
76    /// The status for a wrapped [`object_store::Error`]. This is the single relay
77    /// point for cloud-storage semantics — notably `Precondition` → 412, the
78    /// server-enforced half of the `If-Match` conditional-write contract.
79    fn storage_parts(err: &object_store::Error) -> StatusCode {
80        match err {
81            object_store::Error::NotFound { .. } => StatusCode::NOT_FOUND,
82            object_store::Error::Precondition { .. } => StatusCode::PRECONDITION_FAILED,
83            object_store::Error::NotModified { .. } => StatusCode::NOT_MODIFIED,
84            object_store::Error::AlreadyExists { .. } => StatusCode::CONFLICT,
85            object_store::Error::PermissionDenied { .. } => StatusCode::FORBIDDEN,
86            object_store::Error::Unauthenticated { .. } => StatusCode::UNAUTHORIZED,
87            object_store::Error::NotSupported { .. }
88            | object_store::Error::NotImplemented { .. } => StatusCode::NOT_IMPLEMENTED,
89            object_store::Error::InvalidPath { .. } => StatusCode::BAD_REQUEST,
90            _ => StatusCode::INTERNAL_SERVER_ERROR,
91        }
92    }
93
94    /// The HTTP status this error maps to.
95    fn status(&self) -> StatusCode {
96        match self {
97            ProxyError::NotFound(_) => StatusCode::NOT_FOUND,
98            ProxyError::Unauthenticated(_) => StatusCode::UNAUTHORIZED,
99            ProxyError::PermissionDenied(_) | ProxyError::OutOfScope(_) => StatusCode::FORBIDDEN,
100            ProxyError::InvalidArgument(_) => StatusCode::BAD_REQUEST,
101            ProxyError::PreconditionFailed(_) => StatusCode::PRECONDITION_FAILED,
102            ProxyError::RangeNotSatisfiable(_) => StatusCode::RANGE_NOT_SATISFIABLE,
103            ProxyError::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE,
104            ProxyError::NotImplemented(_) => StatusCode::NOT_IMPLEMENTED,
105            ProxyError::Storage(e) => Self::storage_parts(e),
106            ProxyError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
107        }
108    }
109}
110
111impl IntoResponse for ProxyError {
112    fn into_response(self) -> Response {
113        let status = self.status();
114        let message = self.to_string();
115        // A minimal JSON envelope. The wasm client keys off the status; the body
116        // is for humans/logs. `Content-Type` is set explicitly so a bare string
117        // body isn't served as `text/plain`.
118        let body = format!("{{\"error\":{}}}", serde_json_string(&message));
119        (status, [(header::CONTENT_TYPE, "application/json")], body).into_response()
120    }
121}
122
123/// Serialize a string as a JSON string literal (escaping quotes/backslashes/control
124/// chars) without pulling `serde_json` into this crate's dependency set.
125fn serde_json_string(s: &str) -> String {
126    let mut out = String::with_capacity(s.len() + 2);
127    out.push('"');
128    for c in s.chars() {
129        match c {
130            '"' => out.push_str("\\\""),
131            '\\' => out.push_str("\\\\"),
132            '\n' => out.push_str("\\n"),
133            '\r' => out.push_str("\\r"),
134            '\t' => out.push_str("\\t"),
135            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
136            c => out.push(c),
137        }
138    }
139    out.push('"');
140    out
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn statuses_match_contract() {
149        assert_eq!(
150            ProxyError::OutOfScope("x".into()).status(),
151            StatusCode::FORBIDDEN
152        );
153        assert_eq!(
154            ProxyError::PreconditionFailed("x".into()).status(),
155            StatusCode::PRECONDITION_FAILED
156        );
157        assert_eq!(
158            ProxyError::RangeNotSatisfiable("x".into()).status(),
159            StatusCode::RANGE_NOT_SATISFIABLE
160        );
161        assert_eq!(
162            ProxyError::NotImplemented("gcp").status(),
163            StatusCode::NOT_IMPLEMENTED
164        );
165    }
166
167    #[test]
168    fn object_store_precondition_maps_to_412() {
169        let err = object_store::Error::Precondition {
170            path: "p".into(),
171            source: "etag mismatch".into(),
172        };
173        assert_eq!(
174            ProxyError::Storage(err).status(),
175            StatusCode::PRECONDITION_FAILED
176        );
177    }
178
179    #[test]
180    fn object_store_not_found_maps_to_404() {
181        let err = object_store::Error::NotFound {
182            path: "p".into(),
183            source: "missing".into(),
184        };
185        assert_eq!(ProxyError::Storage(err).status(), StatusCode::NOT_FOUND);
186    }
187
188    #[test]
189    fn json_escaping() {
190        assert_eq!(serde_json_string("a\"b\\c"), r#""a\"b\\c""#);
191        assert_eq!(serde_json_string("line\nbreak"), r#""line\nbreak""#);
192    }
193}