use aws_sdk_s3::error::BuildError;
use aws_smithy_runtime_api::client::orchestrator::HttpResponse;
use aws_smithy_runtime_api::client::result::SdkError;
use duckdb::arrow::error::ArrowError;
use std::fmt::Write;
use std::io;
use std::num::ParseIntError;
use std::path::Path;
use std::path::PathBuf;
use tokio::task::JoinError;
use crate::config::repository_config::RepoConfigError;
use crate::core::db::merkle_node::lmdb::LmdbError;
use crate::core::db::merkle_node::merkle_node_db::MerkleDbError;
use crate::model::ParsedResource;
use crate::model::RepoNew;
use crate::model::Schema;
use crate::model::Workspace;
use crate::model::merkle_tree::merkle_hash::HexHash;
use crate::model::merkle_tree::node_type::InvalidMerkleTreeNodeType;
pub mod path_buf_error;
pub mod string_error;
pub use crate::error::path_buf_error::PathBufError;
pub use crate::error::string_error::StringError;
pub const AUTH_TOKEN_NOT_FOUND: &str = "oxen authentication token not found, obtain one from your administrator and configure with:\n\noxen config --auth <HOST> <TOKEN>\n";
#[derive(thiserror::Error, Debug)]
pub enum OxenError {
#[error(
"oxen not configured, set email and name with:\n\noxen config --name YOUR_NAME --email YOUR_EMAIL\n"
)]
UserConfigNotFound,
#[error("Repository '{0}' not found")]
RepoNotFound(Box<RepoNew>),
#[error("No oxen repository found at {0}")]
LocalRepoNotFound(PathBufError),
#[error("Repository '{0}' already exists")]
RepoAlreadyExists(Box<RepoNew>),
#[error("Oxen repository already exists: {0:?}")]
RepoAlreadyExistsAtPath(PathBuf),
#[error("Invalid repository or namespace name '{0}'. Must match [a-zA-Z0-9][a-zA-Z0-9_.-]+")]
InvalidRepoName(StringError),
#[error("No fork status found.")]
ForkStatusNotFound,
#[error("Merkle store not initialized")]
MerkleStoreNotInitialized,
#[error(
"LMDB-backed Merkle store is not supported on virtual file systems. \
Either use the file-backed store (the default) or initialize the \
repository without --vfs."
)]
MerkleStoreLmdbNotSupportedOnVfs,
#[error("{0}")]
RepoConfig(#[from] RepoConfigError),
#[error("Remote repository not found: {0}")]
RemoteRepoNotFound(StringError),
#[error("{0}")]
UpstreamMergeConflict(StringError),
#[error("Merge in progress targeting commit {expected}, but new merge targets {found}.")]
MergeInProgressMismatch { expected: String, found: String },
#[error("No merge in progress to abort.")]
NoMergeInProgress,
#[error(
"No remote named '{0}' is set. You can set a remote by running:\n\noxen config --set-remote '{0}' <url>\n"
)]
RemoteNotSet(String),
#[error("{0}")]
BranchNotFound(StringError),
#[error("Revision not found: {0}")]
RevisionNotFound(StringError),
#[error("No commits found.")]
NoCommitsFound,
#[error("HEAD not found.")]
HeadNotFound,
#[error("{0}")]
MissingFileName(StringError),
#[error("Workspace not found: {0}")]
WorkspaceNotFound(StringError),
#[error("No queryable workspace found")]
QueryableWorkspaceNotFound,
#[error("Workspace is behind: {0}")]
WorkspaceBehind(Box<Workspace>),
#[error("A workspace with the name '{0}' already exists")]
WorkspaceAlreadyExists(String),
#[error("Workspace '{workspace_id}' staged db is corrupted: {source}")]
WorkspaceStagedDbCorrupted {
workspace_id: String,
source: Box<OxenError>,
},
#[error("{0}")]
WorkspaceNameIndex(#[from] crate::core::workspaces::workspace_name_index::WsError),
#[error("Resource not found: {0}")]
ResourceNotFound(StringError),
#[error("Path does not exist: {0}")]
PathDoesNotExist(PathBufError),
#[error("Resource not found: {0}")]
ParsedResourceNotFound(PathBufError),
#[error("{0}")]
MigrationRequired(StringError),
#[error("{0}")]
OxenUpdateRequired(StringError),
#[error("Invalid version: {0}")]
InvalidVersion(StringError),
#[error("{0}")]
Upload(StringError),
#[error("delete_objects: some keys failed to delete: {0:?}")]
DeleteFailure(Vec<(String, String)>),
#[error("Cannot restore {target_path}: version-store data missing for hash {hash}")]
VersionStoreDataMissing {
hash: String,
target_path: PathBufError,
},
#[error("Unsupported storage kind: {0}")]
UnsupportedStorageKind(String),
#[error("S3 storage backend not yet implemented")]
S3BackendNotImplemented,
#[error("{}", format_restore_failures(failures))]
RestoreFailed {
failures: Vec<(PathBufError, Box<OxenError>)>,
},
#[error("{0}")]
CommitEntryNotFound(StringError),
#[error("{0}")]
MerkleTreeError(#[from] InvalidMerkleTreeNodeType),
#[error("{0}")]
MerkleDbError(#[from] MerkleDbError),
#[error("No changes to commit")]
NoChanges,
#[error("No such commit, dir, or vnode Merkle tree node with hash (hex): {0}")]
MerkleNodeNotFound(HexHash),
#[error(
"Unsupported node type adding to a child file. Only accept Commit, Directory, File, or VNode. Found: {0}"
)]
DisallowedNodeWrite(&'static str),
#[error("{0}")]
Lmdb(#[from] LmdbError),
#[error("Invalid schema: {0}")]
InvalidSchema(Box<Schema>),
#[error("Incompatible schemas: {0}")]
IncompatibleSchemas(Box<Schema>),
#[error("{0}")]
InvalidFileType(StringError),
#[error("{0}")]
ColumnNameAlreadyExists(StringError),
#[error("{0}")]
ColumnNameNotFound(StringError),
#[error("{0}")]
UnsupportedOperation(StringError),
#[error(
"Video thumbnail generation requires the 'ffmpeg' feature to be enabled. Build with --features liboxen/ffmpeg to enable this functionality."
)]
ThumbnailingNotEnabled,
#[error("Query returned no rows")]
NoRowsFound,
#[error("{0}")]
DataFrameError(StringError),
#[error("{0}")]
ImportFileError(StringError),
#[error("{0}")]
SQLParseError(StringError),
#[error("AWS error: {0}")]
AwsError(Box<dyn std::error::Error + Send + Sync>),
#[error("Error stripping prefix: {0}")]
StripPrefixError(#[from] std::path::StripPrefixError),
#[error("{0}")]
IO(#[from] io::Error),
#[error("Could not create file: {0:?}: {1}")]
FileCreate(PathBuf, #[source] io::Error),
#[error("Could not rename file from {src:?} to {dst:?}: {source}")]
FileRename {
src: PathBuf,
dst: PathBuf,
#[source]
source: io::Error,
},
#[error("Authentication failed: {0}")]
Authentication(StringError),
#[error("{0}")]
ArrowError(#[from] ArrowError),
#[error("{0}")]
BinCodeError(#[from] bincode::Error),
#[error("Configuration error: {0}")]
TomlSer(#[from] toml::ser::Error),
#[error("Configuration error: {0}")]
TomlDe(#[from] toml::de::Error),
#[error("Invalid URI: {0}")]
URI(#[from] http::uri::InvalidUri),
#[error("Invalid URL: {0}")]
URL(#[from] url::ParseError),
#[error("JSON error: {0}")]
JSON(#[from] serde_json::Error),
#[error("Network error: {0}")]
HTTP(#[from] reqwest::Error),
#[error("UTF-8 encoding error: {0}")]
UTF8Error(#[from] std::str::Utf8Error),
#[error("UTF-8 conversion error: {0}")]
Utf8ConvError(#[from] std::string::FromUtf8Error),
#[error("Database error: {0}")]
DB(#[from] rocksdb::Error),
#[error("Query error: {0}")]
DUCKDB(#[from] duckdb::Error),
#[error("Environment variable error: {0}")]
ENV(#[from] std::env::VarError),
#[error("Image processing error: {0}")]
ImageError(#[from] image::ImageError),
#[error("Redis error: {0}")]
RedisError(#[from] redis::RedisError),
#[error("Connection pool error: {0}")]
R2D2Error(#[from] r2d2::Error),
#[error("Directory traversal error: {0}")]
JwalkError(#[from] jwalk::Error),
#[error("Pattern error: {0}")]
PatternError(#[from] glob::PatternError),
#[error("Glob error: {0}")]
GlobError(#[from] glob::GlobError),
#[error("DataFrame error: {0}")]
PolarsError(#[from] polars::prelude::PolarsError),
#[error("Invalid integer: {0}")]
ParseIntError(#[from] ParseIntError),
#[error("Encode error: {0}")]
RmpEncodeError(#[from] rmp_serde::encode::Error),
#[error("Decode error: {0}")]
RmpDecodeError(#[from] rmp_serde::decode::Error),
#[error("{context}{cause}")]
JoinError {
context: String,
#[source]
cause: JoinError,
},
#[error("Lock poisoned: {0}")]
LockPoisoned(StringError),
#[error("Internal error: HTTP client cache lock poisoned")]
ClientCachePoisoned,
#[error(
"Cannot push commit '{commit_id}' (\"{commit_message}\"): file data is not available locally.\nThis usually means the repository was cloned without full history.\n{help}"
)]
CannotPushShallowClone {
commit_id: String,
commit_message: String,
help: String,
},
#[error(
"Failed to download {num_files} files after {num_retries} retries: {last_error}\n{}",
format_download_entries(entries)
)]
DownloadBatchExhausted {
num_files: usize,
num_retries: u64,
entries: Vec<(String, PathBuf)>,
last_error: String,
},
#[error("Failed to fetch version {file_hash} from version store: {source}")]
VersionFetchFailed {
file_hash: String,
#[source]
source: Box<OxenError>,
},
#[error("Err status [{status}] from url {url} [{message}]")]
HttpStatusError {
url: String,
status: http::StatusCode,
message: String,
},
#[error("Could not deserialize response from [{url}]\n{status}")]
HttpDeserializeError {
url: String,
status: http::StatusCode,
},
#[error("{}", format_versions_missing_on_server(hashes))]
VersionsMissingOnServer { hashes: Vec<String> },
#[error("Remote Warning: {0}")]
RemoteWarning(StringError),
#[error("Unknown status [{0}]")]
UnknownRemoteResponseStatus(StringError),
#[error("{0}")]
Basic(StringError),
#[error("{0}")]
InternalError(StringError),
}
fn format_download_entries(entries: &[(String, PathBuf)]) -> String {
let mut out = format!("Failing batch ({} files):", entries.len());
for (hash, path) in entries {
let _ = write!(out, "\n {} (hash: {hash})", path.display());
}
out
}
fn is_fatal_http_status(status: http::StatusCode) -> bool {
if status == http::StatusCode::REQUEST_TIMEOUT || status == http::StatusCode::TOO_MANY_REQUESTS
{
return false;
}
status.is_client_error()
}
fn format_versions_missing_on_server(hashes: &[String]) -> String {
let mut out = format!(
"Server is missing {} version blob(s) requested in this batch:",
hashes.len()
);
for hash in hashes {
let _ = write!(out, "\n {hash}");
}
out
}
fn format_restore_failures(failures: &[(PathBufError, Box<OxenError>)]) -> String {
let mut out = format!("Failed to restore {} file(s):", failures.len());
for (path, err) in failures {
let rendered = err.to_string();
let mut lines = rendered.lines();
if let Some(first) = lines.next() {
let _ = write!(out, "\n {path}: {first}");
} else {
let _ = write!(out, "\n {path}:");
}
for line in lines {
let _ = write!(out, "\n {line}");
}
}
out
}
impl OxenError {
pub fn hint(&self) -> Option<String> {
use OxenError::*;
use std::io::ErrorKind::PermissionDenied;
let hint = match self {
LocalRepoNotFound(_) => "Run `oxen init` to create a new repository here.",
Authentication(_) => {
"Check your token with `oxen config --auth <HOST> <TOKEN>` and try again."
}
RemoteRepoNotFound(_) => {
"Verify the remote URL is correct. Check your remotes with `oxen remote -v`."
}
BranchNotFound(_) => "List available branches with `oxen branch --all`.",
RevisionNotFound(_) => {
"Check available branches with `oxen branch --all` or commits with `oxen log`."
}
HeadNotFound | NoCommitsFound => {
"This repository has no commits yet. Add files and create your first commit."
}
PathDoesNotExist(_)
| ResourceNotFound(_)
| ParsedResourceNotFound(_)
| CommitEntryNotFound(_) => "Check the path and current branch with `oxen status`.",
MergeInProgressMismatch { .. } => {
"Run `oxen merge --abort` to abandon the in-progress merge, or retry the original target."
}
VersionStoreDataMissing { .. } => {
"Run `oxen fetch --missing-files` to re-fetch missing version-store data, then retry `oxen restore`."
}
RestoreFailed { failures } => {
if failures
.iter()
.any(|(_, err)| matches!(err.as_ref(), VersionStoreDataMissing { .. }))
{
"Some files could not be restored because their version-store data is missing. Run `oxen fetch --missing-files` to re-fetch, then retry `oxen restore`."
} else {
"Run with RUST_LOG=debug for per-file details, or check `oxen status`."
}
}
HTTP(req_err) => {
if req_err.is_connect() || req_err.is_timeout() {
"Check your internet connection and that the remote host is reachable."
} else if req_err.is_status() {
if let Some(status) = req_err.status() {
return Some(format!("Server returned HTTP {status}."));
} else {
return None;
}
} else {
"Check your internet connection and remote configuration with `oxen remote -v`."
}
}
IO(io_err) if io_err.kind() == PermissionDenied => {
"Check file permissions and try again."
}
DB(_) | ArrowError(_) | BinCodeError(_) | RedisError(_) | R2D2Error(_)
| RmpDecodeError(_) => {
"This is an internal error. Run with RUST_LOG=debug for more details."
}
WorkspaceStagedDbCorrupted { .. } => {
"Recreate the workspace: `oxen workspace delete <id>` then re-create it."
}
DownloadBatchExhausted { .. } | VersionsMissingOnServer { .. } => {
"If a content blob is missing on the server, run `oxen push --missing-files` from a clone with the full local history to repair it."
}
_ => return None,
}
.to_string();
Some(hint)
}
pub fn is_auth_error(&self) -> bool {
matches!(self, OxenError::Authentication(_))
}
pub fn is_not_found(&self) -> bool {
matches!(
self,
OxenError::PathDoesNotExist(_)
| OxenError::ResourceNotFound(_)
| OxenError::RemoteRepoNotFound(_)
| OxenError::LocalRepoNotFound(_)
| OxenError::ParsedResourceNotFound(_)
| OxenError::WorkspaceNotFound(_)
| OxenError::QueryableWorkspaceNotFound
)
}
pub fn is_fatal_for_retry(&self) -> bool {
if self.is_auth_error() || self.is_not_found() {
return true;
}
match self {
OxenError::HttpStatusError { status, .. }
| OxenError::HttpDeserializeError { status, .. } => is_fatal_http_status(*status),
OxenError::HTTP(req_err) => req_err.status().is_some_and(is_fatal_http_status),
OxenError::VersionsMissingOnServer { .. } => true,
OxenError::VersionStoreDataMissing { .. } => true,
OxenError::UnknownRemoteResponseStatus(_) => true,
_ => false,
}
}
pub fn authentication(s: impl AsRef<str>) -> Self {
OxenError::Authentication(StringError::from(s.as_ref()))
}
pub fn invalid_version(s: impl AsRef<str>) -> Self {
OxenError::InvalidVersion(StringError::from(s.as_ref()))
}
pub fn upload(s: &str) -> Self {
OxenError::Upload(StringError::from(s))
}
pub fn repo_not_found(repo: RepoNew) -> Self {
OxenError::RepoNotFound(Box::new(repo))
}
pub fn file_import_error(s: impl AsRef<str>) -> Self {
OxenError::ImportFileError(StringError::from(s.as_ref()))
}
pub fn resource_not_found(value: impl AsRef<str>) -> Self {
OxenError::ResourceNotFound(StringError::from(value.as_ref()))
}
pub fn path_does_not_exist(path: impl AsRef<Path>) -> Self {
OxenError::PathDoesNotExist(path.as_ref().into())
}
pub fn parsed_resource_not_found(resource: ParsedResource) -> Self {
OxenError::ParsedResourceNotFound(resource.resource.into())
}
pub fn local_repo_not_found(dir: impl AsRef<Path>) -> OxenError {
OxenError::LocalRepoNotFound(dir.as_ref().into())
}
pub fn email_and_name_not_set() -> OxenError {
OxenError::UserConfigNotFound
}
pub fn remote_branch_not_found(name: impl AsRef<str>) -> OxenError {
let err = format!("Remote branch '{}' not found", name.as_ref());
OxenError::BranchNotFound(err.into())
}
pub fn local_branch_not_found(name: impl AsRef<str>) -> OxenError {
let err = format!("Branch '{}' not found", name.as_ref());
OxenError::BranchNotFound(err.into())
}
pub fn entry_does_not_exist(path: impl AsRef<Path>) -> OxenError {
OxenError::ParsedResourceNotFound(path.as_ref().into())
}
pub fn entry_does_not_exist_in_commit(
path: impl AsRef<Path>,
commit_id: impl AsRef<str>,
) -> OxenError {
let err = format!(
"Entry {:?} does not exist in commit {}",
path.as_ref(),
commit_id.as_ref()
);
OxenError::CommitEntryNotFound(err.into())
}
pub fn invalid_file_type(file_type: impl AsRef<str>) -> OxenError {
let err = format!("Invalid file type: {:?}", file_type.as_ref());
OxenError::InvalidFileType(StringError::from(err))
}
pub fn column_name_already_exists(column_name: &str) -> OxenError {
let err = format!("Column name already exists: {column_name:?}");
OxenError::ColumnNameAlreadyExists(StringError::from(err))
}
pub fn column_name_not_found(column_name: &str) -> OxenError {
let err = format!("Column name not found: {column_name:?}");
OxenError::ColumnNameNotFound(StringError::from(err))
}
pub fn incompatible_schemas(schema: Schema) -> OxenError {
OxenError::IncompatibleSchemas(Box::new(schema))
}
pub fn basic_str(s: impl AsRef<str>) -> Self {
OxenError::Basic(StringError::from(s.as_ref()))
}
pub fn internal_error(s: impl AsRef<str>) -> Self {
OxenError::InternalError(StringError::from(s.as_ref()))
}
pub fn home_dir_not_found() -> OxenError {
OxenError::basic_str("Home directory not found")
}
pub fn cache_dir_not_found() -> OxenError {
OxenError::basic_str("Cache directory not found")
}
pub fn must_be_on_valid_branch() -> OxenError {
OxenError::basic_str(
"Repository is in a detached HEAD state, checkout a valid branch to continue.\n\n oxen checkout <branch>\n",
)
}
pub fn no_schemas_staged() -> OxenError {
OxenError::basic_str(
"No schemas staged\n\nAuto detect schema on file with:\n\n oxen add path/to/file.csv\n\nOr manually add a schema override with:\n\n oxen schemas add path/to/file.csv 'name:str, age:i32'\n",
)
}
pub fn no_schemas_committed() -> OxenError {
OxenError::basic_str(
"No schemas committed\n\nAuto detect schema on file with:\n\n oxen add path/to/file.csv\n\nOr manually add a schema override with:\n\n oxen schemas add path/to/file.csv 'name:str, age:i32'\n\nThen commit the schema with:\n\n oxen commit -m 'Adding schema for path/to/file.csv'\n",
)
}
pub fn schema_does_not_exist(path: impl AsRef<Path>) -> OxenError {
OxenError::basic_str(format!("Schema does not exist {:?}", path.as_ref()))
}
pub fn commit_id_does_not_exist(commit_id: impl AsRef<str>) -> OxenError {
OxenError::basic_str(format!("Could not find commit: {}", commit_id.as_ref()))
}
pub fn file_error(path: impl AsRef<Path>, error: std::io::Error) -> OxenError {
OxenError::basic_str(format!(
"File does not exist: {:?} error {:?}",
path.as_ref(),
error
))
}
pub fn file_create_error(path: impl AsRef<Path>, error: std::io::Error) -> OxenError {
OxenError::FileCreate(path.as_ref().to_path_buf(), error)
}
pub fn file_open_error(path: impl AsRef<Path>, error: std::io::Error) -> OxenError {
OxenError::basic_str(format!(
"Could not open file: {:?} error {:?}",
path.as_ref(),
error
))
}
pub fn file_read_error(path: impl AsRef<Path>, error: std::io::Error) -> OxenError {
OxenError::basic_str(format!(
"Could not read file: {:?} error {:?}",
path.as_ref(),
error
))
}
pub fn file_metadata_error(path: impl AsRef<Path>, error: std::io::Error) -> OxenError {
OxenError::basic_str(format!(
"Could not get file metadata: {:?} error {:?}",
path.as_ref(),
error
))
}
pub fn file_copy_error(
src: impl AsRef<Path>,
dst: impl AsRef<Path>,
err: impl std::fmt::Debug,
) -> OxenError {
OxenError::basic_str(format!(
"File copy error: {err:?}\nCould not copy from `{:?}` to `{:?}`",
src.as_ref(),
dst.as_ref()
))
}
pub fn file_rename_error(
src: impl AsRef<Path>,
dst: impl AsRef<Path>,
source: std::io::Error,
) -> OxenError {
OxenError::FileRename {
src: src.as_ref().to_path_buf(),
dst: dst.as_ref().to_path_buf(),
source,
}
}
pub fn cannot_overwrite_files(paths: &[PathBuf]) -> OxenError {
let paths_str = paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect::<Vec<String>>()
.join("\n ");
OxenError::basic_str(format!(
"\nError: your local changes to the following files would be overwritten. Please commit the following changes before continuing:\n\n {paths_str}\n"
))
}
pub fn must_supply_valid_api_key() -> OxenError {
OxenError::basic_str(
"Must supply valid API key. Create an account at https://oxen.ai and then set the API key with:\n\n oxen config --auth hub.oxen.ai <API_KEY>\n",
)
}
pub fn file_has_no_name(path: impl AsRef<Path>) -> OxenError {
OxenError::basic_str(format!("File has no file_name: {:?}", path.as_ref()))
}
pub fn could_not_convert_path_to_str(path: impl AsRef<Path>) -> OxenError {
OxenError::basic_str(format!("File has no name: {:?}", path.as_ref()))
}
pub fn local_revision_not_found(name: impl AsRef<str>) -> OxenError {
OxenError::basic_str(format!(
"Local branch or commit reference `{}` not found",
name.as_ref()
))
}
pub fn could_not_find_merge_conflict(path: impl AsRef<Path>) -> OxenError {
OxenError::basic_str(format!(
"Could not find merge conflict for path: {:?}",
path.as_ref()
))
}
pub fn could_not_decode_value_for_key_error(key: impl AsRef<str>) -> OxenError {
OxenError::basic_str(format!(
"Could not decode value for key: {:?}",
key.as_ref()
))
}
pub fn invalid_set_remote_url(url: impl AsRef<str>) -> OxenError {
OxenError::basic_str(format!(
"\nRemote invalid, must be fully qualified URL, got: {:?}\n\n oxen config --set-remote origin https://hub.oxen.ai/<namespace>/<reponame>\n",
url.as_ref()
))
}
pub fn parse_error(value: impl AsRef<str>) -> OxenError {
OxenError::basic_str(format!("Parse error: {:?}", value.as_ref()))
}
pub fn unknown_subcommand(parent: impl AsRef<str>, name: impl AsRef<str>) -> OxenError {
OxenError::basic_str(format!(
"Unknown {} subcommand '{}'",
parent.as_ref(),
name.as_ref()
))
}
}
impl From<JoinError> for OxenError {
fn from(error: JoinError) -> Self {
OxenError::JoinError {
context: "".to_string(),
cause: error,
}
}
}
impl From<String> for OxenError {
fn from(error: String) -> Self {
OxenError::Basic(StringError::from(error))
}
}
impl<E> From<SdkError<E, HttpResponse>> for OxenError
where
E: std::error::Error + Send + Sync + 'static,
{
fn from(e: SdkError<E, HttpResponse>) -> Self {
OxenError::AwsError(Box::new(e))
}
}
impl From<BuildError> for OxenError {
fn from(e: BuildError) -> Self {
OxenError::AwsError(Box::new(e))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn download_batch_exhausted_display_lists_paths_hashes_and_last_error() {
let err = OxenError::DownloadBatchExhausted {
num_files: 7,
num_retries: 5,
entries: vec![
(
"b30cefc4eb9ad1c6f3f61047cec5c828".to_string(),
PathBuf::from("parquet/arize-ax-alex_sessions.parquet"),
),
(
"d13964d33acc80244980c9e16fe5fc2b".to_string(),
PathBuf::from("parquet/phoenix-lambda2-dal_sessions.parquet"),
),
],
last_error: "underlying io error: file not found".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("7 files"), "missing num_files: {msg}");
assert!(msg.contains("5 retries"), "missing num_retries: {msg}");
assert!(
msg.contains("parquet/arize-ax-alex_sessions.parquet"),
"missing first path: {msg}"
);
assert!(
msg.contains("parquet/phoenix-lambda2-dal_sessions.parquet"),
"missing second path: {msg}"
);
assert!(
msg.contains("b30cefc4eb9ad1c6f3f61047cec5c828"),
"missing first hash: {msg}"
);
assert!(
msg.contains("d13964d33acc80244980c9e16fe5fc2b"),
"missing second hash: {msg}"
);
assert!(
msg.contains("underlying io error"),
"missing last_error: {msg}"
);
}
#[test]
fn download_batch_exhausted_hint_mentions_missing_files_recovery() {
let err = OxenError::DownloadBatchExhausted {
num_files: 1,
num_retries: 5,
entries: vec![("abc".to_string(), PathBuf::from("foo.txt"))],
last_error: "io error".to_string(),
};
let hint = err.hint().expect("expected a hint");
assert!(
hint.contains("--missing-files"),
"hint should point at recovery flag: {hint}"
);
}
#[test]
fn version_fetch_failed_display_includes_hash_and_inner_cause() {
let inner = OxenError::Basic(StringError::from("file not found"));
let err = OxenError::VersionFetchFailed {
file_hash: "b30cefc4eb9ad1c6f3f61047cec5c828".to_string(),
source: Box::new(inner),
};
let msg = format!("{err}");
assert!(
msg.contains("b30cefc4eb9ad1c6f3f61047cec5c828"),
"missing file_hash: {msg}"
);
assert!(
msg.contains("file not found"),
"Display should include the wrapped cause: {msg}"
);
let source = std::error::Error::source(&err).expect("expected a source error");
assert!(
source.to_string().contains("file not found"),
"source chain missing inner message: {source}"
);
}
#[test]
fn http_status_error_display_includes_url_status_and_message() {
let err = OxenError::HttpStatusError {
url: "https://hub.example.com/api/repos/x/y/versions".to_string(),
status: http::StatusCode::NOT_FOUND,
message: "Resource not found".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("404"), "missing status: {msg}");
assert!(msg.contains("/versions"), "missing url: {msg}");
assert!(msg.contains("Resource not found"), "missing message: {msg}");
}
#[test]
fn http_deserialize_error_display_includes_url_and_status() {
let err = OxenError::HttpDeserializeError {
url: "https://hub.example.com/api/repos/x/y/versions".to_string(),
status: http::StatusCode::BAD_GATEWAY,
};
let msg = format!("{err}");
assert!(msg.contains("502"), "missing status: {msg}");
assert!(msg.contains("/versions"), "missing url: {msg}");
}
#[test]
fn versions_missing_on_server_display_lists_each_hash() {
let err = OxenError::VersionsMissingOnServer {
hashes: vec!["abc".to_string(), "def".to_string()],
};
let msg = format!("{err}");
assert!(msg.contains("2 version blob"), "missing count: {msg}");
assert!(msg.contains("abc"), "missing first hash: {msg}");
assert!(msg.contains("def"), "missing second hash: {msg}");
}
#[test]
fn is_fatal_for_retry_short_circuits_on_4xx_http_status_error() {
let make = |status| OxenError::HttpStatusError {
url: "u".to_string(),
status,
message: "nope".to_string(),
};
assert!(
make(http::StatusCode::NOT_FOUND).is_fatal_for_retry(),
"404 should be fatal"
);
assert!(
!make(http::StatusCode::REQUEST_TIMEOUT).is_fatal_for_retry(),
"408 should be retryable"
);
assert!(
!make(http::StatusCode::TOO_MANY_REQUESTS).is_fatal_for_retry(),
"429 should be retryable"
);
}
#[test]
fn is_fatal_for_retry_retries_on_5xx_http_status_error() {
let err = OxenError::HttpStatusError {
url: "u".to_string(),
status: http::StatusCode::INTERNAL_SERVER_ERROR,
message: "blip".to_string(),
};
assert!(!err.is_fatal_for_retry(), "5xx should be retryable");
}
#[test]
fn is_fatal_for_retry_classifies_deserialize_error_by_status() {
let make = |status| OxenError::HttpDeserializeError {
url: "u".to_string(),
status,
};
assert!(
make(http::StatusCode::NOT_FOUND).is_fatal_for_retry(),
"404 deserialize fail is fatal"
);
assert!(
!make(http::StatusCode::BAD_GATEWAY).is_fatal_for_retry(),
"5xx deserialize fail (HTML proxy page) is retryable"
);
assert!(
!make(http::StatusCode::REQUEST_TIMEOUT).is_fatal_for_retry(),
"408 deserialize fail should be retryable"
);
assert!(
!make(http::StatusCode::TOO_MANY_REQUESTS).is_fatal_for_retry(),
"429 deserialize fail should be retryable"
);
}
#[test]
fn is_fatal_for_retry_short_circuits_on_versions_missing_on_server() {
let err = OxenError::VersionsMissingOnServer {
hashes: vec!["abc".to_string()],
};
assert!(err.is_fatal_for_retry());
}
#[test]
fn is_fatal_for_retry_short_circuits_on_unknown_remote_response_status() {
let err = OxenError::UnknownRemoteResponseStatus("not-a-real-status".into());
assert!(err.is_fatal_for_retry());
}
#[test]
fn is_fatal_for_retry_short_circuits_on_auth_and_not_found() {
assert!(OxenError::authentication("nope").is_fatal_for_retry());
assert!(OxenError::resource_not_found("path").is_fatal_for_retry());
}
#[test]
fn is_fatal_for_retry_keeps_retrying_on_generic_basic_error() {
let err = OxenError::Basic(StringError::from("connection reset"));
assert!(!err.is_fatal_for_retry());
}
}