Skip to content

Commit 9d7f09a

Browse files
authored
Always attach RawResponse to ErrorKind::HttpResponse (Azure#3039)
1 parent d2cd0db commit 9d7f09a

File tree

9 files changed

+199
-56
lines changed

9 files changed

+199
-56
lines changed

sdk/core/azure_core/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
### Breaking Changes
1010

1111
- Changed `ClientOptions::retry` from `Option<RetryOptions>` to `RetryOptions`.
12+
- Changed `RawResponse::json()` from `async` to synchronous function. The body was already buffered.
13+
- Changed `RawResponse::xml()` from `async` to synchronous function. The body was already buffered.
14+
- Removed `ErrorKind::http_response()`. Construct an `ErrorResponse::HttpResponse` variant instead.
1215
- Removed several unreferenced HTTP headers and accessor structures for those headers.
1316
- Renamed `TransportOptions` to `Transport`.
1417
- Renamed `TransportOptions::new_custom_policy()` to `Transport::with_policy()`.
@@ -24,6 +27,8 @@
2427

2528
### Bugs Fixed
2629

30+
- `ErrorKind::HttpResponse { raw_response, .. }` may have been incorrectly `None`.
31+
2732
### Other Changes
2833

2934
## 0.28.0 (2025-09-11)

sdk/core/azure_core/src/error/error_response.rs

Lines changed: 65 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -148,50 +148,65 @@ pub async fn check_success(
148148
return Ok(response);
149149
}
150150

151-
let response = response.try_into_raw_response().await?;
151+
let raw_response = response.try_into_raw_response().await?;
152152

153153
// If there's no body, we can't extract any more information.
154-
if response.body().is_empty() {
155-
let code = response.headers().get_optional_str(&ERROR_CODE);
156-
let error_kind = ErrorKind::http_response(status, code.map(str::to_owned));
154+
if raw_response.body().is_empty() {
155+
let error_code = raw_response
156+
.headers()
157+
.get_optional_str(&ERROR_CODE)
158+
.map(str::to_owned);
159+
let error_kind = ErrorKind::HttpResponse {
160+
status,
161+
error_code,
162+
raw_response: Some(Box::new(raw_response)),
163+
};
157164
return Err(Error::with_message(error_kind, status.to_string()));
158165
}
159166
let internal_response =
160-
serde_json::de::from_slice::<ErrorResponseInternal>(response.body()).map_err(Error::from);
167+
serde_json::de::from_slice::<ErrorResponseInternal>(raw_response.body())
168+
.map_err(Error::from);
161169

162170
let internal_response = match internal_response {
163171
Ok(r) => r,
164172
Err(_) => {
165173
// If we can't parse the body, return a generic error with the status code and body
166-
let code = response.headers().get_optional_str(&ERROR_CODE);
167-
let error_kind = ErrorKind::http_response(
174+
let error_code = raw_response
175+
.headers()
176+
.get_optional_str(&ERROR_CODE)
177+
.map_or_else(|| raw_response.status().to_string(), str::to_owned);
178+
let message = str::from_utf8(raw_response.body())
179+
.unwrap_or("(invalid utf-8 in body)")
180+
.to_string();
181+
let error_kind = ErrorKind::HttpResponse {
168182
status,
169-
Some(code.map_or_else(|| response.status().to_string(), str::to_owned)),
170-
);
183+
error_code: Some(error_code),
184+
raw_response: Some(Box::new(raw_response)),
185+
};
171186
return Err(Error::with_message(
172187
error_kind,
173-
format!(
174-
"{}: {}",
175-
status,
176-
str::from_utf8(response.body()).unwrap_or("(invalid utf-8 in body)")
177-
),
188+
format!("{}: {}", status, message),
178189
));
179190
}
180191
};
181192

182193
// We give priority to the error code in the header, and try the body version if it's not present.
183-
let code = response.headers().get_optional_str(&ERROR_CODE);
184-
let code = code.or(internal_response.error.code);
185-
186-
let error_kind = ErrorKind::http_response(status, code.map(str::to_owned));
194+
let error_code = raw_response
195+
.headers()
196+
.get_optional_str(&ERROR_CODE)
197+
.or(internal_response.error.code)
198+
.map(str::to_owned);
199+
let message = internal_response
200+
.error
201+
.message
202+
.map_or_else(|| status.to_string(), str::to_owned);
203+
let error_kind = ErrorKind::HttpResponse {
204+
status,
205+
error_code,
206+
raw_response: Some(Box::new(raw_response)),
207+
};
187208

188-
Err(Error::with_message(
189-
error_kind,
190-
internal_response
191-
.error
192-
.message
193-
.map_or_else(|| status.to_string(), |m| m.to_owned()),
194-
))
209+
Err(Error::with_message(error_kind, message))
195210
}
196211

197212
#[cfg(test)]
@@ -217,7 +232,7 @@ mod tests {
217232
ErrorKind::HttpResponse {
218233
status: StatusCode::ImATeapot,
219234
error_code,
220-
raw_response: None
235+
raw_response: Some(_),
221236
}
222237
if error_code.as_deref() == Some("teapot")
223238
));
@@ -242,7 +257,7 @@ mod tests {
242257
ErrorKind::HttpResponse {
243258
status: StatusCode::ImATeapot,
244259
error_code,
245-
raw_response: None
260+
raw_response: Some(_),
246261
}
247262
if error_code.as_deref() == Some("teapot")
248263
));
@@ -292,7 +307,7 @@ mod tests {
292307
ErrorKind::HttpResponse {
293308
status: StatusCode::Ok,
294309
error_code,
295-
raw_response: None
310+
raw_response: Some(_),
296311
}
297312
if error_code.as_deref() == Some("teapot")
298313
));
@@ -306,14 +321,15 @@ mod tests {
306321

307322
let err = check_success(response, None).await.unwrap_err();
308323
let kind = err.kind();
309-
assert_eq!(
310-
*kind,
324+
assert!(matches!(
325+
kind,
311326
ErrorKind::HttpResponse {
312327
status: StatusCode::ImATeapot,
313-
error_code: Some("testError".to_string()),
314-
raw_response: None
328+
error_code,
329+
raw_response: Some(_),
315330
}
316-
);
331+
if error_code.as_deref() == Some("testError")
332+
));
317333
}
318334

