diff --git a/crates/lance-context-core/src/lib.rs b/crates/lance-context-core/src/lib.rs index d0d12bc..2018ac1 100644 --- a/crates/lance-context-core/src/lib.rs +++ b/crates/lance-context-core/src/lib.rs @@ -57,3 +57,7 @@ pub use store::{ // Re-export CompactionMetrics from lance for Python bindings pub use lance::dataset::optimize::CompactionMetrics; + +// Re-export the Lance error type so downstream crates (e.g. the server) can +// match on its typed variants instead of string-matching `Display` output. +pub use lance::Error as LanceError; diff --git a/crates/lance-context-server/src/error.rs b/crates/lance-context-server/src/error.rs index eac91b0..a8d074a 100644 --- a/crates/lance-context-server/src/error.rs +++ b/crates/lance-context-server/src/error.rs @@ -2,6 +2,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; use lance_context_api::{ErrorBody, ErrorResponse}; +use lance_context_core::LanceError; #[derive(Debug)] pub enum AppError { @@ -13,16 +14,31 @@ pub enum AppError { } impl AppError { - pub fn from_lance(err: impl std::fmt::Display) -> Self { - let msg = err.to_string(); - if msg.contains("already in progress") { - AppError::CompactionInProgress - } else if msg.contains("not found") || msg.contains("DatasetNotFound") { - AppError::NotFound(msg) - } else if msg.contains("Invalid") { - AppError::InvalidRequest(msg) - } else { - AppError::Internal(msg) + /// Map a Lance error onto the API's error taxonomy. + /// + /// Prefers Lance's **typed** variants over string matching: `DatasetNotFound` + /// / `NotFound` → 404, `DatasetAlreadyExists` → 409, `InvalidInput` / + /// `SchemaMismatch` → 400. The one unavoidable string match is the + /// "compaction already in progress" signal, which the core raises as an + /// `ArrowError::InvalidArgumentError` that Lance folds into its generic + /// `Arrow` variant (no dedicated variant, see `store.rs`); it is checked + /// first so it still maps to 409 `COMPACTION_IN_PROGRESS`. + pub fn from_lance(err: LanceError) -> Self { + // Checked before the typed match: the compaction-in-progress signal is an + // Arrow-wrapped error with no dedicated variant, so only its text + // distinguishes it. + if err.to_string().contains("already in progress") { + return AppError::CompactionInProgress; + } + match err { + LanceError::DatasetNotFound { .. } | LanceError::NotFound { .. } => { + AppError::NotFound(err.to_string()) + } + LanceError::DatasetAlreadyExists { .. } => AppError::AlreadyExists(err.to_string()), + LanceError::InvalidInput { .. } | LanceError::SchemaMismatch { .. } => { + AppError::InvalidRequest(err.to_string()) + } + other => AppError::Internal(other.to_string()), } } } @@ -51,3 +67,41 @@ impl IntoResponse for AppError { (status, Json(body)).into_response() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_typed_variants_to_taxonomy() { + assert!(matches!( + AppError::from_lance(LanceError::dataset_not_found("db/x", "missing".into())), + AppError::NotFound(_) + )); + assert!(matches!( + AppError::from_lance(LanceError::dataset_already_exists("db/x")), + AppError::AlreadyExists(_) + )); + assert!(matches!( + AppError::from_lance(LanceError::invalid_input("bad field")), + AppError::InvalidRequest(_) + )); + assert!(matches!( + AppError::from_lance(LanceError::io("disk gone".to_string())), + AppError::Internal(_) + )); + } + + #[test] + fn compaction_in_progress_is_detected_from_arrow_variant() { + // Reproduce how the core raises it: an ArrowError folded into Lance's + // generic `Arrow` variant (NOT `InvalidInput`), so only the text + // identifies it. `LanceError::arrow` is exactly what `From` + // produces for a non-external ArrowError. + let err = LanceError::arrow("Compaction already in progress"); + assert!(matches!( + AppError::from_lance(err), + AppError::CompactionInProgress + )); + } +}