Skip to content

Commit

Permalink
op-reg: Switch from node-fetch to make-fetch-happen.
Browse files Browse the repository at this point in the history
This is a similar treatment as to what was applied to `@apollo/gateway` in
#3783.

This replaces a "fetcher" implementation we'd been maintaining (which
continues to grow in complexity) which was built on [`node-fetch`][1].
Instead, it switches to an off-the-shelf [Fetch API][2]-compliant
implementation called [`make-fetch-happen`][3] which leverages
[`minipass-fetch`][4] under the hood.  It's also what `npm` uses internally!

Whereas `node-fetch` is relatively bare-bones and necessitated us writing
our own [conditional request][5], `make-fetch-happen` just does these things
(and also other things, like supporting `HTTP_PROXY` out of the box!).

It does, however, need us to provide a cache store since we cannot use its
(default) file-system based cache (which leverages the powerful
[`cacache`][6] implementation used by `npm`) since file-systems are not
available in all of our supported integrations.  Therefore, this duplicates
the same cache implementation used in `@apollo/gateway`.  If there was a
suitable place to depend on this code from both of these packages, we could
depend on this implementation from that location, but I didn't immediately
see a great place for that to live.  Also, rule of threes or something?

(Also worth noting that it _already includes_ the fix which I opened
in #4325 / 2f29c60
which needed to be applied to the gateway implementation.)

This does REMOVE A TEST which was previously valuable but should no longer
be nearly as valuable.  Specifically, since we now have an HTTP
implementation that handles conditional requests, a cache, and retries
itself, we do handle intermediary retries within that layer.  We still test
the polling (i.e, the literal existence of an interval which re-fires) in
other tests, but it was too tricky to try to re-jigger this test with the
abstraction.  I think this is a good thing to not need to worry about, but
we can consider re-adding it in the event of regressions.

[1]: https://npm.im/node-fetch
[2]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
[3]: https://npm.im/make-fetch-happen
[4]: https://npm.im/minipass-fetch
[5]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests
[6]: https://npm.im/cacache
  • Loading branch information
abernix committed Jun 29, 2020
1 parent b5cd43c commit ff2f61c
Show file tree
Hide file tree
Showing 8 changed files with 449 additions and 91 deletions.
341 changes: 338 additions & 3 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/apollo-server-plugin-operation-registry/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Change Log

### vNEXT

