Skip to content
Open
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,19 @@ 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"
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<Warning>
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>
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,35 @@ 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:
Comment thread
endigma marked this conversation as resolved.

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

* `value` - The value to set for the header
* **set** - Sets a header on the response from the subgraph to the router.

<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.

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.

## 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)).

<Warning>
The `set` value is **not** forwarded to the client response unless a `propagate` rule explicitly forwards it. We caution against relying on this pattern, as the header will not be propagated if no subgraph is actually called.
</Warning>
Comment thread
endigma marked this conversation as resolved.

### Configuration

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

### Setting Cache Control on a Subgraph Response

While users can use `set` on to set `Cache-Control` headers on subgraph responses, we instead recommend using the [`cache_control_policy` configuration](/router/proxy-capabilities/adjusting-cache-control).
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 thread
endigma marked this conversation as resolved.
}
})
})
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.Empty(t, res.Response.Header.Get("X-Header-A"), "set response headers should not be forwarded to the client")
require.Empty(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