use spate_core::error::ErrorClass;
pub(crate) fn classify(e: &object_store::Error) -> ErrorClass {
use object_store::Error as E;
match e {
_ if list_rejected(e) || spate_core::tls_rejection!(rustls, e).is_some() => {
ErrorClass::Fatal
}
E::NotFound { .. }
| E::Precondition { .. }
| E::NotModified { .. }
| E::AlreadyExists { .. }
| E::InvalidPath { .. }
| E::NotSupported { .. }
| E::NotImplemented { .. }
| E::PermissionDenied { .. }
| E::Unauthenticated { .. }
| E::UnknownConfigurationKey { .. } => ErrorClass::Fatal,
_ => ErrorClass::Retryable,
}
}
pub(crate) fn is_pipeline_fatal(e: &object_store::Error) -> bool {
use object_store::Error as E;
spate_core::tls_rejection!(rustls, e).is_some()
|| matches!(
e,
E::InvalidPath { .. }
| E::NotSupported { .. }
| E::NotImplemented { .. }
| E::PermissionDenied { .. }
| E::Unauthenticated { .. }
| E::UnknownConfigurationKey { .. }
)
}
fn list_rejected(e: &object_store::Error) -> bool {
let object_store::Error::Generic { source, .. } = e else {
return false;
};
source
.to_string()
.starts_with("Error performing list request: ")
&& source
.source()
.and_then(|request| request.source())
.is_some_and(|status| {
status
.to_string()
.strip_prefix("Server returned non-2xx status code: ")
.is_some_and(|code| code.starts_with("401 ") || code.starts_with("403 "))
})
}
pub(crate) fn reason(e: &object_store::Error) -> String {
match spate_core::tls_rejection!(rustls, e) {
Some(tls) => format!("{e}: {tls}"),
None => e.to_string(),
}
}
pub(crate) fn poison_kind(e: &object_store::Error) -> crate::split_ctx::PoisonKind {
use crate::split_ctx::PoisonKind;
use object_store::Error as E;
match e {
E::NotFound { .. } => PoisonKind::NotFound,
E::Precondition { .. } | E::NotModified { .. } | E::AlreadyExists { .. } => {
PoisonKind::EtagDrift
}
_ => PoisonKind::Undecodable,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn object_level_failures_classify_as_poison_not_pipeline_fatal() {
use object_store::Error as E;
let src = || "boom".into();
let table: Vec<(E, bool, bool)> = vec![
(
E::NotFound {
path: "k".into(),
source: src(),
},
true,
false,
),
(
E::Precondition {
path: "k".into(),
source: src(),
},
true,
false,
),
(
E::PermissionDenied {
path: "k".into(),
source: src(),
},
true,
true,
),
(
E::Unauthenticated {
path: "k".into(),
source: src(),
},
true,
true,
),
(
E::UnknownConfigurationKey {
store: "s3",
key: "nope".into(),
},
true,
true,
),
(
E::Generic {
store: "s3",
source: src(),
},
false,
false,
),
];
for (e, non_retryable, fatal) in &table {
assert_eq!(
classify(e) != crate::error::ErrorClass::Retryable,
*non_retryable,
"classify({e})"
);
assert_eq!(is_pipeline_fatal(e), *fatal, "is_pipeline_fatal({e})");
}
}
#[tokio::test]
async fn a_rejected_listing_is_fatal() {
use futures_util::StreamExt as _;
use object_store::ObjectStore as _;
for (status, expected) in [
("403 Forbidden", ErrorClass::Fatal),
("401 Unauthorized", ErrorClass::Fatal),
("500 Internal Server Error", ErrorClass::Retryable),
] {
let store = crate::test_servers::store_at(&crate::test_servers::status_server(status));
let e = store.list(None).next().await.unwrap().unwrap_err();
assert!(e.to_string().contains(status), "{e}");
if expected == ErrorClass::Fatal {
assert!(
matches!(e, object_store::Error::Generic { .. }),
"object_store reports a rejected listing as a typed error, so \
`list_rejected` is dead and can be deleted: {e:?}"
);
}
assert_eq!(classify(&e), expected, "{e}");
}
}
#[tokio::test]
async fn a_rejected_read_is_fatal() {
use object_store::ObjectStore as _;
for status in ["403 Forbidden", "401 Unauthorized"] {
let store = crate::test_servers::store_at(&crate::test_servers::status_server(status));
let e = store
.get_opts(&"k".into(), object_store::GetOptions::default())
.await
.unwrap_err();
assert!(
matches!(
e,
object_store::Error::PermissionDenied { .. }
| object_store::Error::Unauthenticated { .. }
),
"{e:?}"
);
assert!(e.to_string().contains(status), "{e}");
assert_eq!(classify(&e), ErrorClass::Fatal, "{e}");
assert!(is_pipeline_fatal(&e), "{e}");
}
}
#[test]
fn a_403_outside_a_listing_is_retryable() {
for text in [
"Error performing PUT http://127.0.0.1/latest/api/token in 1ms - \
Server returned non-2xx status code: 403 Forbidden: denied",
"Error performing list request: Server returned non-2xx status code: 403 Forbidden: ",
] {
let e = object_store::Error::Generic {
store: "S3",
source: text.into(),
};
assert_eq!(classify(&e), ErrorClass::Retryable, "{e}");
assert!(!is_pipeline_fatal(&e), "{e}");
}
}
#[test]
fn a_403_chain_is_fatal_only_under_a_list_request() {
#[derive(Debug)]
struct Link(String, Option<Box<Link>>);
impl std::fmt::Display for Link {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for Link {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.1.as_deref().map(|l| l as _)
}
}
let status = "Server returned non-2xx status code: 403 Forbidden: denied";
let chain = |top: &str| {
let request = format!("Error performing POST http://127.0.0.1/b in 1ms - {status}");
object_store::Error::Generic {
store: "S3",
source: Box::new(Link(
format!("{top}: {request}"),
Some(Box::new(Link(
request,
Some(Box::new(Link(status.to_owned(), None))),
))),
)),
}
};
for (top, expected) in [
("Error performing list request", ErrorClass::Fatal),
(
"Error performing CreateSession request",
ErrorClass::Retryable,
),
] {
let e = chain(top);
assert_eq!(classify(&e), expected, "{e}");
}
}
async fn list_error(store: &object_store::aws::AmazonS3) -> object_store::Error {
use futures_util::StreamExt as _;
use object_store::ObjectStore as _;
store.list(None).next().await.unwrap().unwrap_err()
}
async fn get_error(store: &object_store::aws::AmazonS3) -> object_store::Error {
use object_store::ObjectStore as _;
store
.get_opts(&"k".into(), object_store::GetOptions::default())
.await
.unwrap_err()
}
#[tokio::test]
async fn a_rejecting_tls_alert_is_fatal() {
use rustls::AlertDescription as A;
let ca = crate::test_servers::TestCa::new("any");
for (alert, expected) in [
(A::HandshakeFailure, ErrorClass::Fatal),
(A::ProtocolVersion, ErrorClass::Fatal),
(A::DecodeError, ErrorClass::Retryable),
] {
let name = format!("{alert:?}");
let addr = spate_test::tls_alert_server(b"", u8::from(alert));
let store = crate::test_servers::tls_store_at(&format!("https://{addr}"), &ca);
for e in [list_error(&store).await, get_error(&store).await] {
assert!(format!("{e:?}").contains(&name), "{e:?}");
assert_eq!(classify(&e), expected, "{e:?}");
assert_eq!(is_pipeline_fatal(&e), expected == ErrorClass::Fatal, "{e}");
if expected == ErrorClass::Fatal {
assert!(reason(&e).contains(&name), "{}", reason(&e));
}
}
}
}
#[tokio::test]
async fn an_untrusted_certificate_is_fatal() {
let url =
crate::test_servers::serve(&crate::test_servers::TestCa::new("server"), None).await;
let store =
crate::test_servers::tls_store_at(&url, &crate::test_servers::TestCa::new("other"));
for e in [list_error(&store).await, get_error(&store).await] {
assert_eq!(classify(&e), ErrorClass::Fatal, "{e:?}");
assert!(is_pipeline_fatal(&e), "{e}");
assert!(
matches!(
spate_core::tls_rejection!(rustls, &e),
Some(rustls::Error::InvalidCertificate(_))
),
"{e:?}"
);
assert!(reason(&e).contains("UnknownIssuer"), "{}", reason(&e));
}
}
#[tokio::test]
async fn a_refused_client_certificate_is_fatal() {
let server = crate::test_servers::TestCa::new("server");
let clients = crate::test_servers::TestCa::new("clients");
let url = crate::test_servers::serve(&server, Some(&clients)).await;
let e = list_error(&crate::test_servers::tls_store_at(&url, &server)).await;
assert_eq!(classify(&e), ErrorClass::Fatal, "{e:?}");
assert!(reason(&e).contains("CertificateRequired"), "{}", reason(&e));
}
}