Skip to content

refactor(executor): handle header propagation outside of the plan executor#652

Merged
ardatan merged 5 commits intomainfrom
return-response-from-executor
Jan 22, 2026
Merged

refactor(executor): handle header propagation outside of the plan executor#652
ardatan merged 5 commits intomainfrom
return-response-from-executor

Conversation

@ardatan
Copy link
Member

@ardatan ardatan commented Jan 12, 2026

Extracted from #628

Combining with #651 , the aim is to reduce the number of extra structs.

Today, PlanExecutionOutput which is the return type of execute_query_plan in executor/execution/plan.rs, returns body: Vec<u8> and headers: http::HeaderMap.
Then, in the router/pipeline, ResponseBuilder is called with Bytes::from of PlanExecutionOutput.body and PlanExecutionOutput.headers is converted to ntex::HeaderMap, then content_type is set here.

With this PR, the response aggregator is sent with PlanExecutionOutput, and during the response build, the response headers aggregator sets the headers into ResponseBuilder directly.

So now modify_client_headers helper accepts ResponseBuilder, not http:HeaderMap. And since this throws HeaderPropagationError in the pipeline executor, I had to add HeaderPropagationError to PipelineError.

I also extracted PipelineError to FailedExecutionResult logic to a From trait so that other future implementations that serializes and sets the body in a different way can use it and serialize FailedExecutionResult on their own.
Currently it is used here only -> https://github.com/graphql-hive/router/pull/652/changes#diff-f3dc2f2d579374645281d6950414266d8984195dbf45c00f0a754ceca8550aadR95


Update:
After recent changes, I see that project_by_operation is used for dry_run but it is actually unnecessary as we just need to serialize extensions which has nothing to do with projection since it uses sonic_rs::Value.

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @ardatan, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request refactors the GraphQL plan executor to directly produce and return ntex::http::Response objects. This change significantly streamlines the response handling process by centralizing header propagation and content type management within the executor. By moving response construction logic into the executor, the router's role is simplified, reducing the need for intermediate data structures and improving the clarity of the data flow.

Highlights

  • Centralized Response Generation: The plan executor now directly constructs and returns an ntex::http::Response object, centralizing the logic for building the final HTTP response within the executor module.
  • Simplified Data Structures: The PlanExecutionOutput struct has been refactored to directly hold the ntex::http::Response, eliminating the need for separate body (as Vec<u8>) and headers (http::HeaderMap) fields.
  • Streamlined Header Propagation: Header modification and propagation logic, previously handled in the router after receiving raw bytes and headers, is now applied directly to the ntex::http::Response object within the executor, simplifying the overall response pipeline.
  • Content Type Handling: The response_content_type is now determined earlier in the request pipeline within the router and passed down to the executor, which then uses it to set the content type when constructing the ntex::http::Response.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the plan executor to return a complete ntex::http::Response instead of raw bytes and headers, simplifying the pipeline in bin/router/src/pipeline/mod.rs. However, a high-severity vulnerability was identified in the response header propagation logic: it currently uses a denylist for hop-by-hop headers, which is insufficient. A strict allow-list is required to prevent malicious subgraphs from injecting dangerous headers like Set-Cookie or Content-Security-Policy, which could lead to session fixation or clickjacking. Additionally, performance concerns were noted due to allocations on the hot path during header type conversions, which should be addressed as per the repository's style guide.

@github-actions
Copy link

github-actions bot commented Jan 12, 2026

k6-benchmark results

     ✓ response code was 200
     ✓ no graphql errors
     ✓ valid response structure

     █ setup

     checks.........................: 100.00% ✓ 239484      ✗ 0    
     data_received..................: 7.0 GB  232 MB/s
     data_sent......................: 94 MB   3.1 MB/s
     http_req_blocked...............: avg=3.08µs   min=671ns   med=1.81µs  max=11ms     p(90)=2.51µs  p(95)=2.86µs  
     http_req_connecting............: avg=508ns    min=0s      med=0s      max=3.19ms   p(90)=0s      p(95)=0s      
     http_req_duration..............: avg=18.32ms  min=2.07ms  med=17.4ms  max=237.37ms p(90)=25.13ms p(95)=28.2ms  
       { expected_response:true }...: avg=18.32ms  min=2.07ms  med=17.4ms  max=237.37ms p(90)=25.13ms p(95)=28.2ms  
     http_req_failed................: 0.00%   ✓ 0           ✗ 79848
     http_req_receiving.............: avg=154.72µs min=24.22µs med=39.02µs max=201.46ms p(90)=87.14µs p(95)=381.09µs
     http_req_sending...............: avg=24.76µs  min=5.56µs  med=10.51µs max=23.08ms  p(90)=15.89µs p(95)=28.31µs 
     http_req_tls_handshaking.......: avg=0s       min=0s      med=0s      max=0s       p(90)=0s      p(95)=0s      
     http_req_waiting...............: avg=18.14ms  min=2.01ms  med=17.28ms max=66.33ms  p(90)=24.86ms p(95)=27.87ms 
     http_reqs......................: 79848   2655.409947/s
     iteration_duration.............: avg=18.78ms  min=4.7ms   med=17.76ms max=314.75ms p(90)=25.59ms p(95)=28.69ms 
     iterations.....................: 79828   2654.744831/s
     vus............................: 50      min=50        max=50 
     vus_max........................: 50      min=50        max=50 

@github-actions
Copy link

github-actions bot commented Jan 12, 2026

🐋 This PR was built and pushed to the following Docker images:

Image Names: ghcr.io/graphql-hive/router

Platforms: linux/amd64,linux/arm64

