diff --git a/sdk/storage/azure_storage_blob/src/models/extensions.rs b/sdk/storage/azure_storage_blob/src/models/extensions.rs index 02aa70f331..d5466696c5 100644 --- a/sdk/storage/azure_storage_blob/src/models/extensions.rs +++ b/sdk/storage/azure_storage_blob/src/models/extensions.rs @@ -6,7 +6,6 @@ use crate::models::{ BlockBlobClientUploadBlobFromUrlOptions, BlockBlobClientUploadOptions, PageBlobClientCreateOptions, }; -use azure_core::error::ErrorKind; use std::collections::HashMap; /// Augments the current options bag to only create if the Page blob does not already exist. diff --git a/sdk/storage/azure_storage_blob/src/models/mod.rs b/sdk/storage/azure_storage_blob/src/models/mod.rs index 68354de9e6..082c217106 100644 --- a/sdk/storage/azure_storage_blob/src/models/mod.rs +++ b/sdk/storage/azure_storage_blob/src/models/mod.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. mod extensions; +mod storage_error; pub use crate::generated::models::{ AccessTier, AccountKind, AppendBlobClientAppendBlockFromUrlOptions, @@ -84,3 +85,4 @@ pub use crate::generated::models::{ UserDelegationKey, UserDelegationKeyHeaders, VecSignedIdentifierHeaders, }; pub use extensions::*; +pub use storage_error::StorageError; diff --git a/sdk/storage/azure_storage_blob/src/models/storage_error.rs b/sdk/storage/azure_storage_blob/src/models/storage_error.rs new file mode 100644 index 0000000000..67bac2a20f --- /dev/null +++ b/sdk/storage/azure_storage_blob/src/models/storage_error.rs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use crate::generated::models::StorageErrorCode; +use azure_core::{error::ErrorKind, http::RawResponse, xml}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; + +/// Represents an error response from Azure Storage services. +#[derive(Debug, Clone)] +pub struct StorageError { + /// The HTTP status code. + pub status_code: azure_core::http::StatusCode, + /// The Storage error code, if available. + pub error_code: Option, + /// The error message, if available. + pub message: Option, + /// The request ID from the x-ms-request-id header, if available. + pub request_id: Option, + /// The raw HTTP response. + pub raw_response: RawResponse, + /// Additional fields from the error response that weren't explicitly mapped. + pub additional_error_info: HashMap, +} + +impl StorageError { + pub fn status_code(&self) -> azure_core::http::StatusCode { + self.status_code + } + + pub fn error_code(&self) -> Option<&StorageErrorCode> { + self.error_code.as_ref() + } + + pub fn message(&self) -> Option<&str> { + self.message.as_deref() + } + + pub fn request_id(&self) -> Option<&str> { + self.request_id.as_deref() + } + + pub fn raw_response(&self) -> &RawResponse { + &self.raw_response + } + + pub fn additional_error_info(&self) -> &HashMap { + &self.additional_error_info + } + + /// Converts a `serde_json::Value` to a String representation, handling nested XML structures. + fn value_to_string(value: &Value) -> String { + match value { + // Handle null values + Value::Null => "null".to_string(), + + // Handle boolean values + Value::Bool(b) => b.to_string(), + + // Handle numeric values + Value::Number(n) => n.to_string(), + + // Handle string values + Value::String(s) => s.clone(), + + // Handle arrays + Value::Array(arr) => { + let elements: Vec = arr.iter().map(Self::value_to_string).collect(); + format!("[{}]", elements.join(", ")) + } + + // Handle objects (including XML elements with $text) + Value::Object(obj) => { + // Special case: if the object only has a "$text" field, extract it + if obj.len() == 1 && obj.contains_key("$text") { + if let Some(Value::String(text)) = obj.get("$text") { + return text.clone(); + } + } + + // For other objects, format as key-value pairs + let pairs: Vec = obj + .iter() + .map(|(k, v)| { + // Skip $text key in compound objects to avoid duplication + if k == "$text" { + Self::value_to_string(v) + } else { + format!("{}: {}", k, Self::value_to_string(v)) + } + }) + .collect(); + + // If it's a single element (after filtering), return it directly + if pairs.len() == 1 { + pairs[0].clone() + } else { + format!("{{{}}}", pairs.join(", ")) + } + } + } + } + + /// Deserializes a `StorageError` from XML body with HTTP response metadata. + fn from_xml( + xml_body: &[u8], + status_code: azure_core::http::StatusCode, + raw_response: RawResponse, + ) -> Result { + #[derive(Deserialize)] + #[serde(rename = "Error")] + struct StorageErrorXml { + #[serde(rename = "Code")] + code: Option, + #[serde(rename = "Message")] + message: Option, + #[serde(flatten)] + additional_fields: HashMap, + } + + let xml_fields = xml::from_xml::<_, StorageErrorXml>(xml_body)?; + + // Parse error code from XML body + let error_code = xml_fields.code.and_then(|code| { + code.parse() + .ok() + .or(Some(StorageErrorCode::UnknownValue(code))) + }); + + let request_id = raw_response.headers().get_optional_string( + &azure_core::http::headers::HeaderName::from_static("x-ms-request-id"), + ); + + // Convert additional fields from HashMap to HashMap + let additional_error_info = xml_fields + .additional_fields + .iter() + .map(|(k, v)| (k.clone(), Self::value_to_string(v))) + .collect(); + + Ok(StorageError { + status_code, + error_code, + message: xml_fields.message, + request_id, + raw_response, + additional_error_info, + }) + } +} + +impl TryFrom for StorageError { + type Error = azure_core::Error; + + fn try_from(error: azure_core::Error) -> Result { + match error.kind() { + ErrorKind::HttpResponse { + status, + error_code, + raw_response, + } => { + let raw_response = raw_response.as_ref().ok_or_else(|| { + azure_core::Error::with_message( + azure_core::error::ErrorKind::DataConversion, + "Cannot convert to StorageError: raw_response is missing.", + ) + })?; + + let body = raw_response.body(); + + // No XML Body, Use Error Code from HttpResponse + + // TODO: Need to figure out how to extract the message, seems like it's getting dropped somewhere when azure_core::Error model is created + // i.e. Captured Response from Over The Wire: + // HTTP/1.1 404 The specified blob does not exist. <---- This is getting dropped, I don't see it in Error or HttpResponse debug outputs + // Transfer-Encoding: chunked + // Server: Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0 + // x-ms-request-id: xxxx + // x-ms-client-request-id: xxxxxx + // x-ms-version: 2025-11-05 + // x-ms-error-code: BlobNotFound + // Date: Fri, 10 Oct 2025 22:55:41 GMT + + if body.is_empty() { + let error_code = error_code.as_ref().and_then(|code| { + code.parse() + .ok() + .or(Some(StorageErrorCode::UnknownValue(code.clone()))) + }); + + let request_id = raw_response.as_ref().clone().headers().get_optional_string( + &azure_core::http::headers::HeaderName::from_static("x-ms-request-id"), + ); + + return Ok(StorageError { + status_code: *status, + error_code, + message: None, + request_id, + raw_response: raw_response.as_ref().clone(), + additional_error_info: HashMap::new(), + }); + } + + StorageError::from_xml(body, *status, raw_response.as_ref().clone()) + } + // TODO: We may have to handle other ErrorKind variants, but catch-all for now. + _ => Err(azure_core::Error::with_message( + azure_core::error::ErrorKind::DataConversion, + "ErrorKind was not HttpResponse and could not be parsed.", + )), + } + } +} + +impl std::fmt::Display for StorageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "HTTP Status Code: {}", self.status_code)?; + + if let Some(request_id) = &self.request_id { + writeln!(f, "Request ID: {}", request_id)?; + } + + if let Some(error_code) = &self.error_code { + writeln!(f, "Storage Error Code: {}", error_code)?; + } + + if let Some(message) = &self.message { + writeln!(f, "Error Message: {}", message)?; + } + + if !self.additional_error_info.is_empty() { + writeln!(f, "\nAdditional Error Info:")?; + for (key, value) in &self.additional_error_info { + writeln!(f, "{}: {}", key, value)?; + } + } + + Ok(()) + } +} diff --git a/sdk/storage/azure_storage_blob/tests/blob_client.rs b/sdk/storage/azure_storage_blob/tests/blob_client.rs index 8ea91dfd66..a00c67c8c8 100644 --- a/sdk/storage/azure_storage_blob/tests/blob_client.rs +++ b/sdk/storage/azure_storage_blob/tests/blob_client.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use azure_core::{ + error::ErrorKind, http::{ClientOptions, RequestContent, StatusCode}, Bytes, }; @@ -13,11 +14,13 @@ use azure_storage_blob::{ BlobClientDownloadResultHeaders, BlobClientGetAccountInfoResultHeaders, BlobClientGetPropertiesOptions, BlobClientGetPropertiesResultHeaders, BlobClientSetMetadataOptions, BlobClientSetPropertiesOptions, BlobClientSetTierOptions, - BlockBlobClientUploadOptions, LeaseState, + BlockBlobClientUploadOptions, LeaseState, StorageError, }, BlobClient, BlobClientOptions, BlobContainerClient, BlobContainerClientOptions, }; -use azure_storage_blob_test::{create_test_blob, get_blob_name, get_container_client}; +use azure_storage_blob_test::{ + create_test_blob, get_blob_name, get_container_client, get_container_name, recorded_test_setup, +}; use futures::TryStreamExt; use std::{collections::HashMap, error::Error, time::Duration}; use tokio::time; @@ -492,6 +495,103 @@ async fn test_get_account_info(ctx: TestContext) -> Result<(), Box> { Ok(()) } +#[recorded::test] +async fn test_storage_error_model(ctx: TestContext) -> Result<(), Box> { + // Recording Setup + let recording = ctx.recording(); + let container_client = get_container_client(recording, true).await?; + let blob_client = container_client.blob_client(&get_blob_name(recording)); + + // Act + let response = blob_client.download(None).await; + let error_response = response.unwrap_err(); + + let error_kind = error_response.kind(); + assert!(matches!(error_kind, ErrorKind::HttpResponse { .. })); + + let storage_error: StorageError = error_response.try_into()?; + println!("{}", storage_error); + + Ok(()) +} + +#[recorded::test] +async fn test_bodyless_storage_error_model(ctx: TestContext) -> Result<(), Box> { + // Recording Setup + let recording = ctx.recording(); + let container_client = get_container_client(recording, true).await?; + let blob_client = container_client.blob_client(&get_blob_name(recording)); + + // Act + let response = blob_client.get_properties(None).await; + let error_response = response.unwrap_err(); + + let error_kind = error_response.kind(); + assert!(matches!(error_kind, ErrorKind::HttpResponse { .. })); + + let storage_error: StorageError = error_response.try_into()?; + + println!("{}", storage_error); + + Ok(()) +} + +#[recorded::test] +async fn test_additional_storage_info_parsing(ctx: TestContext) -> Result<(), Box> { + // Recording Setup + + let recording = ctx.recording(); + let container_name = get_container_name(recording); + let (options, endpoint) = recorded_test_setup(recording); + let container_client_options = BlobContainerClientOptions { + client_options: options.clone(), + ..Default::default() + }; + let container_client = BlobContainerClient::new( + &endpoint, + &container_name, + Some(recording.credential()), + Some(container_client_options), + )?; + let source_blob_client = container_client.blob_client(&get_blob_name(recording)); + create_test_blob(&source_blob_client, None, None).await?; + + let blob_client = container_client.blob_client(&get_blob_name(recording)); + + let blob_name = get_blob_name(recording); + let overwrite_blob_client = container_client.blob_client(&blob_name); + create_test_blob( + &overwrite_blob_client, + Some(RequestContent::from(b"overruled!".to_vec())), + None, + ) + .await?; + + // Inject an erroneous 'c' so we raise Copy Source Errors + let overwrite_url = format!( + "{}{}c/{}", + overwrite_blob_client.url(), + container_name, + blob_name + ); + + // Copy Source Error Scenario + let response = blob_client + .block_blob_client() + .upload_blob_from_url(overwrite_url.clone(), None) + .await; + // Assert + let error = response.unwrap_err(); + assert_eq!(StatusCode::NotFound, error.http_status().unwrap()); + + let storage_error: StorageError = error.try_into()?; + + println!("{}", storage_error); + + container_client.delete_container(None).await?; + Ok(()) +} + #[recorded::test] async fn test_encoding_edge_cases(ctx: TestContext) -> Result<(), Box> { // Recording Setup @@ -507,7 +607,6 @@ async fn test_encoding_edge_cases(ctx: TestContext) -> Result<(), Box // BlobClient Options let blob_client_options = BlobClientOptions { - client_options: client_options.clone(), ..Default::default() };