From d29901bd9bccce61d5472eb9f201b28424f9c373 Mon Sep 17 00:00:00 2001 From: Kyle Rader Date: Tue, 3 Mar 2026 00:46:55 -0800 Subject: [PATCH 1/2] fix(fullstack): honor HTTP status in tuple FromResponse impls The `(A, B)` and `(A, B, C)` `FromResponse` implementations ignored the HTTP status code and always tried to decode the body as the success type. When a server function returned a tuple such as `(SetHeader, Json)` with a non-2xx status and an `ErrorPayload`, the body was deserialized as `T`, surfacing a `RequestError::Decode(..)` instead of the intended `ServerFnError::ServerError { .. }`. Check `status.is_success()` first and, on failure, parse the `ErrorPayload` and return `ServerFnError::ServerError { message, code, details }`, matching the single-type `FromResponse` behavior. This lets clients recover typed errors from `details` for tuple responses just as they already can for non-tuple responses. --- packages/fullstack/src/request.rs | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/fullstack/src/request.rs b/packages/fullstack/src/request.rs index db2527cb71..0ddb8f90a5 100644 --- a/packages/fullstack/src/request.rs +++ b/packages/fullstack/src/request.rs @@ -83,6 +83,22 @@ where { fn from_response(res: ClientResponse) -> impl Future> { async move { + let status = res.status(); + + if !status.is_success() { + let ErrorPayload:: { + message, + code, + data, + } = res.json().await?; + + return Err(ServerFnError::ServerError { + message, + code, + details: data, + }); + } + let mut parts = res.make_parts(); let a = A::from_response_parts(&mut parts)?; let b = B::from_response(res).await?; @@ -99,6 +115,22 @@ where { fn from_response(res: ClientResponse) -> impl Future> { async move { + let status = res.status(); + + if !status.is_success() { + let ErrorPayload:: { + message, + code, + data, + } = res.json().await?; + + return Err(ServerFnError::ServerError { + message, + code, + details: data, + }); + } + let mut parts = res.make_parts(); let a = A::from_response_parts(&mut parts)?; let b = B::from_response_parts(&mut parts)?; From 627326d42b1515d575217f66d3f9356ef8ae03ce Mon Sep 17 00:00:00 2001 From: Kyle Rader Date: Thu, 18 Jun 2026 17:08:04 -0700 Subject: [PATCH 2/2] test(fullstack): cover tuple FromResponse status handling Add tests for the `(A, B)` and `(A, B, C)` `FromResponse` impls covering both the 2xx success path and the non-2xx `ErrorPayload` path, mirroring the existing single-type `FromResponse` tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/fullstack/src/request.rs | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/fullstack/src/request.rs b/packages/fullstack/src/request.rs index 0ddb8f90a5..c24f9f71e4 100644 --- a/packages/fullstack/src/request.rs +++ b/packages/fullstack/src/request.rs @@ -325,4 +325,76 @@ mod test { ); }); } + + #[test] + fn tuple_two_path_decodes_ok_on_2xx() { + futures::executor::block_on(async { + let response = build_response(200, "".to_string()); + let result = <(TestFromResponse, TestFromResponse)>::from_response(response).await; + assert!( + result.is_ok(), + "expected Ok(..) for HTTP 200 success case, got: {:?}", + result + ); + }); + } + + #[test] + fn tuple_two_parses_error_payload_on_http_error() { + futures::executor::block_on(async { + let body = r#"{ + "message": "qwerty", + "code": 400 + }"#; + let response = build_response(400, body.to_string()); + let result = <(TestFromResponse, TestFromResponse)>::from_response(response).await; + assert!(result.is_err(), "expected Err(..) for HTTP 400 failed case"); + assert_eq!( + result.unwrap_err(), + ServerFnError::ServerError { + message: "qwerty".to_string(), + code: 400, + details: None + } + ); + }); + } + + #[test] + fn tuple_three_path_decodes_ok_on_2xx() { + futures::executor::block_on(async { + let response = build_response(200, "".to_string()); + let result = + <(TestFromResponse, TestFromResponse, TestFromResponse)>::from_response(response) + .await; + assert!( + result.is_ok(), + "expected Ok(..) for HTTP 200 success case, got: {:?}", + result + ); + }); + } + + #[test] + fn tuple_three_parses_error_payload_on_http_error() { + futures::executor::block_on(async { + let body = r#"{ + "message": "qwerty", + "code": 400 + }"#; + let response = build_response(400, body.to_string()); + let result = + <(TestFromResponse, TestFromResponse, TestFromResponse)>::from_response(response) + .await; + assert!(result.is_err(), "expected Err(..) for HTTP 400 failed case"); + assert_eq!( + result.unwrap_err(), + ServerFnError::ServerError { + message: "qwerty".to_string(), + code: 400, + details: None + } + ); + }); + } }