Skip to content

Commit

Permalink
cache control plugin: set cache-control: no-store by default (#6986)
Browse files Browse the repository at this point in the history
As pointed out in #2605, browsers cache many GET requests by default, so
"not cacheable" shouldn't be the same as "don't set a cache-control
header". This PR makes the backwards-incompatible change to the cache
control plugin to make it always set the cache-control header to
something if it is enabled (with calculateHttpHeaders not set to false),
perhaps `no-store`. (If some other plugin or error already set the
header, it does not override it with `no-store`.)

To restore AS3 behavior:

ApolloServerPluginCacheControl({ calculateHttpHeaders: 'if-cacheable' })

Fixes #2605.
  • Loading branch information
glasser committed Oct 4, 2022
1 parent 80bfb23 commit db5d715
Show file tree
Hide file tree
Showing 7 changed files with 112 additions and 14 deletions.
6 changes: 6 additions & 0 deletions .changeset/forty-beds-cough.md
@@ -0,0 +1,6 @@
---
'@apollo/server-integration-testsuite': patch
'@apollo/server': patch
---

The cache control plugin sets `cache-control: no-store` for uncacheable responses. Pass `calculateHttpHeaders: 'if-cacheable'` to the cache control plugin to restore AS3 behavior.
4 changes: 2 additions & 2 deletions docs/source/api/plugin/cache-control.mdx
Expand Up @@ -91,11 +91,11 @@ This option was popular in Apollo Server 2 as a workaround for the problem solve

###### `calculateHttpHeaders`

`boolean`
`boolean | 'if-cacheable'`
</td>
<td>

By default, the cache control plugin sets the `cache-control` HTTP response header to `max-age=MAXAGE, public` or `max-age=MAXAGE, private` if the request is cacheable. If you specify `calculateHttpHeaders: false`, it will not set this header. The `requestContext.overallCachePolicy` field will still be calculated, and the [response cache plugin](../../performance/caching/#caching-with-responsecacheplugin-advanced) will still work.
By default, the cache control plugin sets the `cache-control` HTTP response header to `max-age=MAXAGE, public` or `max-age=MAXAGE, private` if the request is cacheable, and to `no-store` if the request is not cacheable. If you specify `calculateHttpHeaders: false`, it will not set this header. If you specify `calculateHttpHeaders: 'if-cacheable'`, it will only set the header if the request is cacheable. (A response is cacheable if its overall cache policy has a non-zero `maxAge`, and the body is a single result rather than an incremental delivery response, and the body contains no errors.) Setting this option does not prevent the `requestContext.overallCachePolicy` field from being calculated, nor does it prevent the [response cache plugin](../../performance/caching/#caching-with-responsecacheplugin-advanced) from working.

</td>
</tr>
Expand Down
27 changes: 27 additions & 0 deletions docs/source/migration.mdx
Expand Up @@ -1345,6 +1345,33 @@ In Apollo Server 4, if your server _hasn't_ set up draining and it receives an o

If you are using the `startStandaloneServer` function, your server drains automatically. If you are using `expressMiddleware` or another `http.Server`-based web server, you can add draining using the [`ApolloServerPluginDrainHttpServer` plugin](./api/plugin/drain-http-server/#using-the-plugin).

### Cache control plugin sets `cache-control` header for uncached requests

The cache control plugin is installed by default. It does two things: it calculates `requestContext.overallCachePolicy` based on static and dynamic hints, and it sets the `Cache-Control` response HTTP header.

In Apollo Server 3, the cache control plugin only sets the `Cache-Control` header when the response is cacheable.

In Apollo Server 4, the cache control plugin also sets the `Cache-Control` header (to the value `no-store`) when the response is not cacheable.

To restore the behavior from Apollo Server 3, you can install the cache control plugin and set `calculateHttpHeaders: 'if-cacheable'`:

<MultiCodeBlock>

```ts
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';

new ApolloServer({
// ...
plugins: [
ApolloServerPluginCacheControl({ calculateHttpHeaders: 'if-cacheable' }),
],
});
```

</MultiCodeBlock>


### `CacheScope` type

In Apollo Server 4, `CacheScope` is now a union of strings (`PUBLIC` or `PRIVATE`) rather than an enum:
Expand Down
13 changes: 10 additions & 3 deletions docs/source/performance/caching.md
Expand Up @@ -128,7 +128,7 @@ You can decide how to cache a particular field's result _while_ you're resolving

The `cacheControl` object includes a `setCacheHint` method, which you call like so:

```ts
```ts
const resolvers = {
Query: {
post: (_, { id }, _, info) => {
Expand Down Expand Up @@ -331,14 +331,21 @@ The effect of setting `honorSubgraphCacheControlHeader` to `false` is to have no

## Caching with a CDN

Whenever Apollo Server sends an operation response that has a non-zero `maxAge`, it includes a `Cache-Control` HTTP header that describes the response's cache policy.
Whenever Apollo Server sends an operation response that is cacheable, it includes a `Cache-Control` HTTP header that describes the response's cache policy.

To be cacheable, all of the following must be true:
- The operation has a non-zero `maxAge`.
- The operation has a single response rather than an [incremental delivery](../workflow/requests#incremental-delivery-experimental) response.
- There are no errors in the response.

The header has this format:
When the response is cacheable, the header has this format:

```
Cache-Control: max-age=60, private
```

When the response is not cacheable, the header has the value `Cache-Control: no-store`.

If you run Apollo Server behind a CDN or another caching proxy, you can configure it to use this header's value to cache responses appropriately. See your CDN's documentation for details (for example, here's the [documentation for Amazon CloudFront](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Expiration.html#expiration-individual-objects)).

Some CDNs require custom headers for caching or custom values in the `cache-control` header like `s-maxage`. You can configure your `ApolloServer` instance accordingly by telling the built-in cache control plugin to just calculate a policy without setting HTTP headers, and specifying your own [plugin](../integrations/plugins):
Expand Down
46 changes: 43 additions & 3 deletions packages/integration-testsuite/src/httpServerTests.ts
Expand Up @@ -817,14 +817,32 @@ export function defineIntegrationTestSuiteHttpServerTests(
expect(res.headers['cache-control']).toEqual('max-age=200, public');
});

it('applies cacheControl Headers with if-cacheable', async () => {
const app = await createApp({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginCacheControl({
calculateHttpHeaders: 'if-cacheable',
}),
],
});
const res = await request(app).post('/').send({
query: `{ cooks { title author } }`,
});
expect(res.status).toEqual(200);
expect(res.body.data).toEqual({ cooks: books });
expect(res.headers['cache-control']).toEqual('max-age=200, public');
});

it('contains no cacheControl Headers when uncacheable', async () => {
const app = await createApp({ typeDefs, resolvers });
const res = await request(app).post('/').send({
query: `{ books { title author } }`,
});
expect(res.status).toEqual(200);
expect(res.body.data).toEqual({ books });
expect(res.headers['cache-control']).toBeUndefined;
expect(res.headers['cache-control']).toBe('no-store');
});

it('contains private cacheControl Headers when scoped', async () => {
Expand Down Expand Up @@ -852,13 +870,18 @@ export function defineIntegrationTestSuiteHttpServerTests(
expect(res.body.data).toEqual({
pooks: [{ title: 'pook', books }],
});
expect(res.headers['cache-control']).toBeUndefined;
expect(res.headers['cache-control']).toBeUndefined();
});
});

it('cache-control not set without any hints', async () => {
it('cache-control not set without any hints with if-cacheable', async () => {
const app = await createApp({
schema,
plugins: [
ApolloServerPluginCacheControl({
calculateHttpHeaders: 'if-cacheable',
}),
],
});
const expected = {
testPerson: { firstName: 'Jane' },
Expand All @@ -873,6 +896,23 @@ export function defineIntegrationTestSuiteHttpServerTests(
});
});

it('cache-control not set without any hints', async () => {
const app = await createApp({
schema,
});
const expected = {
testPerson: { firstName: 'Jane' },
};
const req = request(app).post('/').send({
query: 'query test{ testPerson { firstName } }',
});
return req.then((res) => {
expect(res.status).toEqual(200);
expect(res.body.data).toEqual(expected);
expect(res.headers['cache-control']).toBe('no-store');
});
});

it('cache-control set with dynamic hint', async () => {
const app = await createApp({
schema,
Expand Down
Expand Up @@ -282,7 +282,7 @@ describe('Response caching', () => {
expect(result.body.data.uncached).toBe('value:uncached');
expectCacheMiss('cached');
expectCacheMiss('uncached');
expect(httpHeader(result, 'cache-control')).toBe(null);
expect(httpHeader(result, 'cache-control')).toBe('no-store');
expect(httpHeader(result, 'age')).toBe(null);
}

Expand All @@ -295,7 +295,7 @@ describe('Response caching', () => {
expect(result.body.data.uncached).toBe('value:uncached');
expectCacheMiss('cached');
expectCacheMiss('uncached');
expect(httpHeader(result, 'cache-control')).toBe(null);
expect(httpHeader(result, 'cache-control')).toBe('no-store');
expect(httpHeader(result, 'age')).toBe(null);
}

Expand Down
26 changes: 22 additions & 4 deletions packages/server/src/plugin/cacheControl/index.ts
Expand Up @@ -39,10 +39,15 @@ export interface ApolloServerPluginCacheControlOptions {
*/
defaultMaxAge?: number;
/**
* Determines whether to set the `Cache-Control` HTTP header on cacheable
* responses with no errors. The default is true.
* Determines whether to set the `Cache-Control` HTTP header. If true (the
* default), the header is written on all responses (with a value of
* `no-store` for non-cacheable responses). If `'if-cacheable'`, the header is
* only written for cacheable responses. If false, the header is never
* written. A response is cacheable if its overall cache policy has a non-zero
* `maxAge`, and the body is a single result rather than an incremental
* delivery response, and the body contains no errors.
*/
calculateHttpHeaders?: boolean;
calculateHttpHeaders?: boolean | 'if-cacheable';
// For testing only.
__testing__cacheHints?: Map<string, CacheHint>;
}
Expand Down Expand Up @@ -260,6 +265,12 @@ export function ApolloServerPluginCacheControl(
},

async willSendResponse(requestContext) {
// This hook is just for setting response headers, so make sure that
// hasn't been disabled.
if (!calculateHttpHeaders) {
return;
}

const { response, overallCachePolicy } = requestContext;

const policyIfCacheable = overallCachePolicy.policyIfCacheable();
Expand All @@ -268,7 +279,6 @@ export function ApolloServerPluginCacheControl(
// there are no errors, and we actually can write headers, write the
// header.
if (
calculateHttpHeaders &&
policyIfCacheable &&
// At least for now, we don't set cache-control headers for
// incremental delivery responses, since we don't know if a later
Expand All @@ -285,6 +295,14 @@ export function ApolloServerPluginCacheControl(
policyIfCacheable.maxAge
}, ${policyIfCacheable.scope.toLowerCase()}`,
);
} else if (
calculateHttpHeaders !== 'if-cacheable' &&
!response.http.headers.has('cache-control')
) {
// The response is not cacheable, so make sure it doesn't get
// cached. This is especially important for GET requests, because
// browsers and other agents cache many GET requests by default.
response.http.headers.set('cache-control', 'no-store');
}
},
};
Expand Down

0 comments on commit db5d715

Please sign in to comment.