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 #[error("Client Error: {source}")]
17 ClientError {
18 #[from]
19 source: olai_http::Error,
20 },
21
22 #[error("Malformed response: {source}")]
23 MalformedResponse {
24 #[from]
25 source: serde_json::Error,
26 },
27
28 #[error("Malformed url: {source}")]
29 MalformedUrl {
30 #[from]
31 source: url::ParseError,
32 },
33
34 #[error("Reqwuest error: {0}")]
35 RequestError(#[from] reqwest::Error),
36
37 #[error("API error: {0}")]
38 Api(#[from] UcApiError),
39
40 #[error("Generic error: {0}")]
41 Generic(String),
42}
43
44impl Error {
45 pub fn generic(message: impl ToString) -> Self {
46 Error::Generic(message.to_string())
47 }
48
49 pub(crate) fn from_delta_send(err: olai_http::SendRawError) -> Self {
58 match err {
59 olai_http::SendRawError::Sign(e) => Error::ClientError { source: e },
60 olai_http::SendRawError::Retry(e) => match e.status() {
61 Some(status) => parse_delta_error(status.as_u16(), e.body().map(str::as_bytes)),
62 None => Error::ClientError { source: e.error() },
64 },
65 }
66 }
67
68 pub(crate) fn from_api_send(err: olai_http::SendRawError) -> Self {
72 match err {
73 olai_http::SendRawError::Sign(e) => Error::ClientError { source: e },
74 olai_http::SendRawError::Retry(e) => match e.status() {
75 Some(status) => parse_error(status.as_u16(), e.body().map(str::as_bytes)),
76 None => Error::ClientError { source: e.error() },
77 },
78 }
79 }
80
81 pub fn is_not_found(&self) -> bool {
82 match self {
83 Error::Api(UcApiError::NotFound { .. }) => true,
84 Error::Delta(model) => model.error_type.is_not_found(),
85 _ => false,
86 }
87 }
88
89 pub fn is_already_exists(&self) -> bool {
90 match self {
91 Error::Api(UcApiError::AlreadyExists { .. }) => true,
92 Error::Delta(model) => model.error_type.is_already_exists(),
93 _ => false,
94 }
95 }
96
97 pub fn is_permission_denied(&self) -> bool {
98 match self {
99 Error::Api(UcApiError::PermissionDenied { .. }) => true,
100 Error::Delta(model) => {
101 matches!(
102 model.error_type,
103 unitycatalog_delta_api::models::DeltaErrorType::PermissionDeniedException
104 )
105 }
106 _ => false,
107 }
108 }
109
110 pub fn is_unauthenticated(&self) -> bool {
111 match self {
112 Error::Api(UcApiError::Unauthenticated { .. }) => true,
113 Error::Delta(model) => {
114 matches!(
115 model.error_type,
116 unitycatalog_delta_api::models::DeltaErrorType::NotAuthorizedException
117 )
118 }
119 _ => false,
120 }
121 }
122
123 pub fn is_commit_conflict(&self) -> bool {
127 matches!(self, Error::Delta(model) if model.error_type.is_commit_conflict())
128 }
129
130 pub fn is_update_requirement_conflict(&self) -> bool {
134 matches!(self, Error::Delta(model) if model.error_type.is_update_requirement_conflict())
135 }
136
137 pub fn is_resource_exhausted(&self) -> bool {
141 matches!(self, Error::Delta(model) if model.error_type.is_resource_exhausted())
142 }
143
144 pub fn is_commit_state_unknown(&self) -> bool {
148 matches!(self, Error::Delta(model) if model.error_type.is_commit_state_unknown())
149 }
150
151 pub fn is_unsupported_table_format(&self) -> bool {
155 matches!(self, Error::Delta(model) if model.error_type.is_unsupported_table_format())
156 }
157
158 pub fn is_not_implemented(&self) -> bool {
162 matches!(self, Error::Delta(model) if model.error_type.is_not_implemented())
163 }
164
165 pub fn is_route_missing(&self) -> bool {
175 matches!(self, Error::Api(UcApiError::Other { status: 404, .. }))
176 }
177
178 pub fn should_fall_back_to_legacy(&self) -> bool {
184 self.is_unsupported_table_format() || self.is_not_implemented() || self.is_route_missing()
185 }
186}
187
188#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
190#[non_exhaustive]
191pub enum UcApiError {
192 #[error("Invalid parameter: {message}")]
193 InvalidParameter { message: String },
194
195 #[error("Unauthenticated: {message}")]
196 Unauthenticated { message: String },
197
198 #[error("Permission denied: {message}")]
199 PermissionDenied { message: String },
200
201 #[error("Resource not found: {message}")]
202 NotFound { message: String },
203
204 #[error("Resource already exists: {message}")]
205 AlreadyExists { message: String },
206
207 #[error("Request limit exceeded: {message}")]
208 RequestLimitExceeded { message: String },
209
210 #[error("Internal server error: {message}")]
211 InternalError { message: String },
212
213 #[error("Temporarily unavailable: {message}")]
214 TemporarilyUnavailable { message: String },
215
216 #[error("API error {status}: [{error_code}] {message}")]
217 Other {
218 status: u16,
219 error_code: String,
220 message: String,
221 },
222}
223
224impl UcApiError {
225 pub fn error_code(&self) -> &str {
227 match self {
228 UcApiError::InvalidParameter { .. } => "INVALID_PARAMETER_VALUE",
229 UcApiError::Unauthenticated { .. } => "UNAUTHENTICATED",
230 UcApiError::PermissionDenied { .. } => "PERMISSION_DENIED",
231 UcApiError::NotFound { .. } => "RESOURCE_NOT_FOUND",
232 UcApiError::AlreadyExists { .. } => "RESOURCE_ALREADY_EXISTS",
233 UcApiError::RequestLimitExceeded { .. } => "REQUEST_LIMIT_EXCEEDED",
234 UcApiError::InternalError { .. } => "INTERNAL_ERROR",
235 UcApiError::TemporarilyUnavailable { .. } => "TEMPORARILY_UNAVAILABLE",
236 UcApiError::Other { error_code, .. } => error_code,
237 }
238 }
239
240 pub fn http_status(&self) -> u16 {
242 match self {
243 UcApiError::InvalidParameter { .. } => 400,
244 UcApiError::Unauthenticated { .. } => 401,
245 UcApiError::PermissionDenied { .. } => 403,
246 UcApiError::NotFound { .. } => 404,
247 UcApiError::AlreadyExists { .. } => 409,
248 UcApiError::RequestLimitExceeded { .. } => 429,
249 UcApiError::InternalError { .. } => 500,
250 UcApiError::TemporarilyUnavailable { .. } => 503,
251 UcApiError::Other { status, .. } => *status,
252 }
253 }
254
255 pub fn from_api_response(status: u16, error_code: &str, message: String) -> Self {
257 match error_code {
258 "INVALID_PARAMETER_VALUE" => UcApiError::InvalidParameter { message },
259 "UNAUTHENTICATED" => UcApiError::Unauthenticated { message },
260 "PERMISSION_DENIED" => UcApiError::PermissionDenied { message },
261 "RESOURCE_NOT_FOUND" => UcApiError::NotFound { message },
262 "RESOURCE_ALREADY_EXISTS" => UcApiError::AlreadyExists { message },
263 "REQUEST_LIMIT_EXCEEDED" => UcApiError::RequestLimitExceeded { message },
264 "INTERNAL_ERROR" => UcApiError::InternalError { message },
265 "TEMPORARILY_UNAVAILABLE" => UcApiError::TemporarilyUnavailable { message },
266 other => UcApiError::Other {
267 status,
268 error_code: other.to_string(),
269 message,
270 },
271 }
272 }
273}
274
275#[derive(serde::Deserialize)]
277struct ApiErrorBody {
278 #[serde(alias = "errorCode")]
279 error_code: String,
280 message: String,
281}
282
283pub(crate) fn parse_error(status: u16, body: Option<&[u8]>) -> Error {
287 let body = body.unwrap_or_default();
288 match serde_json::from_slice::<ApiErrorBody>(body) {
289 Ok(api_err) => {
290 UcApiError::from_api_response(status, &api_err.error_code, api_err.message).into()
291 }
292 Err(_) => UcApiError::Other {
293 status,
294 error_code: String::new(),
295 message: String::from_utf8_lossy(body).into_owned(),
296 }
297 .into(),
298 }
299}
300
301pub(crate) async fn parse_error_response(response: reqwest::Response) -> Error {
306 let status = response.status().as_u16();
307 match response.bytes().await {
308 Ok(body) => parse_error(status, Some(&body)),
309 Err(e) => Error::RequestError(e),
310 }
311}
312
313pub(crate) fn parse_delta_error(status: u16, body: Option<&[u8]>) -> Error {
327 use unitycatalog_delta_api::models::DeltaErrorResponse;
328
329 let body = body.unwrap_or_default();
330 match serde_json::from_slice::<DeltaErrorResponse>(body) {
331 Ok(envelope) => Error::Delta(envelope.error),
332 Err(_) => UcApiError::Other {
333 status,
334 error_code: String::new(),
335 message: String::from_utf8_lossy(body).into_owned(),
336 }
337 .into(),
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn make_response(status: u16, body: &'static str) -> reqwest::Response {
346 http::Response::builder()
347 .status(status)
348 .header("content-type", "application/json")
349 .body(bytes::Bytes::from_static(body.as_bytes()))
350 .map(reqwest::Response::from)
351 .unwrap()
352 }
353
354 #[tokio::test]
355 async fn test_parse_error_resource_not_found() {
356 let resp = make_response(
357 404,
358 r#"{"error_code":"RESOURCE_NOT_FOUND","message":"catalog 'foo' not found"}"#,
359 );
360 let err = parse_error_response(resp).await;
361 assert!(err.is_not_found());
362 assert!(matches!(
363 err,
364 Error::Api(UcApiError::NotFound { ref message }) if message == "catalog 'foo' not found"
365 ));
366 }
367
368 #[tokio::test]
369 async fn test_parse_error_camel_case_alias() {
370 let resp = make_response(
371 404,
372 r#"{"errorCode":"RESOURCE_NOT_FOUND","message":"not found"}"#,
373 );
374 let err = parse_error_response(resp).await;
375 assert!(err.is_not_found());
376 }
377
378 #[tokio::test]
379 async fn test_parse_error_non_json_body() {
380 let resp = make_response(500, "Internal Server Error");
381 let err = parse_error_response(resp).await;
382 assert!(matches!(
383 err,
384 Error::Api(UcApiError::Other {
385 status: 500,
386 ref message,
387 ..
388 }) if message == "Internal Server Error"
389 ));
390 }
391
392 #[tokio::test]
393 async fn test_parse_error_already_exists() {
394 let resp = make_response(
395 409,
396 r#"{"error_code":"RESOURCE_ALREADY_EXISTS","message":"already exists"}"#,
397 );
398 let err = parse_error_response(resp).await;
399 assert!(err.is_already_exists());
400 }
401
402 fn delta_err(status: u16, body: &str) -> Error {
406 parse_delta_error(status, Some(body.as_bytes()))
407 }
408
409 #[test]
410 fn test_parse_delta_error_not_found() {
411 let err = delta_err(
412 404,
413 r#"{"error":{"message":"table 'x' not found","type":"NoSuchTableException","code":404}}"#,
414 );
415 assert!(err.is_not_found());
416 assert!(!err.is_already_exists());
417 assert!(matches!(
418 err,
419 Error::Delta(ref m) if m.message == "table 'x' not found" && m.code == 404
420 ));
421 }
422
423 #[test]
424 fn test_parse_delta_error_already_exists() {
425 let err = delta_err(
426 409,
427 r#"{"error":{"message":"exists","type":"AlreadyExistsException","code":409}}"#,
428 );
429 assert!(err.is_already_exists());
430 assert!(!err.is_not_found());
431 }
432
433 #[test]
434 fn test_parse_delta_error_commit_conflict() {
435 let err = delta_err(
436 409,
437 r#"{"error":{"message":"conflict","type":"CommitVersionConflictException","code":409}}"#,
438 );
439 assert!(err.is_commit_conflict());
440 assert!(!err.is_update_requirement_conflict());
441 assert!(!err.is_already_exists());
442 }
443
444 #[test]
445 fn test_parse_delta_error_update_requirement_conflict() {
446 let err = delta_err(
447 409,
448 r#"{"error":{"message":"etag mismatch","type":"UpdateRequirementConflictException","code":409}}"#,
449 );
450 assert!(err.is_update_requirement_conflict());
451 assert!(!err.is_commit_conflict());
452 }
453
454 #[test]
455 fn test_parse_delta_error_resource_exhausted() {
456 for ty in ["ResourceExhaustedException", "TooManyRequestsException"] {
457 let body = format!(r#"{{"error":{{"message":"slow down","type":"{ty}","code":429}}}}"#);
458 let err = delta_err(429, &body);
459 assert!(err.is_resource_exhausted(), "type {ty} should be exhausted");
460 }
461 }
462
463 #[test]
464 fn test_parse_delta_error_commit_state_unknown() {
465 let err = delta_err(
466 500,
467 r#"{"error":{"message":"unknown","type":"CommitStateUnknownException","code":500}}"#,
468 );
469 assert!(err.is_commit_state_unknown());
470 }
471
472 #[test]
473 fn test_parse_delta_error_with_stack() {
474 let err = delta_err(
475 500,
476 r#"{"error":{"message":"boom","type":"InternalServerErrorException","code":500,"stack":["a","b"]}}"#,
477 );
478 assert!(matches!(
479 err,
480 Error::Delta(ref m) if m.stack.as_deref() == Some(&["a".to_string(), "b".to_string()][..])
481 ));
482 }
483
484 #[test]
485 fn test_parse_delta_error_non_envelope_body() {
486 let err = delta_err(502, "Bad Gateway");
487 assert!(matches!(
488 err,
489 Error::Api(UcApiError::Other { status: 502, ref message, .. }) if message == "Bad Gateway"
490 ));
491 }
492
493 #[test]
494 fn test_parse_delta_error_unsupported_table_format() {
495 let err = delta_err(
498 400,
499 r#"{"error":{"message":"not a delta table","type":"UnsupportedTableFormatException","code":400}}"#,
500 );
501 assert!(err.is_unsupported_table_format());
502 assert!(err.should_fall_back_to_legacy());
503 assert!(!err.is_not_found());
504 assert!(!err.is_route_missing());
505 }
506
507 #[test]
508 fn test_parse_delta_error_not_implemented() {
509 let err = delta_err(
510 501,
511 r#"{"error":{"message":"nope","type":"NotImplementedException","code":501}}"#,
512 );
513 assert!(err.is_not_implemented());
514 assert!(err.should_fall_back_to_legacy());
515 }
516
517 #[test]
518 fn test_route_missing_is_non_envelope_404() {
519 let err = delta_err(404, "Not Found");
523 assert!(err.is_route_missing());
524 assert!(err.should_fall_back_to_legacy());
525 let enveloped = delta_err(
528 404,
529 r#"{"error":{"message":"no table","type":"NoSuchTableException","code":404}}"#,
530 );
531 assert!(enveloped.is_not_found());
532 assert!(!enveloped.is_route_missing());
533 assert!(!enveloped.should_fall_back_to_legacy());
534 }
535}