use reqwest::Response;
use std::io::Error as IoError;
use thiserror::Error;
use wyrd_spec::error::{WyrdError, WyrdStorageError};
#[derive(Debug, Error)]
pub enum StorageClientError {
#[error("upload plan mismatch: expected {expected}, got {actual}")]
PlanMismatch {
expected: &'static str,
actual: &'static str,
},
#[error("upload hook is required: {0}")]
MissingHook(&'static str),
#[error("storage capability is required: {0}")]
MissingCapability(&'static str),
#[error("invalid upload plan: {0}")]
PlanInvalid(&'static str),
#[error("artifact source failed: {0}")]
Source(String),
#[error("artifact filesystem operation failed: {0}")]
Io(#[from] IoError),
#[error("artifact transfer transport failed during {operation}")]
Transport {
operation: &'static str,
},
#[error("multipart part {part_number} response did not include an ETag")]
MissingETag {
part_number: u32,
},
#[error("artifact size mismatch: expected {expected}, got {actual}")]
SizeMismatch {
expected: u64,
actual: u64,
},
#[error(
"artifact verification failed: expected {expected_sha256} / {expected_size} bytes, got {actual_sha256} / {actual_size} bytes"
)]
VerifyFailed {
expected_sha256: String,
actual_sha256: String,
expected_size: u64,
actual_size: u64,
},
#[error("{0}")]
Wyrd(WyrdError),
}
pub(crate) async fn map_backend_response(response: Response) -> StorageClientError {
let status = response.status().as_u16();
let _ = response.bytes().await;
map_backend_status(status)
}
pub(crate) fn map_backend_status(status: u16) -> StorageClientError {
let err = match status {
403 => WyrdStorageError::PresignExpired {
detail: format!("backend rejected presigned URL with HTTP {status}"),
},
412 => WyrdStorageError::PreconditionFailed,
416 => WyrdStorageError::RangeNotSatisfiable,
s if (500..600).contains(&s) => WyrdStorageError::BackendUnavailable { status: s },
s => WyrdStorageError::Backend {
detail: format!("backend returned HTTP {s}"),
},
};
StorageClientError::Wyrd(err.into())
}
pub(crate) fn from_authenticated(err: WyrdError) -> StorageClientError {
StorageClientError::Wyrd(err)
}
impl From<StorageClientError> for WyrdError {
fn from(error: StorageClientError) -> Self {
match error {
StorageClientError::Wyrd(err) => err,
StorageClientError::SizeMismatch { expected, actual } => {
WyrdStorageError::SizeMismatch { expected, actual }.into()
}
StorageClientError::VerifyFailed {
expected_sha256,
actual_sha256,
expected_size,
actual_size,
} => WyrdError::RegistryArtifactVerifyFailed {
message: "downloaded artifact did not match the server declaration".to_owned(),
details: serde_json::json!({
"expected_sha256": expected_sha256,
"actual_sha256": actual_sha256,
"expected_size": expected_size,
"actual_size": actual_size,
}),
},
StorageClientError::PlanMismatch { expected, actual } => WyrdStorageError::Backend {
detail: format!("invalid upload plan: expected {expected}, got {actual}"),
}
.into(),
StorageClientError::MissingHook(hook) => WyrdStorageError::Backend {
detail: format!("required client hook is missing: {hook}"),
}
.into(),
StorageClientError::MissingCapability(capability) => WyrdStorageError::Backend {
detail: format!("required storage capability is missing: {capability}"),
}
.into(),
StorageClientError::PlanInvalid(detail) => WyrdStorageError::Backend {
detail: detail.to_owned(),
}
.into(),
StorageClientError::Source(detail) => WyrdStorageError::Backend { detail }.into(),
StorageClientError::Io(_) | StorageClientError::Transport { .. } => {
WyrdStorageError::Backend {
detail: "artifact transfer failed".to_owned(),
}
.into()
}
StorageClientError::MissingETag { part_number } => WyrdStorageError::Backend {
detail: format!("multipart part {part_number} response omitted ETag"),
}
.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::{StorageClientError, map_backend_status};
use wyrd_spec::error::{WyrdError, WyrdStorageError};
#[test]
fn size_mismatch_maps_to_storage_catalog() {
let error: WyrdError = StorageClientError::SizeMismatch {
expected: 4,
actual: 3,
}
.into();
assert_eq!(error.code(), "WYRD_STORAGE_400_SIZE_MISMATCH");
assert_eq!(error.status(), 400);
}
#[test]
fn transport_error_does_not_expose_reqwest_or_url_material() {
let error = StorageClientError::Transport {
operation: "upload",
};
let rendered = error.to_string();
assert!(!rendered.contains("reqwest"));
assert!(!rendered.contains("https://"));
}
#[test]
fn presign_expiry_403_maps_to_presign_expired_catalog_entry() {
let error: WyrdError = map_backend_status(403).into();
assert_eq!(error.code(), "WYRD_STORAGE_503_PRESIGN_EXPIRED");
assert_eq!(error.status(), 503);
}
#[test]
fn precondition_412_maps_to_precondition_failed_catalog_entry() {
let error: WyrdError = map_backend_status(412).into();
assert_eq!(error.code(), "WYRD_STORAGE_412_PRECONDITION");
assert_eq!(error.status(), 412);
}
#[test]
fn range_416_maps_to_range_not_satisfiable_catalog_entry() {
let error: WyrdError = map_backend_status(416).into();
assert_eq!(error.code(), "WYRD_STORAGE_416_RANGE_NOT_SATISFIABLE");
assert_eq!(error.status(), 416);
}
#[test]
fn backend_5xx_maps_to_backend_unavailable_catalog_entry() {
let error: WyrdError = map_backend_status(503).into();
assert_eq!(error.code(), "WYRD_STORAGE_503_BACKEND_UNAVAILABLE");
assert_eq!(error.status(), 503);
}
#[test]
fn generic_4xx_maps_to_backend_catalog_entry() {
let error: WyrdError = map_backend_status(400).into();
assert_eq!(error.code(), "WYRD_STORAGE_500_BACKEND");
}
#[test]
fn structured_wyrd_error_flows_through_from_conversion_unchanged() {
let source: WyrdError = WyrdStorageError::PreconditionFailed.into();
let boundary: WyrdError = StorageClientError::Wyrd(source.clone()).into();
assert_eq!(source.code(), boundary.code());
assert_eq!(source.status(), boundary.status());
}
}