Image Tags: ghcr.io/graphql-hive/router:pr-652 ghcr.io/graphql-hive/router:sha-5e4be2d

Docker metadata
{
"buildx.build.ref": "builder-3e87a278-2b2a-4ebc-87dd-3ddfe5d883ad/builder-3e87a278-2b2a-4ebc-87dd-3ddfe5d883ad0/v0bzl10dd4xq3mzzpbrf5e4sz",
"containerimage.descriptor": {
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "digest": "sha256:930fc4f492a3781f7ac76e345a2fae6cde94299c7db7d0fced600396725ab795",
  "size": 1609
},
"containerimage.digest": "sha256:930fc4f492a3781f7ac76e345a2fae6cde94299c7db7d0fced600396725ab795",
"image.name": "ghcr.io/graphql-hive/router:pr-652,ghcr.io/graphql-hive/router:sha-5e4be2d"
}

@ardatan ardatan force-pushed the return-response-from-executor branch from 234544c to 47655cb Compare January 12, 2026 12:53
@ardatan ardatan enabled auto-merge (squash) January 12, 2026 12:53
@ardatan ardatan force-pushed the return-response-from-executor branch 2 times, most recently from 8a61cea to 406299f Compare January 14, 2026 16:13
@ardatan ardatan requested a review from enisdenjo January 18, 2026 18:28
.content_type(ctx.response_content_type)
.body(body);

modify_client_response_headers(exec_ctx.response_headers_aggregator, response.headers_mut())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It gives users the power to modify the content-type header, we're okay with it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can prevent that inside modify_client_response_headers ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just asking for opinion, if we should do allow it or not

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is also a header at the end of the day. Maybe user wants to have a special json type? I think it is fine to allow it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client should not impact the calls to subgraphs, and neither should subgraphs to clients. There should be complete separation of client<->router and router<->subgraph - the only thing that can propagate are the allowed headers as per the config - excluding content-type and accept.

@ardatan
Copy link
Member Author

ardatan commented Jan 20, 2026

Closed in favor of #692

@ardatan ardatan closed this Jan 20, 2026
auto-merge was automatically disabled January 20, 2026 16:37

Pull request was closed

@ardatan ardatan reopened this Jan 20, 2026
@ardatan ardatan closed this Jan 20, 2026
@ardatan ardatan reopened this Jan 20, 2026
@ardatan ardatan force-pushed the return-response-from-executor branch from b21c13f to 02dca46 Compare January 20, 2026 16:57
Copy link
Member

@enisdenjo enisdenjo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When it comes to #620, the execution_pipeline can either return:

  • an execution output with a graphql response
    • at the time of writing, the graphql response is in body: Vec<u8> in the subs pr
  • or a subscriptions output with a stream of graphql responses
    • at the time of writing, the graphql response is in body: BoxStream<'static, Vec<u8>> in the subs pr

I don't mind it being a ntext response, as long as we can assemble it outside of an http request handler for subscriptions over websockets.

Just for context, in websockets, I mimic http requests by assembing an http request-like for each ws operation message - so the execute_query_plan still has the whole ClientRequestDetails deal happening. I figured a great and reliable way of doing that, so the execute_query_plan will continue getting the ClientRequestDetails.

Comment on lines 67 to 68
let body = project_by_operation(
&Value::Null,
vec![],
&extensions,
planned_request.normalized_payload.root_type_name,
&[],
&None,
0,
introspection_context.metadata,
)
.with_plan_context(LazyPlanContext {
subgraph_name: || None,
affected_path: || None,
})
.map_err(|err| {
tracing::error!(
"Failed to project query plan to extensions during dry-run: {}",
err
);
PipelineError::PlanExecutionError(err)
})?;
let response = ntex::http::Response::Ok().json(&json!({
"extensions": extensions,
}));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this.

.content_type(ctx.response_content_type)
.body(body);

modify_client_response_headers(exec_ctx.response_headers_aggregator, response.headers_mut())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client should not impact the calls to subgraphs, and neither should subgraphs to clients. There should be complete separation of client<->router and router<->subgraph - the only thing that can propagate are the allowed headers as per the config - excluding content-type and accept.

supergraph: &SupergraphData,
shared_state: &Arc<RouterSharedState>,
schema_state: &Arc<SchemaState>,
response_content_type: &'static str,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the execution_pipeline need to know about the response content-type? As I said in previous comment, they should be separate an the client should in no way influence the content-type of internal subgraph calls. Subscriptions, for example, would not have a content-type at all when dealing with WebSockets.

The execution_pipeline should be oblivious to the why or for whom it's resolving something.

Copy link
Member Author

@ardatan ardatan Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved the response header propagation to the pipeline executor. My point is to avoid creating a new HeaderMap in the execution then re-iterate it while creating ntex:http::Response. I think with my last commit, the plan executor becomes fully HTTP independent as you wish, right?

Copy link
Member

@enisdenjo enisdenjo Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's looking great! I am just wondering why not simply HeaderMap? Why an abstraction over it? At the end of theday, there is no hiding from the fact that the executing a query plan needs headers, so why not just HeaderMap?

@ardatan ardatan changed the title refactor(executor): return http response from the plan executor refactor(executor): handle header propagation outside of the plan executor Jan 21, 2026
@ardatan ardatan force-pushed the return-response-from-executor branch from 79d2a0e to a87e47c Compare January 21, 2026 23:33
@ardatan ardatan merged commit d476955 into main Jan 22, 2026
23 checks passed
@ardatan ardatan deleted the return-response-from-executor branch January 22, 2026 17:59
ardatan added a commit that referenced this pull request Jan 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants