Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,10 @@ The algorithm evaluates the following in order:

By combining these mechanisms, the algorithm ensures that data handling adheres to the strictest cache control settings from all subgraph responses, promoting both security and performance integrity. Users can define global defaults to enforce a baseline cache policy, and can rely on `no-cache` or `no-store` directives for security sensitive subgraphs.

### Overriding Cache Control Policy
### Influencing Cache Control via Set Rules

<Info>
By using the `set` operation in their header propagation rules, users can overwrite the cache control policy if necessary.
By using the `set` operation in their header propagation rules, users can inject a `Cache-Control` value into a subgraph's response. The restrictive algorithm then includes this value when computing the most restrictive policy across all subgraph responses.
</Info>

For example, a configuration can be set like:
Expand All @@ -152,4 +152,23 @@ headers:
value: "max-age=5400"
```

For this configuration, any request which hits the `specific-subgraph` will have the desired subgraph cache control value set (`max-age=5400`).
For this configuration, any request which hits the `specific-subgraph` will have `Cache-Control: max-age=5400` injected into the subgraph response. The restrictive cache control algorithm then considers this value alongside other subgraph responses to compute the final `Cache-Control` header sent to the client.

This is however equivalent to the following

```
cache_control_policy:
enabled: true
value: "max-age=180, public"
subgraphs:
- name: "specific-subgraph"
value: "max-age=5400, public"
```

<Warning>
As such we recommend not using this method and simply sticking to using the `subgraph` configuration in `cache_control_policy`, as it makes things more explicit and clearer.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
As such we recommend not using this method and simply sticking to using the `subgraph` configuration in `cache_control_policy`, as it makes things more explicit and clearer.
We do not recommend using this method, simply sticking to using the `subgraph` configuration in `cache_control_policy` makes things more explicit and clearer.

</Warning>

<Note>
Until router version VERSION, setting a `Cache-Control` header would do a hard override of whatever value would be computed by the `cache_control_policy` configuration. This has been changed since.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

VERSION? how do we chicken-egg this with the release now that docs are in tree?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Do a release -> then update and merge since its only my pr ideally -> and then merge
Either way can open a new PR if the number is wrong worst case

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You mean releasing from the PR branch?

</Note>
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ headers:
default: "123" # Set the value when the header was not set
algorithm: "last_write"

- op: "set"
name: "X-Custom-Header"
value: "my-required-key"

subgraphs:
specific-subgraph: # Will only affect this subgraph
response:
Expand All @@ -68,7 +64,7 @@ headers:

### What does the snippet do?

With `all` we address all subgraph requests. Next, we can define several rules on the client's request. The operation `propagate` forwards all matching client request headers to the subgraphs. The operation `set` sets a new header which is forward to the subgraphs.
With `all` we address all subgraph responses. Next, we can define several rules on the response headers. The operation `propagate` forwards matching subgraph response headers to the client. The operation `set` injects a header value into the subgraph response — for `Cache-Control`, this value is picked up by the cache control algorithm; for other headers, the value is not forwarded to the client unless a separate `propagate` rule matches it.

The `subgraphs` section allows to propagate headers for specific subgraphs. The name must match with the subgraph name in the Studio.

Expand All @@ -90,12 +86,59 @@ Currently, we support the following header rules:

* `default` - Fallback to this value when the `named`, `matching` or `rename` header could not be found.

* `set` - Sets a header on the request forward to the subgraph. You must set the following values:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does the page still flow properly after this change? We still have set?

<Note>
Go canonicalizes headers by default e.g. `x-my-header` to `X-My-Header.` Write your rule accordingly or use `(?i)``^X-Test-.*` flags to make your regex case insensitive.
</Note>

## Order of Execution

Response header rules are applied per subgraph fetch in the following order:

1. **`all` rules** — Rules defined under `headers.all.response` are applied first, in the order they are defined.
2. **Subgraph-specific rules** — Rules defined under `headers.subgraphs.<name>.response` are applied next, in the order they are defined.
3. **Cache control policy** — The `cache_control_policy` rules run last. This ensures that all `set` rules (both global and subgraph-specific) have already injected their values into the subgraph response before the [restrictive cache control algorithm](/router/proxy-capabilities/adjusting-cache-control) reads them.

* `name` - The name of the header to set
Within each scope, rules execute in definition order. This means a `set` rule defined before a `propagate` rule in the same scope will inject the value into the subgraph response before the `propagate` rule evaluates it.

* `value` - The value to set for the header
## Response Header Set

The `set` operation injects a header value into the subgraph response, making it appear as if the subgraph returned it. This value is then processed by downstream rules (e.g., `propagate` rules or the [restrictive cache control algorithm](/router/proxy-capabilities/adjusting-cache-control)).

<Note>
Go canonicalizes headers by default e.g. `x-my-header` to `X-My-Header.` Write your rule accordingly or use `(?i)``^X-Test-.*` flags to make your regex case insensitive.
The `set` value is **not** forwarded to the client response.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
The `set` value is **not** forwarded to the client response.
The `set` value is **not** forwarded to the client response unless it is propagated.

</Note>


<Warning>
You can use `set` to set a header value and then use a `propagate` rule to forward that same header to the client. However, we would caution against this, as the header will not be propagated if no subgraph was actually called.
</Warning>
Comment on lines +112 to +114
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we merge all this into one block? It's kinda whiplash to read "is not forwarded**" -> "do xyz to forward it"


### Configuration

* `name` - The name of the header to set
* `value` - The value to set for the header

### Example: Setting Cache Control on a Subgraph Response
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Perhaps we can replace this with a use case set is actually ideal for? We can feature config like this elsewhere as a footgun example but I think showing an example then a warning to not do it is bad practice


```yaml
cache_control_policy:
enabled: true
value: "max-age=180, public"

