Skip to main content

unitycatalog_client/
error.rs

1use unitycatalog_delta_api::models::DeltaErrorModel;
2
3pub type Result<T, E = Error> = std::result::Result<T, E>;
4
5#[derive(Debug, thiserror::Error)]
6pub enum Error {
7    #[error("Common Error: {source}")]
8    Common {
9        #[from]
10        source: unitycatalog_common::Error,
11    },
12
13    #[error("Delta API error {}: [{:?}] {}", .0.code, .0.error_type, .0.message)]
14    Delta(DeltaErrorModel),
15
16    // `olai_http::Error` is a native-only transport error (olai-http is not a wasm
17    // dependency). On wasm, transport failures surface as `RequestError` instead.
18    #[cfg(not(target_arch = "wasm32"))]
19    #[error("Client Error: {source}")]
20    ClientError {
21        #[from]
22        source: olai_http::Error,
23    },
24
25    #[error("Malformed response: {source}")]
26    MalformedResponse {
27        #[from]
28        source: serde_json::Error,
29    },
30
31    #[error("Malformed url: {source}")]
32    MalformedUrl {
33        #[from]
34        source: url::ParseError,
35    },
36
37    #[error("Reqwuest error: {0}")]
38    RequestError(#[from] reqwest::Error),
39
40    #[error("API error: {0}")]
41    Api(#[from] UcApiError),
42
43    #[error("Generic error: {0}")]
44    Generic(String),
45}
46
47impl Error {
48    pub fn generic(message: impl ToString) -> Self {
49        Error::Generic(message.to_string())
50    }
51
52    /// Map a failed `/delta/v1` request into a typed [`Error`].
53    ///
54    /// A request sent via [`olai_http::CloudRequestBuilder::send_raw`] fails with
55    /// a [`SendRawError`]: a signing failure carries no HTTP response and maps to
56    /// [`Error::ClientError`]; a request failure carrying an HTTP status + body
57    /// (a [`olai_http::RetryError`]) is parsed as a Delta error envelope via
58    /// [`parse_delta_error`]. A transport failure with no status also maps to
59    /// [`Error::ClientError`].
60    #[cfg(not(target_arch = "wasm32"))]
61    pub(crate) fn from_delta_send(err: olai_http::SendRawError) -> Self {
62        match err {
63            olai_http::SendRawError::Sign(e) => Error::ClientError { source: e },
64            olai_http::SendRawError::Retry(e) => match e.status() {
65                Some(status) => parse_delta_error(status.as_u16(), e.body().map(str::as_bytes)),
66                // No status: a transport/timeout failure, not an HTTP error body.
67                None => Error::ClientError { source: e.error() },
68            },
69        }
70    }
71
72    /// Map a failed UC API request into a typed [`Error`], mirroring
73    /// [`from_delta_send`](Self::from_delta_send) but parsing the UC API error
74    /// body (`{ error_code, message }`) via [`parse_error`].
75    #[cfg(not(target_arch = "wasm32"))]
76    pub(crate) fn from_api_send(err: olai_http::SendRawError) -> Self {
77        match err {
78            olai_http::SendRawError::Sign(e) => Error::ClientError { source: e },
79            olai_http::SendRawError::Retry(e) => match e.status() {
80                Some(status) => parse_error(status.as_u16(), e.body().map(str::as_bytes)),
81                None => Error::ClientError { source: e.error() },
82            },
83        }
84    }
85
86    /// wasm counterpart of [`from_delta_send`](Self::from_delta_send).
87    ///
88    /// `WasmClient::send_raw` returns a plain `reqwest::Error`, which is only ever
89    /// a transport/CORS failure (the browser Fetch backend has no retry layer and
90    /// does not classify HTTP status). Non-2xx responses come back as
91    /// `Ok(Response)` and are handled at the call site (see
92    /// [`Error::delta_status`] / the wasm `table_exists` path), so here we only map
93    /// the transport error.
94    #[cfg(target_arch = "wasm32")]
95    pub(crate) fn from_delta_send(err: reqwest::Error) -> Self {
96        Error::RequestError(err)
97    }
98
99    /// wasm counterpart of [`from_api_send`](Self::from_api_send).
100    #[cfg(target_arch = "wasm32")]
101    pub(crate) fn from_api_send(err: reqwest::Error) -> Self {
102        Error::RequestError(err)
103    }
104
105    /// Build a [`Error::Delta`] from a bare HTTP status with no error body.
106    ///
107    /// Used on the wasm path where a non-2xx `Ok(Response)` carries no parsed Delta
108    /// envelope (e.g. a HEAD response) — mirrors [`parse_delta_error`] with an
109    /// empty body.
110    #[cfg(target_arch = "wasm32")]
111    pub(crate) fn delta_status(status: reqwest::StatusCode) -> Self {
112        parse_delta_error(status.as_u16(), None)
113    }
114}
115
116/// Status-check a `reqwest::Response` and parse a non-2xx body as a Delta error
117/// envelope, on wasm only.
118///
119/// On native, `CloudClient::send_raw` already fails a non-2xx with a
120/// `SendRawError::Retry` (handled by [`Error::from_delta_send`]), so a `Response`
121/// in hand is already successful — this is a passthrough. On wasm, `WasmClient`'s
122/// Fetch backend returns `Ok(Response)` for every status, so we must classify it
123/// here to match native semantics.
124#[cfg(not(target_arch = "wasm32"))]
125pub(crate) async fn check_delta_response(response: reqwest::Response) -> Result<reqwest::Response> {
126    Ok(response)
127}
128
129#[cfg(target_arch = "wasm32")]
130pub(crate) async fn check_delta_response(response: reqwest::Response) -> Result<reqwest::Response> {
131    let status = response.status();
132    if status.is_success() {
133        return Ok(response);
134    }
135    let body = response.bytes().await.map_err(Error::RequestError)?;
136    Err(parse_delta_error(status.as_u16(), Some(&body)))
137}
138
139/// wasm/native twin of [`check_delta_response`] parsing the UC API error body.
140#[cfg(not(target_arch = "wasm32"))]
141pub(crate) async fn check_api_response(response: reqwest::Response) -> Result<reqwest::Response> {
142    Ok(response)
143}
144
145#[cfg(target_arch = "wasm32")]
146pub(crate) async fn check_api_response(response: reqwest::Response) -> Result<reqwest::Response> {
147    let status = response.status();
148    if status.is_success() {
149        return Ok(response);
150    }
151    let body = response.bytes().await.map_err(Error::RequestError)?;
152    Err(parse_error(status.as_u16(), Some(&body)))
153}
154
155impl Error {
156    pub fn is_not_found(&self) -> bool {
157        match self {
158            Error::Api(UcApiError::NotFound { .. }) => true,
159            Error::Delta(model) => model.error_type.is_not_found(),
160            _ => false,
161        }
162    }
163
164    pub fn is_already_exists(&self) -> bool {
165        match self {
166            Error::Api(UcApiError::AlreadyExists { .. }) => true,
167            Error::Delta(model) => model.error_type.is_already_exists(),
168            _ => false,
169        }
170    }
171
172    pub fn is_permission_denied(&self) -> bool {
173        match self {
174            Error::Api(UcApiError::PermissionDenied { .. }) => true,
175            Error::Delta(model) => {
176                matches!(
177                    model.error_type,
178                    unitycatalog_delta_api::models::DeltaErrorType::PermissionDeniedException
179                )
180            }
181            _ => false,
182        }
183    }
184
185    pub fn is_unauthenticated(&self) -> bool {
186        match self {
187            Error::Api(UcApiError::Unauthenticated { .. }) => true,
188            Error::Delta(model) => {
189                matches!(
190                    model.error_type,
191                    unitycatalog_delta_api::models::DeltaErrorType::NotAuthorizedException
192                )
193            }
194            _ => false,
195        }
196    }
197
198    /// Whether this is a Delta `CommitVersionConflictException` (409): a
199    /// concurrent commit ratified the proposed version first. The caller should
200    /// rebuild its snapshot and retry the commit.
201    pub fn is_commit_conflict(&self) -> bool {
202        matches!(self, Error::Delta(model) if model.error_type.is_commit_conflict())
203    }
204
205    /// Whether this is a Delta `UpdateRequirementConflictException` (409): an
206    /// `assert-etag`/`assert-table-uuid` requirement was not met. The caller
207    /// should reload the table and retry.
208    pub fn is_update_requirement_conflict(&self) -> bool {
209        matches!(self, Error::Delta(model) if model.error_type.is_update_requirement_conflict())
210    }
211
212    /// Whether this is a Delta `ResourceExhaustedException` / `TooManyRequestsException`
213    /// (429): the request was throttled or hit the unbackfilled-commit limit. The
214    /// caller should back off (and backfill pending commits) before retrying.
215    pub fn is_resource_exhausted(&self) -> bool {
216        matches!(self, Error::Delta(model) if model.error_type.is_resource_exhausted())
217    }
218
219    /// Whether this is a Delta `CommitStateUnknownException` (500): the commit
220    /// outcome is unknown. The caller must check table state before retrying to
221    /// avoid duplicate commits.
222    pub fn is_commit_state_unknown(&self) -> bool {
223        matches!(self, Error::Delta(model) if model.error_type.is_commit_state_unknown())
224    }
225
226    /// Whether this is a Delta `UnsupportedTableFormatException` (400): the table
227    /// is not Delta, or is a Delta table this `/delta/v1` endpoint does not
228    /// support. The caller should fall back to the legacy UC table API.
229    pub fn is_unsupported_table_format(&self) -> bool {
230        matches!(self, Error::Delta(model) if model.error_type.is_unsupported_table_format())
231    }
232
233    /// Whether this is a Delta `NotImplementedException` (501): the server does
234    /// not implement this `/delta/v1` functionality. The caller should fall back
235    /// to the legacy UC table API.
236    pub fn is_not_implemented(&self) -> bool {
237        matches!(self, Error::Delta(model) if model.error_type.is_not_implemented())
238    }
239
240    /// Whether this is a `404` that is **not** a recognizable Delta error envelope
241    /// — i.e. the `/delta/v1` route itself is absent (no `UnsupportedTableFormat`
242    /// / `NoSuchTable` envelope was returned), as on a UC deployment that does not
243    /// serve `/delta/v1` at all.
244    ///
245    /// This is deliberately distinct from [`Error::is_not_found`]: an *enveloped*
246    /// `NoSuchTableException` (a genuinely missing table) is an [`Error::Delta`]
247    /// and returns `false` here, so callers can fall back on a missing route
248    /// without masking a missing table.
249    pub fn is_route_missing(&self) -> bool {
250        matches!(self, Error::Api(UcApiError::Other { status: 404, .. }))
251    }
252
253    /// Whether the caller should react to this `/delta/v1` loadTable error by
254    /// falling back to the legacy UC table API (filesystem snapshot): an
255    /// unsupported table format, an unimplemented endpoint, or an entirely missing
256    /// route. A genuine `NoSuchTable`/auth/other error returns `false` and must be
257    /// propagated.
258    pub fn should_fall_back_to_legacy(&self) -> bool {
259        self.is_unsupported_table_format() || self.is_not_implemented() || self.is_route_missing()
260    }
261}
262
263/// Typed error variants mapped to the Databricks Unity Catalog API error code spec.
264#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
265#[non_exhaustive]
266pub enum UcApiError {
267    #[error("Invalid parameter: {message}")]
268    InvalidParameter { message: String },
269
270    #[error("Unauthenticated: {message}")]
271    Unauthenticated { message: String },
272
273    #[error("Permission denied: {message}")]
274    PermissionDenied { message: String },
275
276    #[error("Resource not found: {message}")]
277    NotFound { message: String },
278
279    #[error("Resource already exists: {message}")]
280    AlreadyExists { message: String },
281
282    #[error("Request limit exceeded: {message}")]
283    RequestLimitExceeded { message: String },
284
285    #[error("Internal server error: {message}")]
286    InternalError { message: String },
287
288    #[error("Temporarily unavailable: {message}")]
289    TemporarilyUnavailable { message: String },
290
291    #[error("API error {status}: [{error_code}] {message}")]
292    Other {
293        status: u16,
294        error_code: String,
295        message: String,
296    },
297}
298
299impl UcApiError {
300    /// Returns the UC API error code string.
301    pub fn error_code(&self) -> &str {
302        match self {
303            UcApiError::InvalidParameter { .. } => "INVALID_PARAMETER_VALUE",
304            UcApiError::Unauthenticated { .. } => "UNAUTHENTICATED",
305            UcApiError::PermissionDenied { .. } => "PERMISSION_DENIED",
306            UcApiError::NotFound { .. } => "RESOURCE_NOT_FOUND",
307            UcApiError::AlreadyExists { .. } => "RESOURCE_ALREADY_EXISTS",
308            UcApiError::RequestLimitExceeded { .. } => "REQUEST_LIMIT_EXCEEDED",
309            UcApiError::InternalError { .. } => "INTERNAL_ERROR",
310            UcApiError::TemporarilyUnavailable { .. } => "TEMPORARILY_UNAVAILABLE",
311            UcApiError::Other { error_code, .. } => error_code,
312        }
313    }
314
315    /// Returns the HTTP status code associated with this error.
316    pub fn http_status(&self) -> u16 {
317        match self {
318            UcApiError::InvalidParameter { .. } => 400,
319            UcApiError::Unauthenticated { .. } => 401,
320            UcApiError::PermissionDenied { .. } => 403,
321            UcApiError::NotFound { .. } => 404,
322            UcApiError::AlreadyExists { .. } => 409,
323            UcApiError::RequestLimitExceeded { .. } => 429,
324            UcApiError::InternalError { .. } => 500,
325            UcApiError::TemporarilyUnavailable { .. } => 503,
326            UcApiError::Other { status, .. } => *status,
327        }
328    }
329
330    /// Construct from an API response with status code, error code string, and message.
331    pub fn from_api_response(status: u16, error_code: &str, message: String) -> Self {
332        match error_code {
333            "INVALID_PARAMETER_VALUE" => UcApiError::InvalidParameter { message },
334            "UNAUTHENTICATED" => UcApiError::Unauthenticated { message },
335            "PERMISSION_DENIED" => UcApiError::PermissionDenied { message },
336            "RESOURCE_NOT_FOUND" => UcApiError::NotFound { message },
337            "RESOURCE_ALREADY_EXISTS" => UcApiError::AlreadyExists { message },
338            "REQUEST_LIMIT_EXCEEDED" => UcApiError::RequestLimitExceeded { message },
339            "INTERNAL_ERROR" => UcApiError::InternalError { message },
340            "TEMPORARILY_UNAVAILABLE" => UcApiError::TemporarilyUnavailable { message },
341            other => UcApiError::Other {
342                status,
343                error_code: other.to_string(),
344                message,
345            },
346        }
347    }
348}
349
350/// Serde helper for parsing the UC API error body.
351#[derive(serde::Deserialize)]
352struct ApiErrorBody {
353    #[serde(alias = "errorCode")]
354    error_code: String,
355    message: String,
356}
357
358/// Parse a UC API error from its HTTP `status` and response `body`, returning a
359/// typed [`Error`]. Falls back to [`UcApiError::Other`] with the raw bytes when
360/// the body is not the expected `{ error_code, message }` JSON.
361pub(crate) fn parse_error(status: u16, body: Option<&[u8]>) -> Error {
362    let body = body.unwrap_or_default();
363    match serde_json::from_slice::<ApiErrorBody>(body) {
364        Ok(api_err) => {
365            UcApiError::from_api_response(status, &api_err.error_code, api_err.message).into()
366        }
367        Err(_) => UcApiError::Other {
368            status,
369            error_code: String::new(),
370            message: String::from_utf8_lossy(body).into_owned(),
371        }
372        .into(),
373    }
374}
375
376/// Read an error HTTP response, parse the UC API JSON body, and return a typed
377/// [`Error`]. Thin wrapper over [`parse_error`] retained for the generated
378/// resource clients (`codegen/**`), which hold a [`reqwest::Response`] because
379/// they still call `.send()` (a 2xx/non-2xx split, not `send_raw`).
380pub(crate) async fn parse_error_response(response: reqwest::Response) -> Error {
381    let status = response.status().as_u16();
382    match response.bytes().await {
383        Ok(body) => parse_error(status, Some(&body)),
384        Err(e) => Error::RequestError(e),
385    }
386}
387
388/// Parse a `/delta/v1` error from its HTTP `status` and response `body`, matching
389/// the Delta API error envelope (`{ "error": { message, type, code, stack? } }`)
390/// and returning a typed [`Error::Delta`].
391///
392/// Falls back to [`UcApiError::Other`] when the body is not a recognizable Delta
393/// error envelope (e.g. a bare proxy 502 or a truncated body), preserving the raw
394/// bytes for diagnostics.
395///
396/// The caller passes `status` and `body` directly (rather than a
397/// [`reqwest::Response`]) because a failed `/delta/v1` request surfaces through
398/// [`olai_http::CloudRequestBuilder::send_raw`] as an
399/// [`olai_http::RetryError`], which has already consumed the response body and
400/// exposes it via [`olai_http::RetryError::status`]/[`body`](olai_http::RetryError::body).
401pub(crate) fn parse_delta_error(status: u16, body: Option<&[u8]>) -> Error {
402    use unitycatalog_delta_api::models::DeltaErrorResponse;
403
404    let body = body.unwrap_or_default();
405    match serde_json::from_slice::<DeltaErrorResponse>(body) {
406        Ok(envelope) => Error::Delta(envelope.error),
407        Err(_) => UcApiError::Other {
408            status,
409            error_code: String::new(),
410            message: String::from_utf8_lossy(body).into_owned(),
411        }
412        .into(),
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn make_response(status: u16, body: &'static str) -> reqwest::Response {
421        http::Response::builder()
422            .status(status)
423            .header("content-type", "application/json")
424            .body(bytes::Bytes::from_static(body.as_bytes()))
425            .map(reqwest::Response::from)
426            .unwrap()
427    }
428
429    #[tokio::test]
430    async fn test_parse_error_resource_not_found() {
431        let resp = make_response(
432            404,
433            r#"{"error_code":"RESOURCE_NOT_FOUND","message":"catalog 'foo' not found"}"#,
434        );
435        let err = parse_error_response(resp).await;
436        assert!(err.is_not_found());
437        assert!(matches!(
438            err,
439            Error::Api(UcApiError::NotFound { ref message }) if message == "catalog 'foo' not found"
440        ));
441    }
442
443    #[tokio::test]
444    async fn test_parse_error_camel_case_alias() {
445        let resp = make_response(
446            404,
447            r#"{"errorCode":"RESOURCE_NOT_FOUND","message":"not found"}"#,
448        );
449        let err = parse_error_response(resp).await;
450        assert!(err.is_not_found());
451    }
452
453    #[tokio::test]
454    async fn test_parse_error_non_json_body() {
455        let resp = make_response(500, "Internal Server Error");
456        let err = parse_error_response(resp).await;
457        assert!(matches!(
458            err,
459            Error::Api(UcApiError::Other {
460                status: 500,
461                ref message,
462                ..
463            }) if message == "Internal Server Error"
464        ));
465    }
466
467    #[tokio::test]
468    async fn test_parse_error_already_exists() {
469        let resp = make_response(
470            409,
471            r#"{"error_code":"RESOURCE_ALREADY_EXISTS","message":"already exists"}"#,
472        );
473        let err = parse_error_response(resp).await;
474        assert!(err.is_already_exists());
475    }
476
477    /// Parse a Delta error envelope directly from `(status, body)`, matching the
478    /// shape [`Error::from_delta_send`] feeds in after `send_raw` classifies a
479    /// non-2xx response.
480    fn delta_err(status: u16, body: &str) -> Error {
481        parse_delta_error(status, Some(body.as_bytes()))
482    }
483
484    #[test]
485    fn test_parse_delta_error_not_found() {
486        let err = delta_err(
487            404,
488            r#"{"error":{"message":"table 'x' not found","type":"NoSuchTableException","code":404}}"#,
489        );
490        assert!(err.is_not_found());
491        assert!(!err.is_already_exists());
492        assert!(matches!(
493            err,
494            Error::Delta(ref m) if m.message == "table 'x' not found" && m.code == 404
495        ));
496    }
497
498    #[test]
499    fn test_parse_delta_error_already_exists() {
500        let err = delta_err(
501            409,
502            r#"{"error":{"message":"exists","type":"AlreadyExistsException","code":409}}"#,
503        );
504        assert!(err.is_already_exists());
505        assert!(!err.is_not_found());
506    }
507
508    #[test]
509    fn test_parse_delta_error_commit_conflict() {
510        let err = delta_err(
511            409,
512            r#"{"error":{"message":"conflict","type":"CommitVersionConflictException","code":409}}"#,
513        );
514        assert!(err.is_commit_conflict());
515        assert!(!err.is_update_requirement_conflict());
516        assert!(!err.is_already_exists());
517    }
518
519    #[test]
520    fn test_parse_delta_error_update_requirement_conflict() {
521        let err = delta_err(
522            409,
523            r#"{"error":{"message":"etag mismatch","type":"UpdateRequirementConflictException","code":409}}"#,
524        );
525        assert!(err.is_update_requirement_conflict());
526        assert!(!err.is_commit_conflict());
527    }
528
529    #[test]
530    fn test_parse_delta_error_resource_exhausted() {
531        for ty in ["ResourceExhaustedException", "TooManyRequestsException"] {
532            let body = format!(r#"{{"error":{{"message":"slow down","type":"{ty}","code":429}}}}"#);
533            let err = delta_err(429, &body);
534            assert!(err.is_resource_exhausted(), "type {ty} should be exhausted");
535        }
536    }
537
538    #[test]
539    fn test_parse_delta_error_commit_state_unknown() {
540        let err = delta_err(
541            500,
542            r#"{"error":{"message":"unknown","type":"CommitStateUnknownException","code":500}}"#,
543        );
544        assert!(err.is_commit_state_unknown());
545    }
546
547    #[test]
548    fn test_parse_delta_error_with_stack() {
549        let err = delta_err(
550            500,
551            r#"{"error":{"message":"boom","type":"InternalServerErrorException","code":500,"stack":["a","b"]}}"#,
552        );
553        assert!(matches!(
554            err,
555            Error::Delta(ref m) if m.stack.as_deref() == Some(&["a".to_string(), "b".to_string()][..])
556        ));
557    }
558
559    #[test]
560    fn test_parse_delta_error_non_envelope_body() {
561        let err = delta_err(502, "Bad Gateway");
562        assert!(matches!(
563            err,
564            Error::Api(UcApiError::Other { status: 502, ref message, .. }) if message == "Bad Gateway"
565        ));
566    }
567
568    #[test]
569    fn test_parse_delta_error_unsupported_table_format() {
570        // A 400 with the typed envelope: the table is not Delta / not supported by
571        // /delta/v1. Should trigger the legacy-API fallback but is not a not-found.
572        let err = delta_err(
573            400,
574            r#"{"error":{"message":"not a delta table","type":"UnsupportedTableFormatException","code":400}}"#,
575        );
576        assert!(err.is_unsupported_table_format());
577        assert!(err.should_fall_back_to_legacy());
578        assert!(!err.is_not_found());
579        assert!(!err.is_route_missing());
580    }
581
582    #[test]
583    fn test_parse_delta_error_not_implemented() {
584        let err = delta_err(
585            501,
586            r#"{"error":{"message":"nope","type":"NotImplementedException","code":501}}"#,
587        );
588        assert!(err.is_not_implemented());
589        assert!(err.should_fall_back_to_legacy());
590    }
591
592    #[test]
593    fn test_route_missing_is_non_envelope_404() {
594        // A 404 with no Delta error envelope = the /delta/v1 route is absent. This
595        // must fall back to the legacy API, distinct from an enveloped
596        // NoSuchTableException (a genuinely missing table), which must propagate.
597        let err = delta_err(404, "Not Found");
598        assert!(err.is_route_missing());
599        assert!(err.should_fall_back_to_legacy());
600        // is_not_found also reports true for the missing route via the Api arm, but
601        // the enveloped not-found below must NOT be treated as a missing route.
602        let enveloped = delta_err(
603            404,
604            r#"{"error":{"message":"no table","type":"NoSuchTableException","code":404}}"#,
605        );
606        assert!(enveloped.is_not_found());
607        assert!(!enveloped.is_route_missing());
608        assert!(!enveloped.should_fall_back_to_legacy());
609    }
610}