- Switch from using our own HTTP _retries_ and _conditional-request_ implementation built on top of [`node-fetch`](https://npm.im/node-fetch) to a third-party "Fetcher" (i.e., a [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)-compliant implementation) called [`make-fetch-happen`](https://npm.im/make-fetch-happen). [PR #TODO]()

### 0.4.1

- __BREAKING__: Use a content delivery network, fetch storage secrets and operation manifests from different domains: https://storage-secrets.api.apollographql.com and https://operations.api.apollographql.com. Please mind any firewall for outgoing traffic.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@
"dependencies": {
"apollo-graphql": "0.4.5",
"apollo-server-caching": "file:../apollo-server-caching",
"apollo-server-env": "file:../apollo-server-env",
"apollo-server-errors": "file:../apollo-server-errors",
"apollo-server-plugin-base": "file:../apollo-server-plugin-base",
"fast-json-stable-stringify": "^2.0.0",
"loglevel": "^1.6.1",
"loglevel-debug": "^0.0.1",
"node-fetch": "^2.3.0"
"make-fetch-happen": "^8.0.7"
},
"peerDependencies": {
"graphql": "^0.12.0 || ^0.13.0 || ^14.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { GraphQLSchema } from 'graphql/type';
import { InMemoryLRUCache } from 'apollo-server-caching';
import loglevel from 'loglevel';
import loglevelDebug from 'loglevel-debug';
import { fetch } from "apollo-server-env";

type ForbidUnregisteredOperationsPredicate = (
requestContext: GraphQLRequestContext,
Expand All @@ -45,6 +46,7 @@ export interface OperationManifest {

export interface Options {
debug?: boolean;
fetcher?: typeof fetch;
forbidUnregisteredOperations?:
| boolean
| ForbidUnregisteredOperationsPredicate;
Expand Down Expand Up @@ -136,6 +138,7 @@ for observability purposes, but all operations will be permitted.`,
engine,
store,
logger,
fetcher: options.fetcher,
});

await agent.start();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,60 +283,6 @@ describe('Agent', () => {
expect(storeDeleteSpy).toBeCalledTimes(0);
});

it('continues polling even after initial failure', async () => {
nockStorageSecret(genericServiceID, genericApiKeyHash);
nockStorageSecretOperationManifest(
genericServiceID,
genericStorageSecret,
500,
);
const store = defaultStore();
const storeSetSpy = jest.spyOn(store, 'set');
const storeDeleteSpy = jest.spyOn(store, 'delete');
const agent = createAgent({ store });
jest.useFakeTimers();
await agent.start();

expect(storeSetSpy).toBeCalledTimes(0);
expect(storeDeleteSpy).toBeCalledTimes(0);

// Only the initial start-up check should have happened by now.
expect(agent._timesChecked).toBe(1);

// If it's one millisecond short of our next poll interval, nothing
// should have changed yet.
jest.advanceTimersByTime(defaultTestAgentPollSeconds * 1000 - 1);

// Still only one check.
expect(agent._timesChecked).toBe(1);
expect(storeSetSpy).toBeCalledTimes(0);

// Now, we'll expect another GOOD request to fulfill, so we'll nock it.
nockStorageSecret(genericServiceID, genericApiKeyHash);
nockGoodManifestsUnderStorageSecret(
genericServiceID,
genericStorageSecret,
[
sampleManifestRecords.a,
sampleManifestRecords.b,
sampleManifestRecords.c,
],
);

// If we move forward the last remaining millisecond, we should trigger
// and end up with a successful sync.
jest.advanceTimersByTime(1);

// While that timer will fire, it will do work async, and we need to
// wait on that work itself.
await agent.requestPending();

// Now the times checked should have gone up.
expect(agent._timesChecked).toBe(2);
// And store should have been called with operations ABC
expect(storeSetSpy).toBeCalledTimes(3);
});

it('purges operations which are removed from the manifest', async () => {
const store = defaultStore();
const storeSetSpy = jest.spyOn(store, 'set');
Expand Down
48 changes: 36 additions & 12 deletions packages/apollo-server-plugin-operation-registry/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import {
} from './common';

import loglevel from 'loglevel';
import fetcher from 'make-fetch-happen';
import { HttpRequestCache } from './cache';

import { Response } from 'node-fetch';
import { InMemoryLRUCache } from 'apollo-server-caching';
import { fetchIfNoneMatch } from './fetchIfNoneMatch';
import { OperationManifest } from "./ApolloServerPluginOperationRegistry";
import { Response, fetch } from "apollo-server-env";

const DEFAULT_POLL_SECONDS: number = 30;
const SYNC_WARN_TIME_SECONDS: number = 60;

export interface AgentOptions {
logger?: loglevel.Logger;
fetcher?: typeof fetch;
pollSeconds?: number;
schemaHash: string;
engine: any;
Expand All @@ -31,6 +33,7 @@ type SignatureStore = Set<string>;
const callToAction = `Ensure this server's schema has been published with 'apollo service:push' and that operations have been registered with 'apollo client:push'.`;

export default class Agent {
private fetcher: typeof fetch;
private timer?: NodeJS.Timer;
private logger: loglevel.Logger;
private hashedServiceId?: string;
Expand All @@ -49,6 +52,8 @@ export default class Agent {

this.logger = this.options.logger || loglevel.getLogger(pluginName);

this.fetcher = this.options.fetcher || getDefaultGcsFetcher();

if (!this.options.schemaHash) {
throw new Error('`schemaHash` must be passed to the Agent.');
}
Expand Down Expand Up @@ -145,11 +150,7 @@ export default class Agent {
this.options.engine.apiKeyHash,
);

const response = await fetchIfNoneMatch(storageSecretUrl, {
method: 'GET',
// More than three times our polling interval should be long enough to wait.
timeout: this.pollSeconds() * 3 /* times */ * 1000 /* ms */,
});
const response = await this.fetcher(storageSecretUrl, this.fetchOptions);

if (response.status === 304) {
this.logger.debug(
Expand Down Expand Up @@ -191,7 +192,7 @@ export default class Agent {
this.options.schemaHash,
);
this.logger.debug(`Checking for manifest changes at ${legacyManifestUrl}`);
return fetchIfNoneMatch(legacyManifestUrl, this.fetchOptions);
return this.fetcher(legacyManifestUrl, this.fetchOptions);
}

private async fetchManifest(): Promise<Response> {
Expand All @@ -212,10 +213,8 @@ export default class Agent {
this.logger.debug(
`Checking for manifest changes at ${storageSecretManifestUrl}`,
);
const response = await fetchIfNoneMatch(
storageSecretManifestUrl,
this.fetchOptions,
);
const response =
await this.fetcher(storageSecretManifestUrl, this.fetchOptions);

if (response.status === 404 || response.status === 403) {
this.logger.warn(
Expand Down Expand Up @@ -340,3 +339,28 @@ export default class Agent {
this.lastOperationSignatures = replacementSignatures;
}
}

const GCS_RETRY_COUNT = 5;

function getDefaultGcsFetcher() {
return fetcher.defaults({
cacheManager: new HttpRequestCache(),
// All headers should be lower-cased here, as `make-fetch-happen`
// treats differently cased headers as unique (unlike the `Headers` object).
// @see: https://git.io/JvRUa
headers: {
'user-agent': [
require('../package.json').name,
require('../package.json').version
].join('/'),
},
retry: {
retries: GCS_RETRY_COUNT,
// The default factor: expected attempts at 0, 1, 3, 7, 15, and 31 seconds elapsed
factor: 2,
// 1 second
minTimeout: 1000,
randomize: true,
},
});
}
66 changes: 66 additions & 0 deletions packages/apollo-server-plugin-operation-registry/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { CacheManager } from 'make-fetch-happen';
import { Request, Response, Headers } from 'apollo-server-env';
import { InMemoryLRUCache } from 'apollo-server-caching';

const MAX_SIZE = 5 * 1024 * 1024; // 5MB

function cacheKey(request: Request) {
return `op-reg:request-cache:${request.method}:${request.url}`;
}

interface CachedRequest {
body: string;
status: number;
statusText: string;
headers: Headers;
}

export class HttpRequestCache implements CacheManager {
constructor(
public cache: InMemoryLRUCache<CachedRequest> = new InMemoryLRUCache({
maxSize: MAX_SIZE,
}),
) {}

// Return true if entry exists, else false
async delete(request: Request) {
const key = cacheKey(request);
const entry = await this.cache.get(key);
await this.cache.delete(key);
return Boolean(entry);
}

async put(request: Request, response: Response) {
// A `HEAD` request has no body to cache and a 304 response could have
// only been negotiated by using a cached body that was still valid.
// Therefore, we do NOT write to the cache in either of these cases.
// Without avoiding this, we will invalidate the cache, thus causing
// subsequent conditional requests (e.g., `If-None-Match: "MD%") to be
// lacking content to conditionally request against and necessitating
// a full request/response.
if (request.method === "HEAD" || response.status === 304) {
return response;
}

const body = await response.text();

this.cache.set(cacheKey(request), {
body,
status: response.status,
statusText: response.statusText,
headers: response.headers,
});

return new Response(body, response);
}

async match(request: Request) {
return this.cache.get(cacheKey(request)).then(response => {
if (response) {
const { body, ...requestInit } = response;
return new Response(body, requestInit);
}
return;
});
}
}

This file was deleted.

0 comments on commit ff2f61c

Please sign in to comment.