headers:
subgraphs:
specific-subgraph:
response:
- op: "set"
name: "Cache-Control"
value: "max-age=5400"
```

In this example, when a request hits `specific-subgraph`, the `Cache-Control: max-age=5400` value is injected into the subgraph response. The restrictive cache control algorithm then processes this value alongside other subgraph responses to compute the final `Cache-Control` header sent to the client. This is equivalent to doing

```
cache_control_policy:
enabled: true
value: "max-age=180, public"

```
Comment on lines +116 to +144
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If we don't want people to do this, perhaps we should not include so many searchable examples of doing it in the docs

48 changes: 24 additions & 24 deletions router-tests/operations/singleflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -958,7 +958,7 @@ func TestSingleFlight(t *testing.T) {
}
})
})
t.Run("response header set rule with singleflight followers", func(t *testing.T) {
t.Run("response header set rule with singleflight followers is internal only", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
Subgraphs: testenv.SubgraphsConfig{
Expand All @@ -985,8 +985,8 @@ func TestSingleFlight(t *testing.T) {
responses := runConcurrentSingleflightRequests(t, xEnv, `{ employee(id: 1) { id } }`, 5)
for i, res := range responses {
require.Equal(t, `{"data":{"employee":{"id":1}}}`, res.Body)
require.Equal(t, "test-value", res.Response.Header.Get("X-Custom-Header"),
"response %d missing X-Custom-Header", i)
require.Equal(t, "", res.Response.Header.Get("X-Custom-Header"),
"response %d: set response headers should not be forwarded to the client", i)
}
})
})
Expand Down Expand Up @@ -1028,7 +1028,7 @@ func TestSingleFlight(t *testing.T) {
}
})
})
t.Run("multiple response set rules with singleflight followers", func(t *testing.T) {
t.Run("multiple response set rules with singleflight followers are internal only", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
Subgraphs: testenv.SubgraphsConfig{
Expand Down Expand Up @@ -1057,23 +1057,23 @@ func TestSingleFlight(t *testing.T) {
}),
},
}, func(t *testing.T, xEnv *testenv.Environment) {
// Verify single request works
// Verify single request works — set headers are internal only
res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `{ employee(id: 1) { id } }`,
})
require.Equal(t, "value-a", res.Response.Header.Get("X-Header-A"), "single request should have X-Header-A")
require.Equal(t, "value-b", res.Response.Header.Get("X-Header-B"), "single request should have X-Header-B")
require.Equal(t, "", res.Response.Header.Get("X-Header-A"), "set response headers should not be forwarded to the client")
require.Equal(t, "", res.Response.Header.Get("X-Header-B"), "set response headers should not be forwarded to the client")

responses := runConcurrentSingleflightRequests(t, xEnv, `{ employee(id: 1) { id } }`, 5)
for i, res := range responses {
require.Equal(t, "value-a", res.Response.Header.Get("X-Header-A"),
"response %d missing X-Header-A", i)
require.Equal(t, "value-b", res.Response.Header.Get("X-Header-B"),
"response %d missing X-Header-B", i)
require.Equal(t, "", res.Response.Header.Get("X-Header-A"),
"response %d: set response headers should not be forwarded to the client", i)
require.Equal(t, "", res.Response.Header.Get("X-Header-B"),
"response %d: set response headers should not be forwarded to the client", i)
Comment on lines +1069 to +1072
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

require.Empty I think?

}
})
})
t.Run("multi-subgraph response header propagation with singleflight", func(t *testing.T) {
t.Run("multi-subgraph response set with singleflight is internal only", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
Subgraphs: testenv.SubgraphsConfig{
Expand Down Expand Up @@ -1103,12 +1103,12 @@ func TestSingleFlight(t *testing.T) {
responses := runConcurrentSingleflightRequests(t, xEnv, query, 5)
for i, res := range responses {
require.Contains(t, res.Body, `"employee"`)
require.Equal(t, "multi-subgraph-value", res.Response.Header.Get("X-Custom-Header"),
"response %d missing X-Custom-Header from multi-subgraph query", i)
require.Equal(t, "", res.Response.Header.Get("X-Custom-Header"),
"response %d: set response headers should not be forwarded to the client", i)
}
})
})
t.Run("subgraph-specific response header rule with singleflight", func(t *testing.T) {
t.Run("subgraph-specific response set rule with singleflight is internal only", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
Subgraphs: testenv.SubgraphsConfig{
Expand Down Expand Up @@ -1138,12 +1138,12 @@ func TestSingleFlight(t *testing.T) {
responses := runConcurrentSingleflightRequests(t, xEnv, `{ employees { id } }`, 5)
for i, res := range responses {
require.Equal(t, `{"data":{"employees":[{"id":1},{"id":2},{"id":3},{"id":4},{"id":5},{"id":7},{"id":8},{"id":10},{"id":11},{"id":12}]}}`, res.Body)
require.Equal(t, "employees-value", res.Response.Header.Get("X-Subgraph-Header"),
"response %d missing subgraph-specific X-Subgraph-Header", i)
require.Equal(t, "", res.Response.Header.Get("X-Subgraph-Header"),
"response %d: set response headers should not be forwarded to the client", i)
}
})
})
t.Run("mixed global and subgraph-specific response header rules with singleflight", func(t *testing.T) {
t.Run("mixed global and subgraph-specific response set rules with singleflight are internal only", func(t *testing.T) {
t.Parallel()
testenv.Run(t, &testenv.Config{
Subgraphs: testenv.SubgraphsConfig{
Expand Down Expand Up @@ -1193,12 +1193,12 @@ func TestSingleFlight(t *testing.T) {
responses := runConcurrentSingleflightRequests(t, xEnv, query, 5)
for i, res := range responses {
require.Contains(t, res.Body, `"employee"`)
require.Equal(t, "global-value", res.Response.Header.Get("X-Global-Header"),
"response %d missing global X-Global-Header", i)
require.Equal(t, "employees-value", res.Response.Header.Get("X-Employees-Header"),
"response %d missing subgraph-specific X-Employees-Header", i)
require.Equal(t, "family-value", res.Response.Header.Get("X-Family-Header"),
"response %d missing subgraph-specific X-Family-Header", i)
require.Equal(t, "", res.Response.Header.Get("X-Global-Header"),
"response %d: set response headers should not be forwarded to the client", i)
require.Equal(t, "", res.Response.Header.Get("X-Employees-Header"),
"response %d: set response headers should not be forwarded to the client", i)
require.Equal(t, "", res.Response.Header.Get("X-Family-Header"),
"response %d: set response headers should not be forwarded to the client", i)
}
})
})
Expand Down
10 changes: 5 additions & 5 deletions router-tests/protocol/header_propagation_race_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestHeaderPropagationConcurrentMapWrites(t *testing.T) {

const expectedResponse = `{"data":{"employees":[{"id":1,"isAvailable":false,"hobbies":[{},{"name":"Counter Strike"},{},{},{}]},{"id":2,"isAvailable":false,"hobbies":[{},{"name":"Counter Strike"},{}]},{"id":3,"isAvailable":false,"hobbies":[{},{},{},{}]},{"id":4,"isAvailable":false,"hobbies":[{},{},{}]},{"id":5,"isAvailable":false,"hobbies":[{},{},{}]},{"id":7,"isAvailable":false,"hobbies":[{"name":"Chess"},{}]},{"id":8,"isAvailable":false,"hobbies":[{},{"name":"Miscellaneous"},{}]},{"id":10,"isAvailable":false,"hobbies":[{},{},{},{},{},{}]},{"id":11,"isAvailable":false,"hobbies":[{}]},{"id":12,"isAvailable":false,"hobbies":[{},{},{"name":"Miscellaneous"},{}]}]}}`

t.Run("response set rule with parallel subgraph fetches", func(t *testing.T) {
t.Run("response set rule with parallel subgraph fetches is internal only", func(t *testing.T) {
testenv.Run(t, &testenv.Config{
RouterOptions: []core.Option{
core.WithHeaderRules(config.HeaderRules{
Expand All @@ -53,7 +53,7 @@ func TestHeaderPropagationConcurrentMapWrites(t *testing.T) {
require.Equal(t, http.StatusOK, res.Response.StatusCode)
require.Equal(t, expectedResponse, res.Body)

require.Equal(t, "test-value", res.Response.Header.Get("X-Custom-Header"), "single request failed")
require.Equal(t, "", res.Response.Header.Get("X-Custom-Header"), "set response headers should not be forwarded to the client")
})
})

Expand Down Expand Up @@ -92,7 +92,7 @@ func TestHeaderPropagationConcurrentMapWrites(t *testing.T) {
})
})

t.Run("multiple response set rules with parallel subgraph fetches", func(t *testing.T) {
t.Run("multiple response set rules with parallel subgraph fetches are internal only", func(t *testing.T) {
testenv.Run(t, &testenv.Config{
RouterOptions: []core.Option{
core.WithHeaderRules(config.HeaderRules{
Expand All @@ -119,8 +119,8 @@ func TestHeaderPropagationConcurrentMapWrites(t *testing.T) {
require.Equal(t, http.StatusOK, res.Response.StatusCode)
require.Equal(t, expectedResponse, res.Body)

require.Equal(t, "value-a", res.Response.Header.Get("X-Header-A"), "single request failed")
require.Equal(t, "value-b", res.Response.Header.Get("X-Header-B"), "single request failed")
require.Equal(t, "", res.Response.Header.Get("X-Header-A"), "set response headers should not be forwarded to the client")
require.Equal(t, "", res.Response.Header.Get("X-Header-B"), "set response headers should not be forwarded to the client")
})
})
}
27 changes: 20 additions & 7 deletions router-tests/protocol/header_propagation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1122,10 +1122,15 @@ func TestHeaderPropagation(t *testing.T) {
})
})

t.Run("set operation can override cache control policies", func(t *testing.T) {
t.Run("set operation feeds into cache control algorithm", func(t *testing.T) {
t.Parallel()
t.Run("global set operation", func(t *testing.T) {
// The set operation injects into res.Header, so the cache control
// algorithm sees the set value as if the subgraph returned it.
t.Run("global set injects value for algorithm", func(t *testing.T) {
t.Parallel()
// set in All.Response runs before the cache control algorithm.
// It overwrites the subgraph's real CC header, so the algorithm
// sees the injected value for every subgraph.
testenv.Run(t, &testenv.Config{
CacheControlPolicy: config.CacheControlPolicy{
Enabled: true,
Expand All @@ -1138,7 +1143,7 @@ func TestHeaderPropagation(t *testing.T) {
{
Operation: config.HeaderRuleOperationSet,
Name: "Cache-Control",
Value: "my-fake-value",
Value: "max-age=60",
},
},
},
Expand All @@ -1148,13 +1153,19 @@ func TestHeaderPropagation(t *testing.T) {
Query: queryEmployeeWithHobby,
})
cc := res.Response.Header.Get("Cache-Control")
require.Equal(t, "my-fake-value", cc)
// The set value (max-age=60) is injected into every subgraph
// response, overwriting the real headers. The algorithm picks
// max-age=60 as the most restrictive.
require.Equal(t, "max-age=60", cc)
require.Equal(t, `{"data":{"employee":{"id":1,"hobbies":[{},{"name":"Counter Strike"},{},{},{}]}}}`, res.Body)
})
})

t.Run("local subgraph set operation", func(t *testing.T) {
t.Run("subgraph set feeds into algorithm", func(t *testing.T) {
t.Parallel()
// The cache control algorithm runs after all rules (including
// subgraph-specific set rules), so the set value is visible to
// the algorithm.
testenv.Run(t, &testenv.Config{
CacheControlPolicy: config.CacheControlPolicy{
Enabled: true,
Expand All @@ -1168,7 +1179,7 @@ func TestHeaderPropagation(t *testing.T) {
{
Operation: config.HeaderRuleOperationSet,
Name: "Cache-Control",
Value: "my-fake-value",
Value: "max-age=10",
},
},
},
Expand All @@ -1179,7 +1190,9 @@ func TestHeaderPropagation(t *testing.T) {
Query: queryEmployeeWithHobby,
})
cc := res.Response.Header.Get("Cache-Control")
require.Equal(t, "my-fake-value", cc)
// The set overwrites employees' max-age=180 with max-age=10.
// The algorithm sees employees=10 and hobbies=250, picks 10.
require.Equal(t, "max-age=10", cc)
require.Equal(t, `{"data":{"employee":{"id":1,"hobbies":[{},{"name":"Counter Strike"},{},{},{}]}}}`, res.Body)
})
})
Expand Down
Loading
Loading