From 590aed3751ba61e0ccf543b962576a3dcb89cc39 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 6 Mar 2026 10:45:02 -0500 Subject: [PATCH] fix(response_at_path): non-greedy application of errors when current_dir wildcarded (#8962) (cherry picked from commit 04d42e2ea40428321e1209657c5c3b9e2f4fc996) # Conflicts: # apollo-router/tests/integration/snapshots/integration_tests__integration__demand_control__requests_exceeding_one_subgraph_cost_are_accepted@federated_ships_required.snap --- ..._non_greedy_path_application_for_errors.md | 7 + apollo-router/src/query_planner/fetch.rs | 812 +++++++++++++++++- .../src/services/execution/service.rs | 314 ++++++- ...eferred_fragment_bounds_nullability-2.snap | 10 +- ...r_paths__multi_level_response_failure.snap | 51 +- ...or_paths__nested_response_failure_404.snap | 84 +- ...hs__nested_response_failure_malformed.snap | 36 +- ...ond_level_response_failure_empty_path.snap | 13 +- ...cond_level_response_failure_malformed.snap | 15 +- ...ror_selector__nested_response_failure.snap | 13 +- ...are_accepted@federated_ships_required.snap | 57 ++ 11 files changed, 1197 insertions(+), 215 deletions(-) create mode 100644 .changesets/fix_aaron_non_greedy_path_application_for_errors.md create mode 100644 apollo-router/tests/integration/snapshots/integration_tests__integration__demand_control__requests_exceeding_one_subgraph_cost_are_accepted@federated_ships_required.snap diff --git a/.changesets/fix_aaron_non_greedy_path_application_for_errors.md b/.changesets/fix_aaron_non_greedy_path_application_for_errors.md new file mode 100644 index 0000000000..2f89bac9ca --- /dev/null +++ b/.changesets/fix_aaron_non_greedy_path_application_for_errors.md @@ -0,0 +1,7 @@ +### Don't apply entity-less errors from subgraphs greedily + +When making an entity resolution, if for some reason we failed to get an entity (eg, the path was malformed from the subgraph), we'd apply any errors to _everything_ in the list of entities we expected. So, for example, if we were to get 2000 entities and instead received 2000 errors, we'd apply each error to every entity we expected to exist. That causes an explosion of errors and leads to significant memory allocations that almost certainly lead to OOMKills. + +Now, when we don't know where an error should be applied, we apply it to the most immediate parent of the targeted entity (so in the case of a list of users, it'd apply to the list itself rather than to each index of that list). + +By [@aaronArinder](https://github.com/aaronArinder) in https://github.com/apollographql/router/pull/8962 diff --git a/apollo-router/src/query_planner/fetch.rs b/apollo-router/src/query_planner/fetch.rs index a6a341496f..7ef3cb75e2 100644 --- a/apollo-router/src/query_planner/fetch.rs +++ b/apollo-router/src/query_planner/fetch.rs @@ -332,6 +332,14 @@ impl FetchNode { } } + /// Maps a subgraph's response into what can be merged in the overall supergraph response. It + /// does this by making sure both the data and errors from a subgraph's response can be plugged + /// into the right slots for the supergraph response, and it does that by a bit of path + /// handling and manipulation + /// + /// Importantly, it makes a decision about entity-less errors. When we have such an error, it's + /// unclear where it should be applied so we apply them to the most immediate parent of the + /// current_dir #[instrument(skip_all, level = "debug", name = "response_insert")] pub(crate) fn response_at_path<'a>( &'a self, @@ -346,6 +354,40 @@ impl FetchNode { None, )]); + // the fallback_dir is the immediate parent of the current_dir when the current_dir is + // wildcarded (ie, @, which is PathElement::Flatten--conceptually, flattening is the + // application of some behavior to every index in an array) + // + // this is important because sometimes we get paths that don't start with _entities and + // we're unsure where to apply any errors returned by subgraphs; previously we applied + // them to the wildcarded current_dir, which means we applied them to _everything_ at + // that path; if we were handling an array, we'd apply each error to every element of + // that array + // + // you can guess what that might do: if you have a query returning some large number of + // errors and every one of those errors is applied to every index represented in the + // array of data that we're building for the response, you get an explosion of errors + // (and downstream of this function, an explosion of memory allocations when we clone + // each error in FlattenNode--worse, we add them to a vec and that vec will get resized + // when its capacity is hit, creating a significant memory burden that almost certainly + // leads to an OOMKill) + // + // the moral of the story is that we need to be very careful in this function when we + // apply wildcards for paths + let fallback_dir = { + // if we have a wildcard (@, Flatten) + let pos = current_dir + .0 + .iter() + .position(|e| matches!(e, json_ext::PathElement::Flatten(_))); + // then take the most immediate parent + match pos { + Some(i) => Path(current_dir.0[..i].to_vec()), + // otherwise, use the current_dir + None => current_dir.clone(), + } + }; + let mut errors: Vec = vec![]; for mut error in response.errors { // the locations correspond to the subgraph query and cannot be linked to locations @@ -380,16 +422,16 @@ impl FetchNode { } } _ => { - error.path = Some(current_dir.clone()); + error.path = Some(fallback_dir.clone()); errors.push(error) } } } else { - error.path = Some(current_dir.clone()); + error.path = Some(fallback_dir.clone()); errors.push(error); } } else { - error.path = Some(current_dir.clone()); + error.path = Some(fallback_dir.clone()); errors.push(error); } } @@ -529,3 +571,767 @@ impl FetchNode { )); } } + +#[cfg(test)] +mod tests { + use apollo_compiler::name; + use apollo_federation::query_plan::requires_selection; + use apollo_federation::query_plan::serializable_document::SerializableDocument; + use rstest::rstest; + use serde_json_bytes::json; + + use super::*; + use crate::Configuration; + + fn test_schema() -> Schema { + let sdl = r#" + schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/join/v0.3", for: EXECUTION) + { + query: Query + } + directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) repeatable on SCHEMA + directive @join__type(graph: join__Graph!, key: join__FieldSet, extension: Boolean! = false, resolvable: Boolean! = true, isInterfaceObject: Boolean! = false) repeatable on OBJECT | INTERFACE | UNION | ENUM | INPUT_OBJECT | SCALAR + directive @join__graph(name: String!, url: String!) on ENUM_VALUE + + scalar link__Import + scalar join__FieldSet + + enum link__Purpose { SECURITY EXECUTION } + + enum join__Graph { + TEST @join__graph(name: "test", url: "http://localhost:4001/graphql") + } + + type Query { + me: String + } + "#; + Schema::parse(sdl, &Configuration::default()).unwrap() + } + + fn make_fetch_node(requires: Vec) -> FetchNode { + FetchNode { + service_name: "test".into(), + requires, + variable_usages: vec![], + operation: SerializableDocument::from_string("{ me }"), + operation_name: None, + operation_kind: OperationKind::Query, + id: None, + input_rewrites: None, + output_rewrites: None, + context_rewrites: None, + schema_aware_hash: Default::default(), + authorization: Default::default(), + } + } + + fn make_requires() -> Vec { + vec![requires_selection::Selection::InlineFragment( + requires_selection::InlineFragment { + type_condition: Some(name!("T")), + selections: vec![requires_selection::Selection::Field( + requires_selection::Field { + alias: None, + name: name!("id"), + selections: Vec::new(), + }, + )], + }, + )] + } + + fn key(name: &str) -> json_ext::PathElement { + json_ext::PathElement::Key(name.to_string(), None) + } + + fn index(i: usize) -> json_ext::PathElement { + json_ext::PathElement::Index(i) + } + + fn flatten() -> json_ext::PathElement { + json_ext::PathElement::Flatten(None) + } + + fn make_error(path: Option) -> graphql::Error { + match path { + Some(p) => graphql::Error::builder().message("err").path(p).build(), + None => graphql::Error::builder().message("err").build(), + } + } + + #[rstest] + #[case::single_key( + vec![key("topLevel")], + Some(json!({"field": "value"})), + json!({"topLevel": {"field": "value"}}) + )] + #[case::no_data( + vec![key("topLevel")], + None, + json!({"topLevel": null}) + )] + #[case::empty_current_dir( + vec![], + Some(json!({"me": "hello"})), + json!({"me": "hello"}) + )] + #[case::deep_nesting( + vec![key("a"), key("b"), key("c"), key("d")], + Some(json!({"value": 42})), + json!({"a": {"b": {"c": {"d": {"value": 42}}}}}) + )] + fn root_fetch_data_wrapping( + #[case] dir_elements: Vec, + #[case] data: Option, + #[case] expected: Value, + ) { + let schema = test_schema(); + let node = make_fetch_node(vec![]); + let current_dir = Path(dir_elements); + let response = graphql::Response { + data, + ..Default::default() + }; + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert!(errors.is_empty()); + assert_eq!(value, expected); + } + + #[rstest] + #[case::prepends_current_dir( + vec![key("top"), key("nested")], + Some(Path(vec![key("field")])), + Path(vec![key("top"), key("nested"), key("field")]) + )] + #[case::no_error_path_uses_current_dir( + vec![key("top")], + None, + Path(vec![key("top")]) + )] + #[case::trailing_flatten_stripped( + vec![key("list"), flatten()], + Some(Path(vec![key("name")])), + Path(vec![key("list"), key("name")]) + )] + #[case::no_error_path_keeps_flatten( + vec![key("list"), flatten()], + None, + Path(vec![key("list"), flatten()]) + )] + #[case::index_in_error_path( + vec![key("items")], + Some(Path(vec![index(2), key("name")])), + Path(vec![key("items"), index(2), key("name")]) + )] + #[case::flatten_mid_path_not_stripped( + vec![key("a"), flatten(), key("b")], + Some(Path(vec![key("c")])), + Path(vec![key("a"), flatten(), key("b"), key("c")]) + )] + fn root_fetch_error_path( + #[case] dir_elements: Vec, + #[case] error_path: Option, + #[case] expected_path: Path, + ) { + let schema = test_schema(); + let node = make_fetch_node(vec![]); + let current_dir = Path(dir_elements); + let response = graphql::Response::builder() + .error(make_error(error_path)) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].path.as_ref().unwrap(), &expected_path); + } + + #[test] + fn root_fetch_multiple_errors() { + let schema = test_schema(); + let node = make_fetch_node(vec![]); + let current_dir = Path(vec![key("root")]); + let response = graphql::Response::builder() + .error( + graphql::Error::builder() + .message("error 1") + .path(Path(vec![key("a")])) + .build(), + ) + .error( + graphql::Error::builder() + .message("error 2") + .path(Path(vec![key("b")])) + .build(), + ) + .error(graphql::Error::builder().message("error 3").build()) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(errors.len(), 3); + assert_eq!( + errors[0].path.as_ref().unwrap(), + &Path(vec![key("root"), key("a")]) + ); + assert_eq!( + errors[1].path.as_ref().unwrap(), + &Path(vec![key("root"), key("b")]) + ); + assert_eq!(errors[2].path.as_ref().unwrap(), &Path(vec![key("root")])); + } + + #[test] + fn root_fetch_preserves_error_extension_code() { + let schema = test_schema(); + let node = make_fetch_node(vec![]); + let current_dir = Path(vec![key("root")]); + let response = graphql::Response::builder() + .error( + graphql::Error::builder() + .message("auth error") + .extension_code("UNAUTHORIZED") + .path(Path(vec![key("field")])) + .build(), + ) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].extension_code().as_deref(), Some("UNAUTHORIZED")); + } + + #[rstest] + #[case::entities_path_no_index( + vec![key("users"), flatten()], + Some(Path(vec![key("_entities")])), + Path(vec![key("users")]) + )] + #[case::non_entities_prefix( + vec![key("a"), key("b")], + Some(Path(vec![key("other"), key("field")])), + Path(vec![key("a"), key("b")]) + )] + #[case::no_path_truncates_at_flatten( + vec![key("a"), flatten(), key("b")], + None, + Path(vec![key("a")]) + )] + #[case::no_flatten_equals_current_dir( + vec![key("a"), key("b"), key("c")], + None, + Path(vec![key("a"), key("b"), key("c")]) + )] + #[case::two_flattens_truncates_at_first( + vec![key("a"), flatten(), key("b"), flatten()], + None, + Path(vec![key("a")]) + )] + #[case::entities_key_not_index( + vec![key("root")], + Some(Path(vec![key("_entities"), key("notAnIndex")])), + Path(vec![key("root")]) + )] + fn entity_error_uses_fallback_dir( + #[case] dir_elements: Vec, + #[case] error_path: Option, + #[case] expected_path: Path, + ) { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(dir_elements); + let response = graphql::Response::builder() + .data(json!({"_entities": []})) + .error(make_error(error_path)) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].path.as_ref().unwrap(), &expected_path); + } + + #[test] + fn entity_fetch_basic_entities_inserted_at_inverted_paths() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("topField"), flatten()]); + let inverted_paths = vec![ + vec![Path(vec![key("topField"), index(0)])], + vec![Path(vec![key("topField"), index(1)])], + ]; + let response = graphql::Response::builder() + .data(json!({ + "_entities": [ + {"name": "Alice"}, + {"name": "Bob"} + ] + })) + .build(); + + let (value, errors) = + node.response_at_path(&schema, ¤t_dir, inverted_paths, response); + + assert!(errors.is_empty()); + let top = value.as_object().unwrap().get("topField").unwrap(); + let arr = top.as_array().unwrap(); + assert_eq!(arr[0], json!({"name": "Alice"})); + assert_eq!(arr[1], json!({"name": "Bob"})); + } + + #[test] + fn entity_fetch_entity_at_multiple_inverted_paths() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("field"), flatten()]); + let inverted_paths = vec![vec![ + Path(vec![key("field"), index(0)]), + Path(vec![key("field"), index(2)]), + ]]; + let response = graphql::Response::builder() + .data(json!({ + "_entities": [{"name": "Alice"}] + })) + .build(); + + let (value, errors) = + node.response_at_path(&schema, ¤t_dir, inverted_paths, response); + + assert!(errors.is_empty()); + let arr = value + .as_object() + .unwrap() + .get("field") + .unwrap() + .as_array() + .unwrap(); + assert_eq!(arr[0], json!({"name": "Alice"})); + assert_eq!(arr[2], json!({"name": "Alice"})); + } + + #[test] + fn entity_fetch_empty_entities_array_returns_default_value() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("field")]); + let response = graphql::Response::builder() + .data(json!({"_entities": []})) + .build(); + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert!(errors.is_empty()); + assert_eq!(value, Value::default()); + } + + #[test] + fn entity_fetch_more_entities_than_inverted_paths() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("f"), flatten()]); + let inverted_paths = vec![vec![Path(vec![key("f"), index(0)])]]; + let response = graphql::Response::builder() + .data(json!({ + "_entities": [ + {"name": "Alice"}, + {"name": "Bob"}, + {"name": "Charlie"} + ] + })) + .build(); + + let (value, errors) = + node.response_at_path(&schema, ¤t_dir, inverted_paths, response); + + assert!(errors.is_empty()); + let arr = value + .as_object() + .unwrap() + .get("f") + .unwrap() + .as_array() + .unwrap(); + assert_eq!(arr[0], json!({"name": "Alice"})); + } + + #[test] + fn entity_fetch_error_with_entities_path_and_index_remapped() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("users"), flatten()]); + let inverted_paths = vec![ + vec![Path(vec![key("users"), index(0)])], + vec![Path(vec![key("users"), index(1)])], + ]; + let response = graphql::Response::builder() + .data(json!({"_entities": [null, null]})) + .error( + graphql::Error::builder() + .message("entity error") + .path(Path(vec![key("_entities"), index(1), key("name")])) + .build(), + ) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, inverted_paths, response); + + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].path.as_ref().unwrap(), + &Path(vec![key("users"), index(1), key("name")]) + ); + assert_eq!(errors[0].message, "entity error"); + } + + #[test] + fn entity_fetch_error_locations_cleared() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("data")]); + let response = graphql::Response::builder() + .data(json!({"_entities": [null]})) + .error( + graphql::Error::builder() + .message("err") + .locations(vec![graphql::Location { line: 1, column: 5 }]) + .path(Path(vec![key("_entities"), index(0), key("x")])) + .build(), + ) + .build(); + + let (_, errors) = node.response_at_path( + &schema, + ¤t_dir, + vec![vec![Path(vec![key("data"), index(0)])]], + response, + ); + + assert_eq!(errors.len(), 1); + assert!(errors[0].locations.is_empty()); + } + + #[test] + fn entity_fetch_error_index_remapped_to_multiple_inverted_paths() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("items"), flatten()]); + let inverted_paths = vec![vec![ + Path(vec![key("items"), index(0)]), + Path(vec![key("items"), index(3)]), + ]]; + let response = graphql::Response::builder() + .data(json!({"_entities": [null]})) + .error( + graphql::Error::builder() + .message("err") + .path(Path(vec![key("_entities"), index(0), key("name")])) + .build(), + ) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, inverted_paths, response); + + assert_eq!(errors.len(), 2); + assert_eq!( + errors[0].path.as_ref().unwrap(), + &Path(vec![key("items"), index(0), key("name")]) + ); + assert_eq!( + errors[1].path.as_ref().unwrap(), + &Path(vec![key("items"), index(3), key("name")]) + ); + } + + #[test] + fn entity_fetch_error_index_out_of_bounds_inverted_paths_no_panic() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("x")]); + let response = graphql::Response::builder() + .data(json!({"_entities": []})) + .error( + graphql::Error::builder() + .message("oob") + .path(Path(vec![key("_entities"), index(5), key("f")])) + .build(), + ) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert!(errors.is_empty()); + } + + #[test] + fn entity_fetch_preserves_extension_code_on_remapped_errors() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("users"), flatten()]); + let inverted_paths = vec![vec![Path(vec![key("users"), index(0)])]]; + let response = graphql::Response::builder() + .data(json!({"_entities": [null]})) + .error( + graphql::Error::builder() + .message("forbidden") + .extension_code("FORBIDDEN") + .path(Path(vec![key("_entities"), index(0)])) + .build(), + ) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, inverted_paths, response); + + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].extension_code().as_deref(), Some("FORBIDDEN")); + assert_eq!( + errors[0].path.as_ref().unwrap(), + &Path(vec![key("users"), index(0)]) + ); + } + + #[test] + fn entity_fetch_error_appends_remaining_path_after_index() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("data"), flatten()]); + let inverted_paths = vec![vec![Path(vec![key("data"), index(0)])]]; + let response = graphql::Response::builder() + .data(json!({"_entities": [null]})) + .error( + graphql::Error::builder() + .message("nested err") + .path(Path(vec![ + key("_entities"), + index(0), + key("address"), + key("city"), + ])) + .build(), + ) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, inverted_paths, response); + + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].path.as_ref().unwrap(), + &Path(vec![key("data"), index(0), key("address"), key("city")]) + ); + } + + #[test] + fn entity_fetch_missing_entities_key_with_errors() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("users"), flatten()]); + let response = graphql::Response::builder() + .data(json!({"something": "else"})) + .error( + graphql::Error::builder() + .message("permission denied") + .build(), + ) + .build(); + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(value, Value::Null); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].message, "permission denied"); + } + + #[test] + fn entity_fetch_missing_entities_key_no_errors() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("users")]); + let response = graphql::Response::builder() + .data(json!({"something": "else"})) + .build(); + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(value, Value::Null); + assert!(errors.is_empty()); + } + + #[test] + fn entity_fetch_null_data_returns_null_with_errors() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("field")]); + let response = graphql::Response::builder() + .error(graphql::Error::builder().message("subgraph error").build()) + .build(); + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(value, Value::Null); + assert_eq!(errors.len(), 1); + } + + #[test] + fn entity_fetch_null_data_errors_get_fallback_dir() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("users"), flatten(), key("reviews")]); + let expected_fallback = Path(vec![key("users")]); + let response = graphql::Response::builder() + .error(graphql::Error::builder().message("pathless error").build()) + .error( + graphql::Error::builder() + .message("non-entities path") + .path(Path(vec![key("something")])) + .build(), + ) + .error( + graphql::Error::builder() + .message("entities no index") + .path(Path(vec![key("_entities")])) + .build(), + ) + .build(); + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(value, Value::Null); + assert_eq!(errors.len(), 3); + for error in &errors { + assert_eq!( + error.path.as_ref().unwrap(), + &expected_fallback, + "error '{}' did not get fallback_dir", + error.message, + ); + } + } + + #[test] + fn entity_fetch_missing_entities_key_errors_get_fallback_dir() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("items"), flatten()]); + let expected_fallback = Path(vec![key("items")]); + let response = graphql::Response::builder() + .data(json!({"something": "else"})) + .error( + graphql::Error::builder() + .message("permission denied") + .build(), + ) + .error( + graphql::Error::builder() + .message("other error") + .path(Path(vec![key("unrelated")])) + .build(), + ) + .build(); + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(value, Value::Null); + assert_eq!(errors.len(), 2); + for error in &errors { + assert_eq!( + error.path.as_ref().unwrap(), + &expected_fallback, + "error '{}' did not get fallback_dir", + error.message, + ); + } + } + + #[test] + fn entity_fetch_entities_not_array_returns_null() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("field")]); + let response = graphql::Response::builder() + .data(json!({"_entities": "not_an_array"})) + .build(); + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(value, Value::Null); + assert!(errors.is_empty()); + } + + #[test] + fn entity_fetch_entities_not_array_errors_get_fallback_dir() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("products"), flatten()]); + let expected_fallback = Path(vec![key("products")]); + let response = graphql::Response::builder() + .data(json!({"_entities": 42})) + .error(graphql::Error::builder().message("bad entities").build()) + .build(); + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(value, Value::Null); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].path.as_ref().unwrap(), &expected_fallback); + } + + #[test] + fn entity_fetch_data_is_non_object_returns_null_with_fallback_errors() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("orders"), flatten(), key("items")]); + let expected_fallback = Path(vec![key("orders")]); + let response = graphql::Response { + data: Some(Value::Null), + errors: vec![graphql::Error::builder().message("null data error").build()], + ..Default::default() + }; + + let (value, errors) = node.response_at_path(&schema, ¤t_dir, vec![], response); + + assert_eq!(value, Value::Null); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].path.as_ref().unwrap(), &expected_fallback); + } + + #[test] + fn entity_fetch_mixed_error_types() { + let schema = test_schema(); + let node = make_fetch_node(make_requires()); + let current_dir = Path(vec![key("users"), flatten()]); + let inverted_paths = vec![ + vec![Path(vec![key("users"), index(0)])], + vec![Path(vec![key("users"), index(1)])], + ]; + let response = graphql::Response::builder() + .data(json!({"_entities": [{"name": "Alice"}, null]})) + .error( + graphql::Error::builder() + .message("entity 1 error") + .path(Path(vec![key("_entities"), index(1), key("field")])) + .build(), + ) + .error( + graphql::Error::builder() + .message("general error") + .path(Path(vec![key("other")])) + .build(), + ) + .error(graphql::Error::builder().message("pathless").build()) + .build(); + + let (_, errors) = node.response_at_path(&schema, ¤t_dir, inverted_paths, response); + + assert_eq!(errors.len(), 3); + assert_eq!( + errors[0].path.as_ref().unwrap(), + &Path(vec![key("users"), index(1), key("field")]) + ); + assert_eq!(errors[1].path.as_ref().unwrap(), &Path(vec![key("users")])); + assert_eq!(errors[2].path.as_ref().unwrap(), &Path(vec![key("users")])); + } +} diff --git a/apollo-router/src/services/execution/service.rs b/apollo-router/src/services/execution/service.rs index bfbf720885..573d7251da 100644 --- a/apollo-router/src/services/execution/service.rs +++ b/apollo-router/src/services/execution/service.rs @@ -477,7 +477,7 @@ impl ExecutionService { None => false, Some(error_path) => { query.contains_error_path(&response.label, error_path, variables_set) - && error_path.starts_with(&path) + && error_path_matches_response_path(error_path, &path) } }) .cloned() @@ -542,6 +542,17 @@ impl ExecutionService { } } +/// Whether an error at `error_path` should be included in a deferred incremental +/// sub-response at `response_path`. An error matches if it is at or below the +/// sub-response (the original behavior), OR if it is a parent of the sub-response +/// path (e.g., an error at `topProducts` matches a sub-response at +/// `topProducts/0`). The parent-path case handles errors produced by +/// `response_at_path`'s `fallback_dir` truncation, which strips wildcard +/// segments to prevent error multiplication in FlattenNode +fn error_path_matches_response_path(error_path: &Path, response_path: &Path) -> bool { + error_path.starts_with(response_path) || response_path.starts_with(error_path) +} + fn rewrite_defer_label(response: &Response) -> Option { if let Some(label) = &response.label { #[allow(clippy::manual_map)] // use an explicit `if` to comment each case @@ -681,3 +692,304 @@ impl ServiceFactory for ExecutionServiceFactory { .boxed() } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use apollo_compiler::Name; + use apollo_compiler::schema; + use rstest::rstest; + use serde_json_bytes::ByteString; + + use super::*; + use crate::graphql::Error; + use crate::graphql::Response; + use crate::json_ext::Path; + use crate::json_ext::PathElement; + use crate::spec::FieldType; + use crate::spec::IncludeSkip; + use crate::spec::Query; + use crate::spec::Selection; + use crate::spec::query::subselections::BooleanValues; + + fn key(name: &str) -> PathElement { + PathElement::Key(name.to_string(), None) + } + + fn index(i: usize) -> PathElement { + PathElement::Index(i) + } + + fn path(elements: Vec) -> Path { + Path(elements) + } + + fn dummy_field_type() -> FieldType { + FieldType(schema::Type::Named(Name::new_unchecked("String"))) + } + + fn field(name: &str, sub: Option>) -> Selection { + Selection::Field { + name: ByteString::from(name), + alias: None, + selection_set: sub, + field_type: dummy_field_type(), + include_skip: IncludeSkip::default(), + } + } + + /// Builds a Query whose selection set is: + /// topProducts { name reviews { author { username } } } + /// This validates error paths through topProducts/N/reviews/N/author/... + fn make_test_query() -> Arc { + let mut query = Query::empty_for_tests(); + query.operation.selection_set = vec![field( + "topProducts", + Some(vec![ + field("name", None), + field( + "reviews", + Some(vec![field("author", Some(vec![field("username", None)]))]), + ), + ]), + )]; + Arc::new(query) + } + + fn make_error_at(p: Path, message: &str) -> Error { + Error::builder().message(message).path(p).build() + } + + fn make_error_no_path(message: &str) -> Error { + Error::builder().message(message).build() + } + + #[rstest] + #[case::exact_match( + vec![key("topProducts"), index(0)], + vec![key("topProducts"), index(0)], + true + )] + #[case::error_deeper( + vec![key("topProducts"), index(0), key("name")], + vec![key("topProducts"), index(0)], + true + )] + #[case::error_is_parent( + vec![key("topProducts")], + vec![key("topProducts"), index(0)], + true + )] + #[case::unrelated( + vec![key("otherField")], + vec![key("topProducts"), index(0)], + false + )] + #[case::diverging_indices( + vec![key("topProducts"), index(0), key("name")], + vec![key("topProducts"), index(1)], + false + )] + #[case::empty_error_path( + vec![], + vec![key("topProducts"), index(0)], + true + )] + #[case::empty_response_path( + vec![key("topProducts")], + vec![], + true + )] + #[case::both_empty(vec![], vec![], true)] + #[case::parent_matches_index_0( + vec![key("topProducts")], + vec![key("topProducts"), index(0)], + true + )] + #[case::parent_matches_index_1( + vec![key("topProducts")], + vec![key("topProducts"), index(1)], + true + )] + #[case::parent_matches_index_99( + vec![key("topProducts")], + vec![key("topProducts"), index(99)], + true + )] + #[case::nested_parent_matches_descendant( + vec![key("topProducts"), index(0), key("reviews")], + vec![key("topProducts"), index(0), key("reviews"), index(0), key("author")], + true + )] + #[case::nested_parent_no_match_different_branch( + vec![key("topProducts"), index(0), key("reviews")], + vec![key("topProducts"), index(1), key("reviews"), index(0)], + false + )] + fn error_path_matching( + #[case] error_elements: Vec, + #[case] response_elements: Vec, + #[case] expected: bool, + ) { + let ep = path(error_elements); + let rp = path(response_elements); + assert_eq!( + error_path_matches_response_path(&ep, &rp), + expected, + "error_path={ep}, response_path={rp}" + ); + } + + #[rstest] + #[case::exact_path( + vec![make_error_at(path(vec![key("topProducts"), index(0)]), "err")], + vec![(path(vec![key("topProducts"), index(0)]), Value::Object(Object::default()))], + vec![1], + vec![vec!["err"]] + )] + #[case::deeper_error( + vec![make_error_at( + path(vec![key("topProducts"), index(0), key("reviews"), index(0), key("author")]), + "deep err", + )], + vec![(path(vec![key("topProducts"), index(0)]), Value::Object(Object::default()))], + vec![1], + vec![vec!["deep err"]] + )] + #[case::parent_error( + vec![make_error_at(path(vec![key("topProducts")]), "parent err")], + vec![(path(vec![key("topProducts"), index(0)]), Value::Object(Object::default()))], + vec![1], + vec![vec!["parent err"]] + )] + #[case::parent_fans_out( + vec![make_error_at(path(vec![key("topProducts")]), "parent err")], + vec![ + (path(vec![key("topProducts"), index(0)]), Value::Object(Object::default())), + (path(vec![key("topProducts"), index(1)]), Value::Object(Object::default())), + (path(vec![key("topProducts"), index(2)]), Value::Object(Object::default())), + ], + vec![1, 1, 1], + vec![vec!["parent err"], vec!["parent err"], vec!["parent err"]] + )] + #[case::no_path( + vec![make_error_no_path("no path")], + vec![(path(vec![key("topProducts"), index(0)]), Value::Object(Object::default()))], + vec![0], + vec![vec![]] + )] + #[case::wrong_index( + vec![make_error_at(path(vec![key("topProducts"), index(1), key("name")]), "wrong index")], + vec![(path(vec![key("topProducts"), index(0)]), Value::Object(Object::default()))], + vec![0], + vec![vec![]] + )] + #[case::multi_error_distribution( + vec![ + make_error_at(path(vec![key("topProducts"), index(0), key("name")]), "err for 0"), + make_error_at(path(vec![key("topProducts"), index(1), key("name")]), "err for 1"), + ], + vec![ + (path(vec![key("topProducts"), index(0)]), Value::Object(Object::default())), + (path(vec![key("topProducts"), index(1)]), Value::Object(Object::default())), + ], + vec![1, 1], + vec![vec!["err for 0"], vec!["err for 1"]] + )] + fn split_incremental_error_distribution( + #[case] errors: Vec, + #[case] sub_responses: Vec<(Path, Value)>, + #[case] expected_error_counts: Vec, + #[case] expected_messages: Vec>, + ) { + let query = make_test_query(); + let response = Response::builder().errors(errors).build(); + + let result = ExecutionService::split_incremental_response( + &query, + false, + BooleanValues { bits: 0 }, + response, + sub_responses, + ) + .unwrap(); + + assert_eq!(result.incremental.len(), expected_error_counts.len()); + for (i, inc) in result.incremental.iter().enumerate() { + assert_eq!( + inc.errors.len(), + expected_error_counts[i], + "sub-response {i} error count mismatch" + ); + let messages: Vec<&str> = inc.errors.iter().map(|e| e.message.as_str()).collect(); + assert_eq!( + messages, expected_messages[i], + "sub-response {i} error messages mismatch" + ); + } + } + + #[test] + fn null_data_sub_response_with_no_errors_is_filtered_out() { + let query = make_test_query(); + let response = Response::builder().build(); + let sub_responses = vec![(path(vec![key("topProducts"), index(0)]), Value::Null)]; + + let result = ExecutionService::split_incremental_response( + &query, + false, + BooleanValues { bits: 0 }, + response, + sub_responses, + ) + .unwrap(); + + assert!(result.incremental.is_empty()); + } + + #[test] + fn null_data_sub_response_with_errors_is_kept() { + let query = make_test_query(); + let response = Response::builder() + .errors(vec![make_error_at( + path(vec![key("topProducts"), index(0)]), + "err", + )]) + .build(); + let sub_responses = vec![(path(vec![key("topProducts"), index(0)]), Value::Null)]; + + let result = ExecutionService::split_incremental_response( + &query, + false, + BooleanValues { bits: 0 }, + response, + sub_responses, + ) + .unwrap(); + + assert_eq!(result.incremental.len(), 1); + assert_eq!(result.incremental[0].errors.len(), 1); + } + + #[test] + fn has_next_is_propagated() { + let query = make_test_query(); + let response = Response::builder().build(); + let sub_responses = vec![( + path(vec![key("topProducts"), index(0)]), + Value::Object(Object::default()), + )]; + + let result = ExecutionService::split_incremental_response( + &query, + true, + BooleanValues { bits: 0 }, + response, + sub_responses, + ) + .unwrap(); + + assert_eq!(result.has_next, Some(true)); + } +} diff --git a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__deferred_fragment_bounds_nullability-2.snap b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__deferred_fragment_bounds_nullability-2.snap index 6a8459af72..894e59df42 100644 --- a/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__deferred_fragment_bounds_nullability-2.snap +++ b/apollo-router/src/services/supergraph/snapshots/apollo_router__services__supergraph__tests__deferred_fragment_bounds_nullability-2.snap @@ -1,6 +1,5 @@ --- source: apollo-router/src/services/supergraph/tests.rs -assertion_line: 800 expression: stream.next_response().await.unwrap() --- { @@ -20,8 +19,7 @@ expression: stream.next_response().await.unwrap() "path": [ "currentUser", "activeOrganization", - "suborga", - 0 + "suborga" ], "extensions": { "code": "FETCH_ERROR", @@ -57,8 +55,7 @@ expression: stream.next_response().await.unwrap() "path": [ "currentUser", "activeOrganization", - "suborga", - 1 + "suborga" ], "extensions": { "code": "FETCH_ERROR", @@ -94,8 +91,7 @@ expression: stream.next_response().await.unwrap() "path": [ "currentUser", "activeOrganization", - "suborga", - 2 + "suborga" ], "extensions": { "code": "FETCH_ERROR", diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__multi_level_response_failure.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__multi_level_response_failure.snap index dfae1d9262..b64956d9fe 100644 --- a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__multi_level_response_failure.snap +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__multi_level_response_failure.snap @@ -44,8 +44,7 @@ expression: response { "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", "path": [ - "topProducts", - 0 + "topProducts" ], "extensions": { "service": "inventory", @@ -53,56 +52,10 @@ expression: response "code": "SUBREQUEST_MALFORMED_RESPONSE" } }, - { - "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", - "path": [ - "topProducts", - 1 - ], - "extensions": { - "service": "inventory", - "reason": "graphql response without data must contain at least one error", - "code": "SUBREQUEST_MALFORMED_RESPONSE" - } - }, - { - "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", - "path": [ - "topProducts", - 0, - "reviews", - 0, - "author" - ], - "extensions": { - "service": "accounts", - "reason": "graphql response without data must contain at least one error", - "code": "SUBREQUEST_MALFORMED_RESPONSE" - } - }, - { - "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", - "path": [ - "topProducts", - 0, - "reviews", - 1, - "author" - ], - "extensions": { - "service": "accounts", - "reason": "graphql response without data must contain at least one error", - "code": "SUBREQUEST_MALFORMED_RESPONSE" - } - }, { "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", "path": [ - "topProducts", - 1, - "reviews", - 0, - "author" + "topProducts" ], "extensions": { "service": "accounts", diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_404.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_404.snap index 13ccbc5089..8d3c801da9 100644 --- a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_404.snap +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_404.snap @@ -44,11 +44,7 @@ expression: response { "message": "HTTP fetch failed from 'accounts': 404: Not Found", "path": [ - "topProducts", - 0, - "reviews", - 0, - "author" + "topProducts" ], "extensions": { "code": "SUBREQUEST_HTTP_ERROR", @@ -59,86 +55,10 @@ expression: response } } }, - { - "message": "HTTP fetch failed from 'accounts': 404: Not Found", - "path": [ - "topProducts", - 0, - "reviews", - 1, - "author" - ], - "extensions": { - "code": "SUBREQUEST_HTTP_ERROR", - "service": "accounts", - "reason": "404: Not Found", - "http": { - "status": 404 - } - } - }, - { - "message": "HTTP fetch failed from 'accounts': 404: Not Found", - "path": [ - "topProducts", - 1, - "reviews", - 0, - "author" - ], - "extensions": { - "code": "SUBREQUEST_HTTP_ERROR", - "service": "accounts", - "reason": "404: Not Found", - "http": { - "status": 404 - } - } - }, - { - "message": "HTTP fetch failed from 'accounts': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", - "path": [ - "topProducts", - 0, - "reviews", - 0, - "author" - ], - "extensions": { - "code": "SUBREQUEST_HTTP_ERROR", - "service": "accounts", - "reason": "subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", - "http": { - "status": 404 - } - } - }, - { - "message": "HTTP fetch failed from 'accounts': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", - "path": [ - "topProducts", - 0, - "reviews", - 1, - "author" - ], - "extensions": { - "code": "SUBREQUEST_HTTP_ERROR", - "service": "accounts", - "reason": "subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", - "http": { - "status": 404 - } - } - }, { "message": "HTTP fetch failed from 'accounts': subgraph response does not contain 'content-type' header; expected content-type: application/json or content-type: application/graphql-response+json", "path": [ - "topProducts", - 1, - "reviews", - 0, - "author" + "topProducts" ], "extensions": { "code": "SUBREQUEST_HTTP_ERROR", diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_malformed.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_malformed.snap index 31b679c697..6124b3a4cf 100644 --- a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_malformed.snap +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__nested_response_failure_malformed.snap @@ -44,41 +44,7 @@ expression: response { "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", "path": [ - "topProducts", - 0, - "reviews", - 0, - "author" - ], - "extensions": { - "service": "accounts", - "reason": "graphql response without data must contain at least one error", - "code": "SUBREQUEST_MALFORMED_RESPONSE" - } - }, - { - "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", - "path": [ - "topProducts", - 0, - "reviews", - 1, - "author" - ], - "extensions": { - "service": "accounts", - "reason": "graphql response without data must contain at least one error", - "code": "SUBREQUEST_MALFORMED_RESPONSE" - } - }, - { - "message": "service 'accounts' response was malformed: graphql response without data must contain at least one error", - "path": [ - "topProducts", - 1, - "reviews", - 0, - "author" + "topProducts" ], "extensions": { "service": "accounts", diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_empty_path.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_empty_path.snap index 983a56dbe1..155e18fb17 100644 --- a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_empty_path.snap +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_empty_path.snap @@ -44,18 +44,7 @@ expression: response { "message": "inventory error", "path": [ - "topProducts", - 0 - ], - "extensions": { - "service": "inventory" - } - }, - { - "message": "inventory error", - "path": [ - "topProducts", - 1 + "topProducts" ], "extensions": { "service": "inventory" diff --git a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_malformed.snap b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_malformed.snap index 6f155d1399..7a984a7757 100644 --- a/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_malformed.snap +++ b/apollo-router/tests/integration/query_planner/snapshots/integration_tests__integration__query_planner__error_paths__second_level_response_failure_malformed.snap @@ -44,20 +44,7 @@ expression: response { "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", "path": [ - "topProducts", - 0 - ], - "extensions": { - "service": "inventory", - "reason": "graphql response without data must contain at least one error", - "code": "SUBREQUEST_MALFORMED_RESPONSE" - } - }, - { - "message": "service 'inventory' response was malformed: graphql response without data must contain at least one error", - "path": [ - "topProducts", - 1 + "topProducts" ], "extensions": { "service": "inventory", diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__nested_response_failure.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__nested_response_failure.snap index 131e577b4e..bd10cf48aa 100644 --- a/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__nested_response_failure.snap +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__coprocessor__on_graphql_error_selector__nested_response_failure.snap @@ -6,18 +6,7 @@ expression: errors { "message": "inventory error", "path": [ - "topProducts", - 0 - ], - "extensions": { - "service": "inventory" - } - }, - { - "message": "inventory error", - "path": [ - "topProducts", - 1 + "topProducts" ], "extensions": { "service": "inventory" diff --git a/apollo-router/tests/integration/snapshots/integration_tests__integration__demand_control__requests_exceeding_one_subgraph_cost_are_accepted@federated_ships_required.snap b/apollo-router/tests/integration/snapshots/integration_tests__integration__demand_control__requests_exceeding_one_subgraph_cost_are_accepted@federated_ships_required.snap new file mode 100644 index 0000000000..d2dcbb9814 --- /dev/null +++ b/apollo-router/tests/integration/snapshots/integration_tests__integration__demand_control__requests_exceeding_one_subgraph_cost_are_accepted@federated_ships_required.snap @@ -0,0 +1,57 @@ +--- +source: apollo-router/tests/integration/demand_control.rs +expression: parse_result_for_snapshot(response).await +--- +{ + "apollo::demand_control::actual_cost": 9.0, + "apollo::demand_control::actual_cost_by_subgraph": { + "vehicles": 9.0 + }, + "apollo::demand_control::estimated_cost": 140.0, + "apollo::demand_control::estimated_cost_by_subgraph": { + "users": 110.0, + "vehicles": 30.0 + }, + "apollo::demand_control::result": "COST_OK", + "apollo::demand_control::result_by_subgraph": { + "users": "SUBGRAPH_COST_ESTIMATED_TOO_EXPENSIVE", + "vehicles": "COST_OK" + }, + "apollo::demand_control::strategy": "static_estimated", + "apollo::experimental_mock_subgraphs::subgraph_call_count": { + "vehicles": 2 + }, + "body": { + "data": { + "ships": [ + { + "name": "Ship1", + "registrationFee": null + }, + { + "name": "Ship2", + "registrationFee": null + }, + { + "name": "Ship3", + "registrationFee": null + } + ] + }, + "errors": [ + { + "extensions": { + "code": "SUBGRAPH_COST_ESTIMATED_TOO_EXPENSIVE", + "cost.subgraph": "users", + "cost.subgraph.estimated": 110.0, + "cost.subgraph.max": 1.0, + "service": "users" + }, + "message": "query estimated cost 110 exceeded configured maximum 1 for subgraph users", + "path": [ + "ships" + ] + } + ] + } +}