@@ -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" ) ) ) ]
226247pub 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 \n Content-Type: application/json\r \n Content-Length: {}\r \n Connection: 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+ }
0 commit comments