fix(s3): make an unsupported delete participant retryable and explain it (#8574)

A user-bucket delete fans out through the delete-transaction RPC. When a
participant answers Unimplemented (a peer still running a release without
that RPC, or a protocol-version mismatch) the coordinator reports
StorageError::UnsupportedDisk, which had no S3 mapping: clients received a
500 InternalError whose message was the variant's Display text,
"drive does not support O_DIRECT". That text describes the startup drive
probe and sends an operator mid-upgrade after the wrong cause.

Map UnsupportedDisk to 503 ServiceUnavailable with a constant message that
names the protocol-version cause and the remedy (finish upgrading every
node, then retry). The typed source is retained for diagnostics and no
Retry-After is added, since retrying the same node does not help until the
cluster upgrade completes. No request path other than delete recovery
produces this variant; the startup drive probe still fails startup as
before.


Claude-Session: https://claude.ai/code/session_01CezCdjE9AdShFUi9tNk797

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Chris
2026-10-15 21:48:46 +08:00
committed by GitHub
parent 6ec1942ba3
commit d7156290d5
+47
View File
@@ -23,6 +23,13 @@ const MAX_VERSIONS_EXCEEDED_CODE: &str = "MaxVersionsExceeded";
const MAX_VERSIONS_EXCEEDED_MESSAGE: &str = "You've exceeded the limit on the number of versions you can create on this object";
const SLOW_DOWN_READ_CODE: &str = "SlowDownRead";
const SLOW_DOWN_READ_MESSAGE: &str = "Resource requested is unreadable, please reduce your request rate";
/// Public message for `StorageError::UnsupportedDisk` on a request path. The
/// only request-time producer is the delete-transaction protocol when a
/// participant answers Unimplemented (a peer still on a release without the
/// recovery RPC, or a protocol-version mismatch); the variant's Display text
/// ("drive does not support O_DIRECT") describes the startup drive probe and
/// would send an operator mid-upgrade after the wrong cause.
const UNSUPPORTED_PARTICIPANT_MESSAGE: &str = "A storage participant does not support this operation's protocol version. If the cluster is being upgraded, finish upgrading every node and retry.";
/// S3 error code for a request that names a KMS key the KMS does not hold.
pub const KMS_KEY_NOT_FOUND_ERROR_CODE: &str = "KMS.NotFoundException";
@@ -759,6 +766,9 @@ impl From<StorageError> for ApiError {
StorageError::ObjectNameInvalid(_, _) => S3ErrorCode::InvalidArgument,
StorageError::BucketExists(_) => S3ErrorCode::BucketAlreadyOwnedByYou,
StorageError::DiskFull | StorageError::StorageFull => S3ErrorCode::ServiceUnavailable,
// Retryable on an upgraded node (or once the cluster upgrade
// completes); see UNSUPPORTED_PARTICIPANT_MESSAGE.
StorageError::UnsupportedDisk => S3ErrorCode::ServiceUnavailable,
StorageError::SlowDown => S3ErrorCode::SlowDown,
StorageError::FaultyDisk
| StorageError::FaultyRemoteDisk
@@ -804,6 +814,8 @@ impl From<StorageError> for ApiError {
let message = if matches!(&err, StorageError::QuotaExceeded { .. }) {
err.to_string()
} else if matches!(&err, StorageError::UnsupportedDisk) {
UNSUPPORTED_PARTICIPANT_MESSAGE.to_string()
} else if matches!(&err, StorageError::MaxVersionsExceeded)
|| (code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)))
{
@@ -982,6 +994,40 @@ mod tests {
assert!(!body.contains("private-key"));
}
#[tokio::test]
async fn unsupported_participant_is_retryable_with_an_actionable_message() {
use http_body_util::BodyExt as _;
let error = S3Error::from(ApiError::from(StorageError::UnsupportedDisk));
assert_eq!(error.code(), &S3ErrorCode::ServiceUnavailable);
assert_eq!(error.message(), Some(UNSUPPORTED_PARTICIPANT_MESSAGE));
assert!(matches!(
error.source().and_then(|source| source.downcast_ref::<StorageError>()),
Some(StorageError::UnsupportedDisk)
));
let response = error
.to_http_response()
.expect("unsupported participant error must serialize");
assert_eq!(response.status(), http::StatusCode::SERVICE_UNAVAILABLE);
assert!(
!response.headers().contains_key(http::header::RETRY_AFTER),
"retrying the same node does not help until it or its peers are upgraded"
);
let body = response
.into_body()
.collect()
.await
.expect("error XML must be readable")
.to_bytes();
let xml = std::str::from_utf8(&body).expect("error XML must be UTF-8");
assert!(xml.contains("<Code>ServiceUnavailable</Code>"));
assert!(
!xml.contains("O_DIRECT"),
"the drive-probe Display text must not leak as the public message"
);
}
#[tokio::test]
async fn capacity_failures_share_service_unavailable_without_lock_retry_advice() {
use crate::storage_api::error::DiskError;
@@ -1958,6 +2004,7 @@ mod tests {
(StorageError::BucketExists("test".into()), S3ErrorCode::BucketAlreadyOwnedByYou),
(StorageError::DiskFull, S3ErrorCode::ServiceUnavailable),
(StorageError::StorageFull, S3ErrorCode::ServiceUnavailable),
(StorageError::UnsupportedDisk, S3ErrorCode::ServiceUnavailable),
(StorageError::SlowDown, S3ErrorCode::SlowDown),
(StorageError::FaultyDisk, S3ErrorCode::ServiceUnavailable),
(StorageError::FaultyRemoteDisk, S3ErrorCode::ServiceUnavailable),