mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
648 lines
24 KiB
Rust
648 lines
24 KiB
Rust
// 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<T> {
|
|
/// Convert to a python error based on the Lance error type
|
|
fn infer_error(self) -> PyResult<T>;
|
|
/// Convert to OSError
|
|
fn os_error(self) -> PyResult<T>;
|
|
/// Convert to RuntimeError
|
|
fn runtime_error(self) -> PyResult<T>;
|
|
/// Convert to ValueError
|
|
fn value_error(self) -> PyResult<T>;
|
|
}
|
|
|
|
impl<T> PythonErrorExt<T> for std::result::Result<T, LanceError> {
|
|
fn infer_error(self) -> PyResult<T> {
|
|
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::<LanceError>()
|
|
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::ResponseInterrupted {
|
|
request_id,
|
|
batches,
|
|
rows,
|
|
table_version,
|
|
source,
|
|
} => Python::attach(|py| {
|
|
let error = py
|
|
.import(intern!(py, "lancedb.remote.errors"))?
|
|
.getattr(intern!(py, "ResponseInterruptedError"))?
|
|
.call1((
|
|
err.to_string(),
|
|
request_id,
|
|
*batches,
|
|
*rows,
|
|
*table_version,
|
|
is_transport_error(&**source),
|
|
))?;
|
|
let cause = http_from_rust_error(py, &**source, request_id, None)?;
|
|
error.setattr(intern!(py, "__cause__"), cause)?;
|
|
Err(PyErr::from_value(error))
|
|
}),
|
|
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<T> {
|
|
self.map_err(|err| PyOSError::new_err(err.to_string()))
|
|
}
|
|
|
|
fn runtime_error(self) -> PyResult<T> {
|
|
self.map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
|
}
|
|
|
|
fn value_error(self) -> PyResult<T> {
|
|
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<u16>,
|
|
) -> PyResult<PyErr> {
|
|
let message = err.to_string();
|
|
let details = err
|
|
.downcast_ref::<LanceError>()
|
|
.and_then(LanceError::http_error_details);
|
|
let py_err = new_py_http_error(
|
|
py,
|
|
message,
|
|
request_id,
|
|
status_code,
|
|
details,
|
|
err.downcast_ref::<LanceError>()
|
|
.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<u16>,
|
|
details: Option<&HttpErrorDetails>,
|
|
metadata: Option<&RemoteErrorMetadata>,
|
|
is_transport_error: bool,
|
|
) -> PyResult<Bound<'py, PyAny>> {
|
|
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<Bound<'py, PyAny>> {
|
|
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<Bound<'py, PyAny>> {
|
|
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<u16> {
|
|
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::<reqwest::Error>() {
|
|
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::<String>()
|
|
.unwrap(),
|
|
"not_found"
|
|
);
|
|
assert_eq!(
|
|
cause
|
|
.getattr("resource_kind")
|
|
.unwrap()
|
|
.extract::<String>()
|
|
.unwrap(),
|
|
"function"
|
|
);
|
|
assert_eq!(
|
|
cause
|
|
.getattr("resource_name")
|
|
.unwrap()
|
|
.extract::<String>()
|
|
.unwrap(),
|
|
"missing"
|
|
);
|
|
assert_eq!(
|
|
cause
|
|
.getattr("namespace_path")
|
|
.unwrap()
|
|
.extract::<Vec<String>>()
|
|
.unwrap(),
|
|
["ns"]
|
|
);
|
|
assert_eq!(
|
|
cause
|
|
.getattr("status_code")
|
|
.unwrap()
|
|
.extract::<u16>()
|
|
.unwrap(),
|
|
404
|
|
);
|
|
assert_eq!(
|
|
cause
|
|
.getattr("request_id")
|
|
.unwrap()
|
|
.extract::<String>()
|
|
.unwrap(),
|
|
"current-request"
|
|
);
|
|
assert_eq!(
|
|
cause
|
|
.getattr("job_id")
|
|
.unwrap()
|
|
.extract::<String>()
|
|
.unwrap(),
|
|
"outer-job"
|
|
);
|
|
assert_eq!(
|
|
cause
|
|
.getattr("job_request_id")
|
|
.unwrap()
|
|
.extract::<String>()
|
|
.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::<String>()
|
|
.unwrap(),
|
|
name
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|