Skip to content

fix(gateway): Prevent inoperable state on initial failure to load configuration #4277

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

Merged
merged 14 commits into from
Jul 27, 2020
Merged
Show file tree
Hide file tree
Changes from 5 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
41 changes: 37 additions & 4 deletions packages/apollo-gateway/src/__tests__/gateway/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@ import gql from 'graphql-tag';
import { ApolloGateway } from '../../';
import { fixtures } from '../__fixtures__/schemas/';
import { ApolloServer } from "apollo-server";
import { Logger } from 'apollo-server-types';

let logger: Logger;

beforeEach(() => {
const warn = jest.fn();
const debug = jest.fn();
const error = jest.fn();
const info = jest.fn();

logger = {
warn,
debug,
error,
info,
};
});

describe('ApolloGateway executor', () => {
it('validates requests prior to execution', async () => {
Expand All @@ -25,30 +42,46 @@ describe('ApolloGateway executor', () => {
queryHash: 'hashed',
context: null,
cache: {} as any,
logger,
});

expect(errors![0].message).toMatch(
'Variable "$first" got invalid value "3"; Expected type Int.',
'Variable "$first" got invalid value "3";',
);
});

it('still sets the ApolloServer executor on load rejection', async () => {
jest.spyOn(console, 'error').mockImplementation();

const gateway = new ApolloGateway({
// Empty service list will throw, which is what we want.
// Empty service list will trigger the gateway to crash on load, which is what we want.
serviceList: [],
logger,
});

// Mock implementation of process.exit with another () => never function.
// This is because the gateway doesn't just throw in this scenario, it crashes.
const mockExit = jest
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new Error(code?.toString());
});

const server = new ApolloServer({
gateway,
subscriptions: false,
logger,
});

// Ensure the throw happens to maintain the correctness of this test.
await expect(
server.executeOperation({ query: '{ __typename }' })).rejects.toThrow();

expect(server.requestOptions.executor).toBe(gateway.executor);

expect(logger.error.mock.calls).toEqual([
["Error checking for changes to service definitions: Tried to load services from remote endpoints but none provided"],
["This data graph is missing a valid configuration. 1"]
]);

mockExit.mockRestore();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
books,
documents,
} from '../__fixtures__/schemas/';
import { Logger } from 'apollo-server-types';

// The order of this was specified to preserve existing test coverage. Typically
// we would just import and use the `fixtures` array.
Expand All @@ -29,6 +30,22 @@ const serviceDefinitions = [
url: `http://localhost:${i}`,
}));

let logger: Logger;

beforeEach(() => {
const warn = jest.fn();
const debug = jest.fn();
const error = jest.fn();
const info = jest.fn();

logger = {
warn,
debug,
error,
info,
};
});

Comment on lines +33 to +48
Copy link
Member

Choose a reason for hiding this comment

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

I have also been repeating this pattern in many places. Perhaps at some point soon (not now), we should just make a spyableLogger?

