// SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The LanceDB Authors use pyo3::{ Bound, PyAny, PyErr, PyResult, Python, exceptions::{PyIOError, PyNotImplementedError, PyOSError, PyRuntimeError, PyValueError}, intern, types::{PyAnyMethods, PyNone}, }; use lancedb::error::{Error as LanceError, HttpErrorDetails, RemoteErrorMetadata}; pub trait PythonErrorExt { /// Convert to a python error based on the Lance error type fn infer_error(self) -> PyResult; /// Convert to OSError fn os_error(self) -> PyResult; /// Convert to RuntimeError fn runtime_error(self) -> PyResult; /// Convert to ValueError fn value_error(self) -> PyResult; } impl PythonErrorExt for std::result::Result { fn infer_error(self) -> PyResult { match &self { Ok(_) => Ok(self.unwrap()), Err(err) => match err { LanceError::InvalidInput { .. } | LanceError::InvalidTableName { .. } | LanceError::NotAMaterializedView { .. } | LanceError::Schema { .. } | LanceError::DatabaseNotFound { .. } | LanceError::DatabaseAlreadyExists { .. } | LanceError::TableAlreadyExists { .. } => self.value_error(), LanceError::TableNotFound { source, .. } => { let Some( request_error @ LanceError::Http { request_id, status_code, source: http_source, }, ) = source.downcast_ref::() else { return self.value_error(); }; Python::attach(|py| { let status_code = status_code.map(|s| s.as_u16()); let metadata = request_error.remote_error_metadata(); let details = request_error.http_error_details(); let py_err = if details.is_some() || metadata.is_some_and(|metadata| metadata.error_code.is_some()) { new_py_http_error( py, http_source.to_string(), request_id, status_code, details, metadata, false, )? } else { let cls = py .import(intern!(py, "lancedb.remote.errors"))? .getattr(intern!(py, "TableNotFoundError"))?; let error = cls.call1((err.to_string(), request_id, status_code))?; error.setattr( "job_id", metadata.and_then(|value| value.job_id.as_deref()), )?; error.setattr( "job_request_id", metadata.and_then(|value| value.job_request_id.as_deref()), )?; error }; let cause = http_from_rust_error(py, source.as_ref(), request_id, status_code)?; py_err.setattr(intern!(py, "__cause__"), cause)?; Err(PyErr::from_value(py_err)) }) } LanceError::ColumnNotFound { .. } | LanceError::FieldNotFound { .. } => { Python::attach(|py| { let cls = py .import(intern!(py, "lancedb.exceptions"))? .getattr(intern!(py, "ColumnNotFoundError"))?; Err(PyErr::from_value(cls.call1((err.to_string(),))?)) }) } LanceError::CreateDir { .. } => self.os_error(), LanceError::ObjectStore { .. } => Err(PyIOError::new_err(err.to_string())), LanceError::NotSupported { .. } => { Err(PyNotImplementedError::new_err(err.to_string())) } LanceError::Http { request_id, source, status_code, } => Python::attach(|py| { let message = if err .remote_error_metadata() .is_some_and(|metadata| metadata.error_code.is_some()) { source.to_string() } else { err.to_string() }; let err = new_py_http_error( py, message, request_id, status_code.map(|s| s.as_u16()), err.http_error_details(), err.remote_error_metadata(), is_transport_error(source.as_ref()), )?; if let Some(cause) = source.source() { // The HTTP error already includes the first cause. But // we can add the rest of the chain if there is any more. let cause_err = http_from_rust_error( py, cause, request_id, status_code.map(|s| s.as_u16()), )?; err.setattr(intern!(py, "__cause__"), cause_err)?; } Err(PyErr::from_value(err)) }), LanceError::Retry { request_id, request_failures, max_request_failures, connect_failures, max_connect_failures, read_failures, max_read_failures, source, status_code, } => Python::attach(|py| { let cause_err = http_from_rust_error( py, source.as_ref(), request_id, status_code.map(|s| s.as_u16()), )?; let message = err.to_string(); let retry_error_cls = py .import(intern!(py, "lancedb.remote.errors"))? .getattr("RetryError")?; let err = retry_error_cls.call1(( message, request_id, *request_failures, *connect_failures, *read_failures, *max_request_failures, *max_connect_failures, *max_read_failures, status_code.map(|s| s.as_u16()), ))?; err.setattr(intern!(py, "__cause__"), cause_err)?; Err(PyErr::from_value(err)) }), LanceError::Sql { message, request_id, grpc_code, metadata, } => Python::attach(|py| { Err(PyErr::from_value(new_py_sql_error( py, message, request_id, *grpc_code, metadata, )?)) }), LanceError::JobFailed { job_id, failure } => Python::attach(|py| { let cls = py .import(intern!(py, "lancedb.exceptions"))? .getattr(intern!(py, "JobFailedError"))?; let sql_source = match failure.source.as_deref() { Some(LanceError::Sql { message, request_id, grpc_code, metadata, }) => Some(( new_py_sql_error(py, message, request_id, *grpc_code, metadata)?, request_id.as_str(), metadata.job_request_id.as_deref(), )), _ => None, }; let error = cls.call1(( err.to_string(), job_id.clone(), failure.sql_job_id.clone(), sql_source.as_ref().map(|(_, request_id, _)| *request_id), sql_source .as_ref() .and_then(|(_, _, request_id)| *request_id), ))?; if let Some((cause, _, _)) = sql_source { error.setattr(intern!(py, "__cause__"), cause)?; } Err(PyErr::from_value(error)) }), LanceError::JobCancelled { .. } => Python::attach(|py| { let cls = py .import(intern!(py, "lancedb.exceptions"))? .getattr(intern!(py, "JobCancelledError"))?; Err(PyErr::from_value(cls.call1((err.to_string(),))?)) }), LanceError::JobNotFound { .. } => Python::attach(|py| { let cls = py .import(intern!(py, "lancedb.exceptions"))? .getattr(intern!(py, "JobNotFoundError"))?; Err(PyErr::from_value(cls.call1((err.to_string(),))?)) }), _ => self.runtime_error(), }, } } fn os_error(self) -> PyResult { self.map_err(|err| PyOSError::new_err(err.to_string())) } fn runtime_error(self) -> PyResult { self.map_err(|err| PyRuntimeError::new_err(err.to_string())) } fn value_error(self) -> PyResult { self.map_err(|err| PyValueError::new_err(err.to_string())) } } fn http_from_rust_error( py: Python<'_>, err: &(dyn std::error::Error + 'static), request_id: &str, status_code: Option, ) -> PyResult { let message = err.to_string(); let details = err .downcast_ref::() .and_then(LanceError::http_error_details); let py_err = new_py_http_error( py, message, request_id, status_code, details, err.downcast_ref::() .and_then(LanceError::remote_error_metadata), is_transport_error(err), )?; // Reset the traceback since it doesn't provide additional information. let py_err = py_err.call_method1(intern!(py, "with_traceback"), (PyNone::get(py),))?; if let Some(cause) = err.source() { let cause_err = http_from_rust_error(py, cause, request_id, status_code)?; py_err.setattr(intern!(py, "__cause__"), cause_err)?; } Ok(PyErr::from_value(py_err)) } fn new_py_http_error<'py>( py: Python<'py>, message: String, request_id: &str, status_code: Option, details: Option<&HttpErrorDetails>, metadata: Option<&RemoteErrorMetadata>, is_transport_error: bool, ) -> PyResult> { let error_code = metadata .and_then(|metadata| metadata.error_code.as_deref()) .or_else(|| details.map(|details| details.error_code.as_str())); let resource_kind = details.map(|details| details.resource_kind.as_str()); let resource_name = details.map(|details| details.resource_name.as_str()); let namespace_path = details.map(|details| details.namespace_path.clone()); py.import("lancedb.remote.errors")? .getattr("HttpError")? .call1(( message, request_id, status_code, error_code, resource_kind, resource_name, namespace_path, is_transport_error, metadata.and_then(|metadata| metadata.job_id.as_deref()), metadata.and_then(|metadata| metadata.job_request_id.as_deref()), )) } fn new_py_sql_error<'py>( py: Python<'py>, message: &str, request_id: &str, grpc_code: i32, metadata: &RemoteErrorMetadata, ) -> PyResult> { if sql_has_resource_error(metadata, grpc_code) { return new_py_sql_resource_error(py, message, request_id, grpc_code, metadata); } py.import("lancedb.remote.errors")? .getattr("SqlError")? .call1(( message, request_id, grpc_code, metadata.error_code.as_deref(), metadata.job_id.as_deref(), metadata.job_request_id.as_deref(), )) } fn new_py_sql_resource_error<'py>( py: Python<'py>, message: &str, request_id: &str, grpc_code: i32, metadata: &RemoteErrorMetadata, ) -> PyResult> { let resource = metadata.resource.as_ref().unwrap(); py.import("lancedb.remote.errors")? .getattr("HttpError")? .call1(( message, request_id, grpc_code_to_http_status(grpc_code), metadata.error_code.as_deref(), resource.kind.as_str(), resource.name.as_str(), resource.namespace_path.clone(), false, metadata.job_id.as_deref(), metadata.job_request_id.as_deref(), )) } fn sql_has_resource_error(metadata: &RemoteErrorMetadata, grpc_code: i32) -> bool { metadata.resource.is_some() && matches!( (metadata.error_code.as_deref(), grpc_code), // These are gRPC status codes, not HTTP status codes. (Some("not_found"), 5) | (Some("already_exists"), 6) ) } fn grpc_code_to_http_status(grpc_code: i32) -> Option { match grpc_code { 3 => Some(400), 5 => Some(404), 6 => Some(409), 7 => Some(403), 9 => Some(400), 12 => Some(501), _ => None, } } // Preserve the typed transport classification across the Python boundary. // A missing status code alone also covers malformed job responses, which // waiting longer cannot repair. fn is_transport_error(err: &(dyn std::error::Error + 'static)) -> bool { if let Some(error) = err.downcast_ref::() { return error.is_connect() || error.is_timeout() || error.is_request() || error.is_body() || error.is_decode(); } err.source().is_some_and(is_transport_error) } #[cfg(test)] mod tests { use std::ffi::CString; use std::sync::{Arc, Once}; use lancedb::error::{JobFailure, RemoteResourceIdentity}; use pyo3::types::{PyAnyMethods, PyModule}; use super::*; static INSTALL_PYTHON_ERROR_MODULES: Once = Once::new(); fn install_python_error_modules(py: Python<'_>) -> PyResult<()> { // Load the checked-in error classes without requiring an installed wheel. // The namespace exception is only a base for an unrelated remote class. PyModule::from_code( py, c"import sys\n\nimport types\n\n\ lancedb = types.ModuleType('lancedb')\n\ lancedb.__path__ = []\n\ lancedb_remote = types.ModuleType('lancedb.remote')\n\ lancedb_remote.__path__ = []\n\ lancedb.remote = lancedb_remote\n\ namespace = types.ModuleType('lance_namespace')\n\ namespace_errors = types.ModuleType('lance_namespace.errors')\n\ namespace_errors.TableNotFoundError = RuntimeError\n\ namespace.errors = namespace_errors\n\ sys.modules['lancedb'] = lancedb\n\ sys.modules['lancedb.remote'] = lancedb_remote\n\ sys.modules['lance_namespace'] = namespace\n\ sys.modules['lance_namespace.errors'] = namespace_errors", c"error_test_bootstrap.py", c"lancedb_error_test_bootstrap", )?; let exceptions_code = CString::new(include_str!("../../python/python/lancedb/exceptions.py")).unwrap(); let exceptions = PyModule::from_code( py, &exceptions_code, c"exceptions.py", c"lancedb.exceptions", )?; let lancedb = py.import("lancedb")?; lancedb.setattr("exceptions", &exceptions)?; py.import("sys")? .getattr("modules")? .set_item("lancedb.exceptions", &exceptions)?; let remote_errors_code = CString::new(include_str!("../../python/python/lancedb/remote/errors.py")).unwrap(); let remote_errors = PyModule::from_code( py, &remote_errors_code, c"remote/errors.py", c"lancedb.remote.errors", )?; lancedb .getattr("remote")? .setattr("errors", &remote_errors)?; py.import("sys")? .getattr("modules")? .set_item("lancedb.remote.errors", &remote_errors)?; Ok(()) } fn ensure_python_error_modules() { Python::initialize(); // Once waits without holding the GIL; only its one-time closure attaches. INSTALL_PYTHON_ERROR_MODULES.call_once(|| { Python::attach(install_python_error_modules).unwrap(); }); } #[test] fn generic_job_failure_retains_the_named_resource_as_its_cause() { ensure_python_error_modules(); let source = LanceError::Sql { message: "Function 'missing' was not found".into(), request_id: "current-request".into(), grpc_code: 5, metadata: Box::new({ let mut metadata = RemoteErrorMetadata::default(); metadata.error_code = Some("not_found".into()); metadata.job_id = Some("outer-job".into()); metadata.job_request_id = Some("worker-request".into()); metadata.resource = Some(RemoteResourceIdentity::new( "function", "missing", vec!["ns".into()], )); metadata }), }; let error = LanceError::JobFailed { job_id: Some("outer-job".into()), failure: Box::new(JobFailure { message: Some("Function 'missing' was not found".into()), traceback: None, sql_job_id: Some("outer-job".into()), source: Some(Arc::new(source)), ..Default::default() }), }; let py_err = Python::attach(|_| { let result: std::result::Result<(), LanceError> = Err(error); result.infer_error().unwrap_err() }); Python::attach(|py| { let value = py_err.value(py); let job_failed = py .import("lancedb.exceptions") .unwrap() .getattr("JobFailedError") .unwrap(); assert!(value.is_instance(&job_failed).unwrap(), "{py_err:?}"); let cause = value.getattr("__cause__").unwrap(); let function_not_found = py .import("lancedb.remote.errors") .unwrap() .getattr("FunctionNotFoundError") .unwrap(); assert!( cause.is_instance(&function_not_found).unwrap(), "{py_err:?}" ); assert_eq!( cause .getattr("error_code") .unwrap() .extract::() .unwrap(), "not_found" ); assert_eq!( cause .getattr("resource_kind") .unwrap() .extract::() .unwrap(), "function" ); assert_eq!( cause .getattr("resource_name") .unwrap() .extract::() .unwrap(), "missing" ); assert_eq!( cause .getattr("namespace_path") .unwrap() .extract::>() .unwrap(), ["ns"] ); assert_eq!( cause .getattr("status_code") .unwrap() .extract::() .unwrap(), 404 ); assert_eq!( cause .getattr("request_id") .unwrap() .extract::() .unwrap(), "current-request" ); assert_eq!( cause .getattr("job_id") .unwrap() .extract::() .unwrap(), "outer-job" ); assert_eq!( cause .getattr("job_request_id") .unwrap() .extract::() .unwrap(), "worker-request" ); }); } #[test] fn direct_catalog_lookup_is_raised_as_the_named_resource_error() { ensure_python_error_modules(); for (kind, name, namespace_path, exception) in [ ( "function", "missing", vec!["ns".into()], "FunctionNotFoundError", ), ("namespace", "ns", Vec::new(), "NamespaceNotFoundError"), ] { let mut metadata = RemoteErrorMetadata::default(); metadata.error_code = Some("not_found".into()); metadata.resource = Some(RemoteResourceIdentity::new(kind, name, namespace_path)); let error = LanceError::Sql { message: format!("{kind} '{name}' was not found"), request_id: "current-request".into(), grpc_code: 5, metadata: Box::new(metadata), }; let py_err = Python::attach(|_| { let result: std::result::Result<(), LanceError> = Err(error); result.infer_error().unwrap_err() }); Python::attach(|py| { let value = py_err.value(py); let class = py .import("lancedb.remote.errors") .unwrap() .getattr(exception) .unwrap(); assert!(value.is_instance(&class).unwrap(), "{py_err:?}"); assert_eq!( value .getattr("resource_name") .unwrap() .extract::() .unwrap(), name ); }); } } }