Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gateway: introduce make-fetch-happen #3783

Merged
merged 13 commits into from
Feb 20, 2020
Merged
431 changes: 356 additions & 75 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/apollo-gateway/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- __BREAKING__: The behavior and signature of `RemoteGraphQLDataSource`'s `didReceiveResponse` method has been changed. No changes are necessary _unless_ your implementation has overridden the default behavior of this method by either extending the class and overriding the method or by providing `didReceiveResponse` as a parameter to the `RemoteGraphQLDataSource`'s constructor options. Implementations which have provided their own `didReceiveResponse` using either of these methods should view the PR linked here for details on what has changed. [PR #3743](https://github.com/apollographql/apollo-server/pull/3743)
- __NEW__: Setting the `apq` option to `true` on the `RemoteGraphQLDataSource` will enable the use of [automated persisted queries (APQ)](https://www.apollographql.com/docs/apollo-server/performance/apq/) when sending queries to downstream services. Depending on the complexity of queries sent to downstream services, this technique can greatly reduce the size of the payloads being transmitted over the network. Downstream implementing services must also support APQ functionality to participate in this feature (Apollo Server does by default unless it has been explicitly disabled). As with normal APQ behavior, a downstream server must have received and registered a query once before it will be able to serve an APQ request. [#3744](https://github.com/apollographql/apollo-server/pull/3744)
- __NEW__: Experimental feature: compress downstream requests via generated fragments [#3791](https://github.com/apollographql/apollo-server/pull/3791) This feature enables the gateway to generate fragments for queries to downstream services in order to minimize bytes over the wire and parse time. This can be enabled via the gateway config by setting `experimental_autoFragmentization: true`. It is currently disabled by default.
- Introduce `make-fetch-happen` package. Remove `cachedFetcher` in favor of the caching implementation provided by this package. [#3783](https://github.com/apollographql/apollo-server/pull/3783/files)

## v0.12.1

Expand Down
1 change: 1 addition & 0 deletions packages/apollo-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"graphql-extensions": "file:../graphql-extensions",
"loglevel": "^1.6.1",
"loglevel-debug": "^0.0.1",
"make-fetch-happen": "^7.1.1",
"pretty-format": "^24.7.0"
},
"peerDependencies": {
Expand Down
64 changes: 64 additions & 0 deletions packages/apollo-gateway/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { URL, format } from 'url';
import { CacheManager } from 'make-fetch-happen';
import { Request, Response } from 'apollo-server-env';
import { InMemoryLRUCache } from 'apollo-server-caching';

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

/**
* @see: https://github.com/npm/make-fetch-happen/blob/master/cache.js
*/
function cacheKey(request: Request) {
const parsed = new URL(request.url);
const key = `gateway:request-cache:${format({
protocol: parsed.protocol,
slashes: true,
port: parsed.port,
hostname: parsed.hostname,
pathname: parsed.pathname,
})}`;
return key;
}
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved

export class Cache implements CacheManager {
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
constructor(
public cache: InMemoryLRUCache<string> = 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);
}
Comment on lines +26 to +31
Copy link
Member

Choose a reason for hiding this comment

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

Do we actually need to implement a delete method? When does it get called?

Copy link
Member Author

@trevor-scheer trevor-scheer Feb 19, 2020

Choose a reason for hiding this comment

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

Ours is never actually called (at least within our tests), however I do want our exported interface to comply with make-fetch-happen's requirements listed here: https://github.com/npm/make-fetch-happen#--optscachemanager

On that note, I'm unsure if I should export the CacheManager interface from the gateway package or if I can leave it in the make-fetch-happen definition file. Thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

@trevor-scheer I think we should leave it in make-fetch-happen, and avoid exposing this directly to the user, for now.


async put(request: Request, response: Response) {
let body = await response.text();

this.cache.set(
cacheKey(request),
JSON.stringify({
body,
status: response.status,
statusText: response.statusText,
// @ts-ignore - TODO? New types for `.raw()` are going unrecognized by CI for some reason.
headers: response.headers.raw(),
}),
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
);

return new Response(body, response);
}

async match(request: Request) {
return this.cache.get(cacheKey(request)).then(response => {
if (response) {
const { body, ...requestInit } = JSON.parse(response);
return new Response(body, requestInit);
}
return;
});
}
}
69 changes: 0 additions & 69 deletions packages/apollo-gateway/src/cachedFetcher.ts

This file was deleted.

13 changes: 11 additions & 2 deletions packages/apollo-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import { GraphQLDataSource } from './datasources/types';
import { RemoteGraphQLDataSource } from './datasources/RemoteGraphQLDataSource';
import { HeadersInit } from 'node-fetch';
import { getVariableValues } from 'graphql/execution/values';
import { CachedFetcher } from './cachedFetcher';
import fetcher, { Fetcher } from 'make-fetch-happen';
import { Cache } from './cache';

export type ServiceEndpointDefinition = Pick<ServiceDefinition, 'name' | 'url'>;

Expand All @@ -60,6 +61,7 @@ interface GatewayConfigBase {
experimental_pollInterval?: number;
experimental_approximateQueryPlanStoreMiB?: number;
experimental_autoFragmentization?: boolean;
fetcher?: Fetcher;
}

interface RemoteGatewayConfig extends GatewayConfigBase {
Expand Down Expand Up @@ -161,7 +163,10 @@ export class ApolloGateway implements GraphQLService {
private serviceDefinitions: ServiceDefinition[] = [];
private compositionMetadata?: CompositionMetadata;
private serviceSdlCache = new Map<string, string>();
private fetcher = new CachedFetcher();

private fetcher: Fetcher = fetcher.defaults({
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
cacheManager: new Cache()
});

// Observe query plan, service info, and operation info prior to execution.
// The information made available here will give insight into the resulting
Expand Down Expand Up @@ -247,6 +252,10 @@ export class ApolloGateway implements GraphQLService {
'If you are polling running services, use with caution.',
);
}

if (config.fetcher) {
this.fetcher = config.fetcher;
}
}
}

Expand Down
46 changes: 19 additions & 27 deletions packages/apollo-gateway/src/loadServicesFromStorage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CachedFetcher } from './cachedFetcher';
import { Fetcher } from 'make-fetch-happen';
import { parse } from 'graphql';
import { Experimental_UpdateServiceDefinitions } from '.';

Expand Down Expand Up @@ -61,54 +61,46 @@ export async function getServiceDefinitionsFromStorage({
apiKeyHash: string;
graphVariant?: string;
federationVersion: number;
fetcher: CachedFetcher;
fetcher: Fetcher;
}): ReturnType<Experimental_UpdateServiceDefinitions> {
// fetch the storage secret
const storageSecretUrl = getStorageSecretUrl(graphId, apiKeyHash);
const response = await fetcher.fetch(storageSecretUrl);
const secret = JSON.parse(response.result);

const secret: string = await fetcher(storageSecretUrl).then(response =>
response.json(),
);

if (!graphVariant) {
graphVariant = 'current';
}

const baseUrl = `${urlPartialSchemaBase}/${secret}/${graphVariant}/v${federationVersion}`;

const {
isCacheHit: linkFileCacheHit,
result: linkFileResult,
} = await fetcher.fetch(`${baseUrl}/composition-config-link`);

// If the link file is a cache hit, no further work is needed
if (linkFileCacheHit) return { isNewSchema: false };
const response = await fetcher(`${baseUrl}/composition-config-link`);

const parsedLink = JSON.parse(linkFileResult) as LinkFileResult;
if (response.status === 304) {
return { isNewSchema: false };
}

const { result: configFileResult } = await fetcher.fetch(
`${urlPartialSchemaBase}/${parsedLink.configPath}`,
);
const linkFileResult: LinkFileResult = await response.json();

const compositionMetadata = JSON.parse(
configFileResult,
) as CompositionMetadata;
const compositionMetadata: CompositionMetadata = await fetcher(
`${urlPartialSchemaBase}/${linkFileResult.configPath}`,
).then(response => response.json());

// It's important to maintain the original order here
const serviceDefinitions = await Promise.all(
compositionMetadata.implementingServiceLocations.map(
async ({ name, path }) => {
const serviceLocation = await fetcher.fetch(
const { url, partialSchemaPath }: ImplementingService = await fetcher(
`${urlPartialSchemaBase}/${path}`,
);

const { url, partialSchemaPath } = JSON.parse(
serviceLocation.result,
) as ImplementingService;
).then(response => response.json());

const { result } = await fetcher.fetch(
const sdl = await fetcher(
`${urlPartialSchemaBase}/${partialSchemaPath}`,
);
).then(response => response.text());

return { name, url, typeDefs: parse(result) };
return { name, url, typeDefs: parse(sdl) };
},
),
);
Expand Down
40 changes: 40 additions & 0 deletions packages/apollo-gateway/src/make-fetch-happen.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
declare module 'make-fetch-happen' {
import { Response, Request } from 'apollo-server-env';

// If adding to these options, they should mirror those from `make-fetch-happen`
// @see: https://github.com/npm/make-fetch-happen/#extra-options
export interface FetcherOptions {
abernix marked this conversation as resolved.
Show resolved Hide resolved
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
cacheManager?: string | CacheManager;
// @see: https://www.npmjs.com/package/retry#retrytimeoutsoptions
retry?:
| boolean
| number
| {
// The maximum amount of times to retry the operation. Default is 10. Seting this to 1 means do it once, then retry it once
retries?: number;
// The exponential factor to use. Default is 2.
factor?: number;
// The number of milliseconds before starting the first retry. Default is 1000.
minTimeout?: number;
// The maximum number of milliseconds between two retries. Default is Infinity.
maxTimeout?: number;
// Randomizes the timeouts by multiplying with a factor between 1 to 2. Default is false.
randomize?: boolean;
};
onRetry(): void;
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved
}

export interface CacheManager {
delete(req: Request): Promise<Boolean>;
put(req: Request, res: Response): Promise<Response>;
match(req: Request): Promise<Response | undefined>;
}
export interface Fetcher {
(url: string): Promise<Response>;
Copy link
Member

Choose a reason for hiding this comment

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

The fetch that make-fetch-happen still accepts a Request (in place of string for the first argument), but also options passed as the second argument (known as "init", by the specification which are represented by the RequestInit type. That type lives on the dom lib in TypeScript (warning, large file), but we intentionally have a rough-copy of it in our apollo-server-env:

https://github.com/apollographql/apollo-server/blob/bbe3dee5bce50f39459b92192edf3b58b89125fb/packages/apollo-server-env/dist/fetch.d.ts#L47-L71

Additionally, I think that the default options that fetch accepts in RequestInit are extended with those supported by make-fetch-happen — represented (but I haven't validated the completeness of it) by FetcherOptions above.

I think we might want something like this:

  import { fetch as envFetch } from 'apollo-server-env';
  export interface Fetcher {
    (
      input?: Parameters<typeof envFetch>[0],
      init?: Parameters<typeof envFetch>[1] & FetcherOptions,
    ): ReturnType<typeof envFetch>;
  }

Copy link
Member Author

Choose a reason for hiding this comment

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

From a code readability standpoint, I chose to use the types themselves and effectively dupe the fetch type here: 2b3f234

I won't deny that your approach couples the types and some potential maintenance burden of duplicated types. Interested to know your thoughts, since I'm pretty torn, tbh.

Copy link
Member

Choose a reason for hiding this comment

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

I guess my perspective is exactly that. In other words, I suggested this because I don't really care to keep myself up to date on what new options the Fetch API eventually introduces; there was a new draft of the specification that landed in January. The, e.g., node-fetch package is likely going to stay on top of that, and I'm glad they will.

However, my suggestion here doesn't solve an existing problem — we already maintain our own Fetch types in apollo-server-env. 😬 More than anything, I wish that were not the case right now and my biggest concern is that we continue in that direction — we already have apollo-env and apollo-server-env in some of the @apollo/* packages.

Let's certainly ship this how it is for now, but just keep our eyes on it.

defaults(opts?: any): Fetcher;
}

let fetch: Fetcher;
trevor-scheer marked this conversation as resolved.
Show resolved Hide resolved

export default fetch;
}