Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: better error reporting in graphql connector (#2525)
A customer is running into problems with `@graphql` - they get a "received invalid response from upstream server" when they try to use it, and there's no way to figure out what the invalid response was and therefore how to fix it. This PR updates the connector to print the response out to logs when it encounters an error. While I was there I made a few improvements to the response handling: - If there's an HTTP error status _and_ we can't decode the response, then we surface the HTTP status to users. - If there's an HTTP error but the request is otherwise parseable then we carry on as normal, with an additional error - I've also made response deserializing a bit more strict - if there's neither data or error that's a malformed response. We might need to do more work around this, but I want to get this out before the end of the day so this'll hopefully do?
- Loading branch information
Showing
2 changed files
with
201 additions
and
20 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
176 changes: 176 additions & 0 deletions
176
common/dynaql/src/registry/resolvers/graphql/response.rs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
use http::StatusCode; | ||
use serde_json::Value; | ||
|
||
use crate::ServerError; | ||
|
||
use super::Error; | ||
|
||
#[derive(PartialEq, Debug)] | ||
#[allow(clippy::module_name_repetitions)] // Honestly clippy, get fucked | ||
pub struct UpstreamResponse { | ||
pub data: serde_json::Value, | ||
pub errors: Vec<ServerError>, | ||
} | ||
|
||
impl UpstreamResponse { | ||
pub fn from_response_text( | ||
http_status: StatusCode, | ||
response_text_result: Result<String, impl Into<Error>>, | ||
) -> Result<Self, Error> { | ||
let response_text = response_text_result | ||
.map_err(|error| handle_error_after_response(http_status, error, None))?; | ||
|
||
serde_json::from_str::<UpstreamResponse>(&response_text) | ||
.map_err(|error| handle_error_after_response(http_status, error, Some(response_text))) | ||
} | ||
} | ||
|
||
impl<'de> serde::Deserialize<'de> for UpstreamResponse { | ||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | ||
where | ||
D: serde::Deserializer<'de>, | ||
{ | ||
use serde::de::Error; | ||
|
||
#[derive(serde::Deserialize)] | ||
struct ResponseDeser { | ||
/// The operation data (if the operation was successful) | ||
data: Option<Value>, | ||
|
||
/// Any errors that occurred as part of this operation | ||
errors: Option<Vec<ServerError>>, | ||
} | ||
|
||
let ResponseDeser { data, errors } = ResponseDeser::deserialize(deserializer)?; | ||
|
||
if data.is_none() && errors.is_none() { | ||
return Err(D::Error::custom( | ||
"neither data or errors were present in the upstream response", | ||
)); | ||
} | ||
|
||
Ok(UpstreamResponse { | ||
data: data.unwrap_or(Value::Null), | ||
errors: errors.unwrap_or_default(), | ||
}) | ||
} | ||
} | ||
|
||
/// If we encountered an error handling a GraphQL response then we want to log the | ||
/// body for debugging purposes. But we also want to check the HTTP status code | ||
/// and use that as our primary error if it's not success. | ||
fn handle_error_after_response( | ||
status: StatusCode, | ||
error: impl Into<Error>, | ||
#[allow(unused)] response_body: Option<String>, | ||
) -> Error { | ||
let error = error.into(); | ||
#[cfg(feature = "tracing_worker")] | ||
{ | ||
logworker::debug!("", "Error in GraphQL connector: {error}"); | ||
if let Some(text) = response_body { | ||
logworker::debug!("", "Response Body: {text}"); | ||
} | ||
} | ||
|
||
if !status.is_success() { | ||
return Error::HttpErrorResponse(status.as_u16()); | ||
} | ||
|
||
error | ||
} | ||
|
||
#[cfg(test)] | ||
mod tests { | ||
use rstest::rstest; | ||
use serde_json::json; | ||
|
||
use super::*; | ||
|
||
#[rstest] | ||
#[case(200, r#"{"data": {}}"#, UpstreamResponse { | ||
data: json!({}), | ||
errors: vec![] | ||
})] | ||
#[case(200, r#"{"errors": []}"#, UpstreamResponse { | ||
data: json!(null), | ||
errors: vec![] | ||
})] | ||
#[case(200, r#" | ||
{"errors": [{ | ||
"message": "oh no" | ||
}]} | ||
"#, UpstreamResponse { | ||
data: json!(null), | ||
errors: vec![ServerError { | ||
message: "oh no".into(), | ||
source: None, | ||
locations: vec![], | ||
path: vec![], | ||
extensions: None | ||
}] | ||
})] | ||
#[case(500, r#" | ||
{"errors": [{ | ||
"message": "oh no" | ||
}]} | ||
"#, UpstreamResponse { | ||
data: json!(null), | ||
errors: vec![ServerError { | ||
message: "oh no".into(), | ||
source: None, | ||
locations: vec![], | ||
path: vec![], | ||
extensions: None | ||
}] | ||
})] | ||
#[case(200, r#"{"data": {}}"#, UpstreamResponse { | ||
data: json!({}), | ||
errors: vec![] | ||
})] | ||
fn test_happy_paths( | ||
#[case] status_code: u16, | ||
#[case] text: &str, | ||
#[case] expected_response: UpstreamResponse, | ||
) { | ||
assert_eq!( | ||
UpstreamResponse::from_response_text( | ||
StatusCode::from_u16(status_code).unwrap(), | ||
Ok::<_, Error>(text.to_string()) | ||
) | ||
.unwrap(), | ||
expected_response | ||
); | ||
} | ||
|
||
#[test] | ||
fn test_error_paths() { | ||
let response = UpstreamResponse::from_response_text( | ||
StatusCode::from_u16(500).unwrap(), | ||
Ok::<_, Error>(r#"{"blah": {}}"#.into()), | ||
) | ||
.unwrap_err(); | ||
assert!( | ||
matches!(response, Error::HttpErrorResponse(500)), | ||
"`{response}` does not match" | ||
); | ||
let response = UpstreamResponse::from_response_text( | ||
StatusCode::from_u16(200).unwrap(), | ||
Ok::<_, Error>(r#"{"blah": {}}"#.into()), | ||
) | ||
.unwrap_err(); | ||
assert!( | ||
matches!(response, Error::JsonDecodeError(_)), | ||
"{response} does not match" | ||
); | ||
let response = UpstreamResponse::from_response_text( | ||
StatusCode::from_u16(200).unwrap(), | ||
Ok::<_, Error>(r#"{"errors": "bad"}"#.into()), | ||
) | ||
.unwrap_err(); | ||
assert!( | ||
matches!(response, Error::JsonDecodeError(_)), | ||
"{response} does not match" | ||
); | ||
} | ||
} |