319335
#[tokio::test]
@@ -327,16 +343,23 @@ mod tests {
327343
);
328344

329345
let err = check_success(response, None).await.unwrap_err();
330-
let kind = err.kind();
331-
assert_eq!(
332-
*kind,
333-
ErrorKind::HttpResponse {
334-
status: StatusCode::ImATeapot,
335-
error_code: Some("testError".to_string()),
336-
raw_response: None
337-
}
338-
);
346+
let ErrorKind::HttpResponse {
347+
status,
348+
error_code: Some(error_code),
349+
raw_response: Some(raw_response),
350+
} = err.kind()
351+
else {
352+
panic!("expected ErrorKind::HttpResponse");
353+
};
354+
339355
assert!(err.to_string().contains(r#"{"json": "error"}"#));
356+
assert_eq!(status, &StatusCode::ImATeapot);
357+
assert_eq!(error_code, "testError");
358+
assert_eq!(raw_response.status(), StatusCode::ImATeapot);
359+
assert_eq!(raw_response.headers().iter().count(), 1);
360+
assert!(
361+
matches!(str::from_utf8(raw_response.body()), Ok(body) if body == r#"{"json": "error"}"#)
362+
);
340363
}
341364

342365
#[test]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
use azure_core::{
5+
error::{ErrorKind, ErrorResponse},
6+
http::{headers::Headers, BufResponse, ClientOptions, HttpClient, StatusCode, Transport},
7+
Bytes,
8+
};
9+
use azure_core_test::{credentials::MockCredential, http::MockHttpClient};
10+
use azure_security_keyvault_secrets::{SecretClient, SecretClientOptions};
11+
use futures::FutureExt as _;
12+
use std::{
13+
str,
14+
sync::{Arc, LazyLock},
15+
};
16+
17+
#[tokio::test]
18+
async fn deconstruct_raw_response() -> Result<(), Box<dyn std::error::Error>> {
19+
let options = SecretClientOptions {
20+
client_options: ClientOptions {
21+
transport: Some(Transport::new(TRANSPORT.clone())),
22+
..Default::default()
23+
},
24+
..Default::default()
25+
};
26+
let client = SecretClient::new(
27+
"https://my-vault.vault.azure.net",
28+
MockCredential::new()?,
29+
Some(options),
30+
)?;
31+
32+
let err = client.get_secret("secret-name", None).await.unwrap_err();
33+
34+
// Deconstruct the HttpResponse.
35+
let ErrorKind::HttpResponse {
36+
status,
37+
error_code,
38+
raw_response,
39+
} = err.kind()
40+
else {
41+
panic!("expected ErrorKind::HttpResponse");
42+
};
43+
44+
assert_eq!(status, &StatusCode::BadRequest);
45+
assert_eq!(error_code.as_deref(), Some("BadParameter"));
46+
assert!(
47+
matches!(raw_response, Some(r) if str::from_utf8(r.body())?.contains("Unknown parameter"))
48+
);
49+
50+
Ok(())
51+
}
52+
53+
#[tokio::test]
54+
async fn deserialize_error_response() -> Result<(), Box<dyn std::error::Error>> {
55+
let options = SecretClientOptions {
56+
client_options: ClientOptions {
57+
transport: Some(Transport::new(TRANSPORT.clone())),
58+
..Default::default()
59+
},
60+
..Default::default()
61+
};
62+
let client = SecretClient::new(
63+
"https://my-vault.vault.azure.net",
64+
MockCredential::new()?,
65+
Some(options),
66+
)?;
67+
68+
let err = client.get_secret("secret-name", None).await.unwrap_err();
69+
70+
// Deconstruct the HttpResponse
71+
let ErrorKind::HttpResponse {
72+
status,
73+
error_code,
74+
raw_response: Some(raw_response),
75+
} = err.kind()
76+
else {
77+
panic!("expected ErrorKind::HttpResponse");
78+
};
79+
80+
// Deserialize the RawResponse
81+
let error_response: ErrorResponse = raw_response.clone().json()?;
82+
83+
assert_eq!(status, &StatusCode::BadRequest);
84+
assert_eq!(error_code.as_deref(), Some("BadParameter"));
85+
let Some(error) = error_response.error else {
86+
panic!("expected error");
87+
};
88+
assert_eq!(error.details.len(), 1);
89+
assert_eq!(error.details[0].code.as_deref(), Some("BadParameter"));
90+
assert_eq!(error.details[0].target.as_deref(), Some("foo"));
91+
92+
Ok(())
93+
}
94+
95+
static TRANSPORT: LazyLock<Arc<dyn HttpClient>> = LazyLock::new(|| {
96+
Arc::new(MockHttpClient::new(|_| {
97+
async {
98+
Ok(BufResponse::from_bytes(
99+
StatusCode::BadRequest,
100+
Headers::new(),
101+
Bytes::from_static(
102+
br#"{
103+
"error": {
104+
"code": "BadParameter",
105+
"message": "Unknown parameter",
106+
"details": [
107+
{
108+
"code": "BadParameter",
109+
"target": "foo"
110+
}
111+
]
112+
}
113+
}"#,
114+
),
115+
))
116+
}
117+
.boxed()
118+
}))
119+
});

sdk/identity/azure_identity/src/azure_pipelines_credential.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ impl ClientAssertion for Client {
157157

158158
return Err(
159159
azure_core::Error::with_message(
160-
ErrorKind::http_response(status_code, Some(status_code.canonical_reason().to_string())),
160+
ErrorKind::HttpResponse { status: status_code, error_code: Some(status_code.canonical_reason().to_string()), raw_response: None },
161161
format!("{status_code} response from the OIDC endpoint. Check service connection ID and pipeline configuration. {err_headers}"),
162162
)
163163
);

sdk/typespec/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
### Breaking Changes
1010

11+
- Changed `RawResponse::json()` from `async` to synchronous function. The body was already buffered.
12+
- Changed `RawResponse::xml()` from `async` to synchronous function. The body was already buffered.
13+
- Removed `ErrorKind::http_response()`. Construct an `ErrorResponse::HttpResponse` variant instead.
1114
- Renamed a number of construction functions for `Error` to align with [guidelines](https://azure.github.io/azure-sdk/rust_introduction.html)"
1215
- Renamed `Error::full()` to `Error::with_error()`.
1316
- Renamed `Error::with_message()` to `Error::with_message_fn()`.

sdk/typespec/src/error/mod.rs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,6 @@ impl ErrorKind {
4848
context: Repr::Simple(self),
4949
}
5050
}
51-
52-
/// Create an `ErrorKind` from an HTTP response.
53-
#[cfg(feature = "http")]
54-
pub fn http_response(status: StatusCode, error_code: Option<String>) -> Self {
55-
Self::HttpResponse {
56-
status,
57-
error_code,
58-
raw_response: None,
59-
}
60-
}
6151
}
6252

6353
impl Display for ErrorKind {

sdk/typespec/src/http/response.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ impl RawResponse {
5050

5151
/// Deserialize the JSON stream into type `T`.
5252
#[cfg(feature = "json")]
53-
pub async fn json<T>(self) -> crate::Result<T>
53+
pub fn json<T>(self) -> crate::Result<T>
5454
where
5555
T: DeserializeOwned,
5656
{
@@ -59,7 +59,7 @@ impl RawResponse {
5959

6060
/// Deserialize the XML stream into type `T`.
6161
#[cfg(feature = "xml")]
62-
pub async fn xml<T>(self) -> crate::Result<T>
62+
pub fn xml<T>(self) -> crate::Result<T>
6363
where
6464
T: DeserializeOwned,
6565
{

sdk/typespec/typespec_client_core/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
### Breaking Changes
88

99
- Changed `ClientOptions::retry` from `Option<RetryOptions>` to `RetryOptions`.
10+
- Changed `RawResponse::json()` from `async` to synchronous function. The body was already buffered.
11+
- Changed `RawResponse::xml()` from `async` to synchronous function. The body was already buffered.
12+
- Removed `ErrorKind::http_response()`. Construct an `ErrorResponse::HttpResponse` variant instead.
1013
- Renamed `TransportOptions` to `Transport`.
1114
- Renamed `TransportOptions::new_custom_policy()` to `Transport::with_policy()`.
1215

sdk/typespec/typespec_client_core/src/http/response.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ mod tests {
411411
.iter()
412412
.any(|(k, v)| k.as_str() == "x-ms-error" && v.as_str() == "BadParameter"));
413413

414-
let err: ErrorResponse = err.json().await.expect("convert to ErrorResponse");
414+
let err: ErrorResponse = err.json().expect("convert to ErrorResponse");
415415
assert_eq!(err.code, Some("BadParameter".into()));
416416
assert_eq!(err.message, Some("Bad parameter".into()));
417417
}

0 commit comments

Comments
 (0)