Skip to content

Commit b5a20f0

Browse files
Forte11Cubathesimplekid
authored andcommitted
fix(wallet): surface HTTP status code in Async transport
1 parent 35d4fa1 commit b5a20f0

2 files changed

Lines changed: 130 additions & 7 deletions

File tree

crates/cdk/src/wallet/mint_connector/transport.rs

Lines changed: 127 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,26 @@ impl Transport for Async {
170170
let response = request
171171
.send()
172172
.await
173-
.map_err(|e| Error::HttpError(None, e.to_string()))?
173+
.map_err(|e| Error::HttpError(None, e.to_string()))?;
174+
175+
let status = response.status();
176+
let body = response
174177
.text()
175178
.await
176179
.map_err(|e| Error::HttpError(None, e.to_string()))?;
177180

178-
serde_json::from_str::<R>(&response).map_err(|err| {
181+
if !(200..300).contains(&status) {
182+
// Attempt to strictly parse as a Cashu ErrorResponse (requires the 'code' field).
183+
// This avoids laundering generic errors into ErrorCode::Unknown(999).
184+
if let Ok(err_resp) = serde_json::from_str::<ErrorResponse>(&body) {
185+
return Err(err_resp.into());
186+
}
187+
return Err(Error::HttpError(Some(status), body));
188+
}
189+
190+
serde_json::from_str::<R>(&body).map_err(|err| {
179191
tracing::warn!("Http Response error: {}", err);
180-
match ErrorResponse::from_json(&response) {
192+
match ErrorResponse::from_json(&body) {
181193
Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
182194
Err(err) => err.into(),
183195
}
@@ -206,15 +218,24 @@ impl Transport for Async {
206218
.await
207219
.map_err(|e| Error::HttpError(None, e.to_string()))?;
208220

209-
let response = response
221+
let status = response.status();
222+
let body = response
210223
.text()
211224
.await
212225
.map_err(|e| Error::HttpError(None, e.to_string()))?;
213226

214-
serde_json::from_str::<R>(&response).map_err(|err| {
227+
if !(200..300).contains(&status) {
228+
// Attempt to strictly parse as a Cashu ErrorResponse (requires the 'code' field).
229+
// This avoids laundering generic errors into ErrorCode::Unknown(999).
230+
if let Ok(err_resp) = serde_json::from_str::<ErrorResponse>(&body) {
231+
return Err(err_resp.into());
232+
}
233+
return Err(Error::HttpError(Some(status), body));
234+
}
235+
236+
serde_json::from_str::<R>(&body).map_err(|err| {
215237
tracing::warn!("Http Response error: {}", err);
216-
tracing::debug!("{:?}", response);
217-
match ErrorResponse::from_json(&response) {
238+
match ErrorResponse::from_json(&body) {
218239
Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
219240
Err(err) => err.into(),
220241
}
@@ -224,3 +245,102 @@ impl Transport for Async {
224245

225246
#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
226247
pub mod tor_transport;
248+
249+
#[cfg(all(test, not(target_arch = "wasm32")))]
250+
mod tests {
251+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
252+
use tokio::net::TcpListener;
253+
254+
use super::*;
255+
256+
/// Spawn a one-shot HTTP server that replies with the given status line and
257+
/// body to the first request it receives. Returns the URL to connect to.
258+
async fn spawn_canned_response(status_line: &'static str, body: &'static str) -> Url {
259+
let listener = TcpListener::bind("127.0.0.1:0")
260+
.await
261+
.expect("bind loopback");
262+
let addr = listener.local_addr().expect("local addr");
263+
264+
let response = format!(
265+
"{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
266+
body.len()
267+
);
268+
269+
tokio::spawn(async move {
270+
if let Ok((mut socket, _)) = listener.accept().await {
271+
let mut buf = [0u8; 2048];
272+
let _ = socket.read(&mut buf).await;
273+
let _ = socket.write_all(response.as_bytes()).await;
274+
let _ = socket.shutdown().await;
275+
}
276+
});
277+
278+
Url::parse(&format!("http://{}/", addr)).expect("valid url")
279+
}
280+
281+
/// Regression test for https://github.com/cashubtc/cdk/issues/1914
282+
///
283+
/// A 429 response with a body that does not match the Cashu ErrorResponse
284+
/// schema (e.g. nutshell's `{"detail":"Rate limit exceeded."}`) must surface
285+
/// as `Error::HttpError(Some(429), body)` instead of being laundered into
286+
/// `ErrorCode::Unknown(999)` by `ErrorResponse::from_value`.
287+
#[tokio::test]
288+
async fn http_post_surfaces_429_status() {
289+
let url = spawn_canned_response(
290+
"HTTP/1.1 429 Too Many Requests",
291+
r#"{"detail":"Rate limit exceeded."}"#,
292+
)
293+
.await;
294+
295+
let transport = Async::default();
296+
let result: Result<serde_json::Value, Error> =
297+
transport.http_post(url, None, &serde_json::json!({})).await;
298+
299+
match result {
300+
Err(Error::HttpError(Some(429), body)) => {
301+
assert!(
302+
body.contains("Rate limit exceeded"),
303+
"body should be preserved, got: {body}"
304+
);
305+
}
306+
other => panic!("expected HttpError(Some(429), _), got {other:?}"),
307+
}
308+
}
309+
310+
#[tokio::test]
311+
async fn http_get_surfaces_429_status() {
312+
let url = spawn_canned_response(
313+
"HTTP/1.1 429 Too Many Requests",
314+
r#"{"detail":"Rate limit exceeded."}"#,
315+
)
316+
.await;
317+
318+
let transport = Async::default();
319+
let result: Result<serde_json::Value, Error> = transport.http_get(url, None).await;
320+
321+
match result {
322+
Err(Error::HttpError(Some(429), body)) => {
323+
assert!(body.contains("Rate limit exceeded"));
324+
}
325+
other => panic!("expected HttpError(Some(429), _), got {other:?}"),
326+
}
327+
}
328+
329+
#[tokio::test]
330+
async fn http_post_surfaces_400_mint_error() {
331+
let url = spawn_canned_response(
332+
"HTTP/1.1 400 Bad Request",
333+
r#"{"code": 1000, "detail": "Token already spent"}"#,
334+
)
335+
.await;
336+
337+
let transport = Async::default();
338+
let result: Result<serde_json::Value, Error> =
339+
transport.http_post(url, None, &serde_json::json!({})).await;
340+
341+
match result {
342+
Err(Error::UnknownErrorResponse(msg)) if msg.contains("1000") => {}
343+
other => panic!("expected Error::UnknownErrorResponse containing 1000, got {other:?}"),
344+
}
345+
}
346+
}

crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ impl TorAsync {
207207

208208
if !(200..300).contains(&status) {
209209
let text = String::from_utf8_lossy(&bytes).to_string();
210+
if let Ok(err_resp) = serde_json::from_str::<ErrorResponse>(&text) {
211+
return Err(err_resp.into());
212+
}
210213
return Err(Error::HttpError(Some(status), text));
211214
}
212215

0 commit comments

Comments
 (0)