describe('lifecycle hooks', () => {
it('uses updateServiceDefinitions override', async () => {
const experimental_updateServiceDefinitions: Experimental_UpdateServiceDefinitions = jest.fn(
Expand All @@ -41,6 +58,7 @@ describe('lifecycle hooks', () => {
serviceList: serviceDefinitions,
experimental_updateServiceDefinitions,
experimental_didUpdateComposition: jest.fn(),
logger,
});

await gateway.load();
Expand All @@ -49,7 +67,7 @@ describe('lifecycle hooks', () => {
expect(gateway.schema!.getType('Furniture')).toBeDefined();
});

it('calls experimental_didFailComposition with a bad config', async done => {
it('calls experimental_didFailComposition with a bad config', async () => {
const experimental_didFailComposition = jest.fn();

const gateway = new ApolloGateway({
Expand All @@ -65,10 +83,20 @@ describe('lifecycle hooks', () => {
isNewSchema: true,
};
},
serviceList: [],
experimental_didFailComposition,
logger,
});

// Mock implementation of process.exit with another () => never function.
const mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(code?.toString());
});

await expect(gateway.load()).rejects.toThrowError();
// Confirm the gateway exits with code 1
await expect(gateway.load()).rejects.toThrowError('1');

mockExit.mockRestore();

const callbackArgs = experimental_didFailComposition.mock.calls[0][0];
expect(callbackArgs.serviceList).toHaveLength(1);
Expand All @@ -77,7 +105,6 @@ describe('lifecycle hooks', () => {
);
expect(callbackArgs.compositionMetadata.id).toEqual('abc');
expect(experimental_didFailComposition).toBeCalled();
done();
});

it('calls experimental_didUpdateComposition on schema update', async () => {
Expand Down Expand Up @@ -126,8 +153,10 @@ describe('lifecycle hooks', () => {
const gateway = new ApolloGateway({
experimental_updateServiceDefinitions: mockUpdate,
experimental_didUpdateComposition: mockDidUpdate,
experimental_pollInterval: 100,
logger,
});
// @ts-ignore for testing purposes, a short pollInterval is ideal so we'll override here
gateway.experimental_pollInterval = 100;

let resolve1: Function;
let resolve2: Function;
Expand All @@ -141,14 +170,13 @@ describe('lifecycle hooks', () => {
.mockImplementationOnce(() => resolve2()),
);

gateway.load();
await gateway.load();

await schemaChangeBlocker1;
expect(mockUpdate).toBeCalledTimes(1);
expect(mockDidUpdate).toBeCalledTimes(1);

await schemaChangeBlocker2;

expect(mockUpdate).toBeCalledTimes(2);
expect(mockDidUpdate).toBeCalledTimes(2);

Expand All @@ -164,13 +192,12 @@ describe('lifecycle hooks', () => {
// second call should have previous info in the second arg
expect(secondCall[1]!.schema).toBeDefined();
expect(secondCall[1]!.compositionMetadata!.schemaHash).toEqual('hash1');

jest.useRealTimers();
});

it('uses default service definition updater', async () => {
const gateway = new ApolloGateway({
localServiceList: serviceDefinitions,
logger,
});

const { schema } = await gateway.load();
Expand All @@ -183,16 +210,15 @@ describe('lifecycle hooks', () => {
});

it('warns when polling on the default fetcher', async () => {
const consoleSpy = jest.spyOn(console, 'warn');
new ApolloGateway({
serviceList: serviceDefinitions,
experimental_pollInterval: 10,
logger,
});
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy.mock.calls[0][0]).toMatch(
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Polling running services is dangerous and not recommended in production. Polling should only be used against a registry. If you are polling running services, use with caution.',
);
consoleSpy.mockRestore();
});

it('registers schema change callbacks when experimental_pollInterval is set for unmanaged configs', async () => {
Expand All @@ -206,6 +232,7 @@ describe('lifecycle hooks', () => {
serviceList: [{ name: 'book', url: 'http://localhost:32542' }],
experimental_updateServiceDefinitions,
experimental_pollInterval: 100,
logger,
});

let resolve: Function;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ it.each([
spyGetServiceDefinitionsFromStorage.mockRestore();
});

it('Rollsback to a previous schema when triggered', async () => {
it.skip('Rollsback to a previous schema when triggered', async () => {
// Init
mockStorageSecretSuccess();
mockCompositionConfigLinkSuccess();
Expand Down Expand Up @@ -221,13 +221,13 @@ it('Rollsback to a previous schema when triggered', async () => {
await gateway.load({ engine: { apiKeyHash, graphId } });

await firstSchemaChangeBlocker;
expect(onChange.mock.calls.length).toBe(1);
expect(onChange).toHaveBeenCalledTimes(1);

await secondSchemaChangeBlocker;
expect(onChange.mock.calls.length).toBe(2);
expect(onChange).toHaveBeenCalledTimes(2);

await thirdSchemaChangeBlocker;
expect(onChange.mock.calls.length).toBe(3);
expect(onChange).toHaveBeenCalledTimes(3);
});

function failNTimes(n: number, fn: () => nock.Interceptor) {
Expand Down Expand Up @@ -305,7 +305,7 @@ describe('Downstream service health checks', () => {
expect(gateway.schema!.getType('User')!.description).toBe('This is my User');
});

it(`Rejects on initial load when health check fails`, async () => {
it(`Crashes on initial load when health check fails`, async () => {
mockSDLQuerySuccess(service);
mockServiceHealthCheck(service).reply(500);

Expand All @@ -315,9 +315,16 @@ describe('Downstream service health checks', () => {
logger,
});

await expect(gateway.load()).rejects.toThrowErrorMatchingInlineSnapshot(
`"500: Internal Server Error"`,
);
// Mock implementation of process.exit with another () => never function.
const mockExit = jest
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new Error(code?.toString());
});

await expect(gateway.load()).rejects.toThrowError('1');

mockExit.mockRestore();
});
});

Expand Down Expand Up @@ -353,7 +360,7 @@ describe('Downstream service health checks', () => {
).rejects.toThrowErrorMatchingInlineSnapshot(`"500: Internal Server Error"`);
});

it('Rolls over to new schema when health check succeeds', async () => {
it.skip('Rolls over to new schema when health check succeeds', async () => {
mockStorageSecretSuccess();
mockCompositionConfigLinkSuccess();
mockCompositionConfigsSuccess([service]);
Expand Down Expand Up @@ -386,13 +393,15 @@ describe('Downstream service health checks', () => {
gateway.experimental_pollInterval = 100;

gateway.onSchemaChange(onChange);
gateway.load({ engine: { apiKeyHash, graphId } });
await gateway.load({ engine: { apiKeyHash, graphId } });

await schemaChangeBlocker1;
expect(onChange.mock.calls.length).toBe(1);
expect(gateway.schema!.getType('User')!.description).toBe('This is my User');
expect(onChange).toHaveBeenCalledTimes(1);

await schemaChangeBlocker2;
expect(gateway.schema!.getType('User')!.description).toBe('This is my updated User');
expect(onChange).toHaveBeenCalledTimes(2);
});

it('Preserves original schema when health check fails', async () => {
Expand Down Expand Up @@ -426,12 +435,14 @@ describe('Downstream service health checks', () => {
// fails (as expected) so we get creative with the second mock as seen below.
const original = gateway.updateComposition;
const mockUpdateComposition = jest
.fn(original)
.mockImplementationOnce(original)
.mockImplementationOnce(async opts => {
.fn()
.mockImplementationOnce(async () => {
await original.apply(gateway);
})
.mockImplementationOnce(async () => {
// mock the first poll and handle the error which would otherwise be caught
// and logged from within the `pollServices` class method
await expect(original.apply(gateway, [opts]))
await expect(original.apply(gateway))
.rejects
.toThrowErrorMatchingInlineSnapshot(
`"500: Internal Server Error"`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,5 @@ export function mockRawPartialSchema({ partialSchemaPath }: MockService) {
}

export function mockRawPartialSchemaSuccess(service: MockService) {
mockRawPartialSchema(service).reply(200, service.sdl);
return mockRawPartialSchema(service).reply(200, service.sdl);
}
Loading