Skip to content

Commit 8ca2c11

Browse files
authored
Backport usage reporting improvements #7101 to AS3 (#7106)
We only require Node 12 here but that's still enough for the zlib API change. We don't bother to add `signal` to the `apollo-server-env` RequestInit. The integration test does show that it works with the default fetcher (`node-fetch` via `apollo-server-env`).
1 parent 0e8d85f commit 8ca2c11

File tree

6 files changed

+89
-37
lines changed

6 files changed

+89
-37
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ The version headers in this history reflect the versions of Apollo Server itself
1010

1111
## vNEXT
1212

13+
## v3.10.4
14+
15+
- `apollo-server-core`: Manage memory more efficiently in the usage reporting plugin by allowing large objects to be garbage collected more quickly. [PR #7106](https://github.com/apollographql/apollo-server/pull/7106)
16+
- `apollo-server-core`: The usage reporting plugin now defaults to a 30 second timeout for each attempt to send reports to Apollo Server instead of no timeout; the timeout can be adjusted with the new `requestTimeoutMs` option to `ApolloServerPluginUsageReporting`. (Apollo's servers already enforced a 30 second timeout, so this is unlikely to break any existing use cases.) [PR #7106](https://github.com/apollographql/apollo-server/pull/7106)
17+
1318
## v3.10.3
19+
1420
- `apollo-server-core`: Fix memory leak in usage reporting plugin. [Issue #6983](https://github.com/apollographql/apollo-server/issues/6983) [PR #6999](https://github.com/apollographql/apollo-server/[Issue #6983](https://github.com/apollographql/apollo-server/issues/6983)pull/6999)
1521

1622
## v3.10.2

package-lock.json

+14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/apollo-server-core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"graphql-tag": "^2.11.0",
4545
"loglevel": "^1.6.8",
4646
"lru-cache": "^6.0.0",
47+
"node-abort-controller": "^3.0.1",
4748
"sha.js": "^2.4.11",
4849
"uuid": "^9.0.0",
4950
"whatwg-mimetype": "^3.0.0"

packages/apollo-server-core/src/plugin/usageReporting/options.ts

+6
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,12 @@ export interface ApolloServerPluginUsageReportingOptions<TContext> {
258258
* Minimum back-off for retries. Defaults to 100ms.
259259
*/
260260
minimumRetryDelayMs?: number;
261+
/**
262+
* Default timeout for each individual attempt to send a report to Apollo.
263+
* (This is for each HTTP POST, not for all potential retries.) Defaults to 30
264+
* seconds (30000ms).
265+
*/
266+
requestTimeoutMs?: number;
261267
/**
262268
* A logger interface to be used for output and errors. When not provided
263269
* it will default to the server's own `logger` implementation and use

packages/apollo-server-core/src/plugin/usageReporting/plugin.ts

+44-28
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import os from 'os';
2+
import { promisify } from 'util';
23
import { gzip } from 'zlib';
34
import retry from 'async-retry';
45
import { Report, ReportHeader, Trace } from 'apollo-reporting-protobuf';
5-
import { Response, fetch, Headers } from 'apollo-server-env';
6+
import { Response, fetch, Headers, RequestInit } from 'apollo-server-env';
7+
import { AbortController } from 'node-abort-controller';
68
import type {
79
GraphQLRequestListener,
810
GraphQLServerListener,
@@ -38,6 +40,8 @@ import {
3840
} from '@apollo/utils.usagereporting';
3941
import type LRUCache from 'lru-cache';
4042

43+
const gzipPromise = promisify(gzip);
44+
4145
const reportHeaderDefaults = {
4246
hostname: os.hostname(),
4347
agentVersion: `apollo-server-core@${
@@ -238,7 +242,7 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
238242

239243
// Needs to be an arrow function to be confident that key is defined.
240244
const sendReport = async (executableSchemaId: string): Promise<void> => {
241-
const report = getAndDeleteReport(executableSchemaId);
245+
let report = getAndDeleteReport(executableSchemaId);
242246
if (
243247
!report ||
244248
(Object.keys(report.tracesPerQuery).length === 0 &&
@@ -255,9 +259,12 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
255259

256260
const protobufError = Report.verify(report);
257261
if (protobufError) {
258-
throw new Error(`Error encoding report: ${protobufError}`);
262+
throw new Error(`Error verifying report: ${protobufError}`);
259263
}
260-
const message = Report.encode(report).finish();
264+
let message: Uint8Array | null = Report.encode(report).finish();
265+
// Let the original protobuf object be garbage collected (helpful if the
266+
// HTTP request hangs).
267+
report = null;
261268

262269
// Potential follow-up: we can compare message.length to
263270
// report.sizeEstimator.bytes and use it to "learn" if our estimation is
@@ -282,35 +289,26 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
282289
);
283290
}
284291

285-
const compressed = await new Promise<Buffer>((resolve, reject) => {
286-
// The protobuf library gives us a Uint8Array. Node 8's zlib lets us
287-
// pass it directly; convert for the sake of Node 6. (No support right
288-
// now for Node 4, which lacks Buffer.from.)
289-
const messageBuffer = Buffer.from(
290-
message.buffer as ArrayBuffer,
291-
message.byteOffset,
292-
message.byteLength,
293-
);
294-
gzip(messageBuffer, (err, gzipResult) => {
295-
if (err) {
296-
reject(err);
297-
} else {
298-
resolve(gzipResult);
299-
}
300-
});
301-
});
292+
const compressed = await gzipPromise(message);
293+
// Let the uncompressed message be garbage collected (helpful if the
294+
// HTTP request is slow).
295+
message = null;
302296

303297
// Wrap fetcher with async-retry for automatic retrying
304298
const fetcher = options.fetcher ?? fetch;
305299
const response: Response = await retry(
306300
// Retry on network errors and 5xx HTTP
307301
// responses.
308302
async () => {
309-
const curResponse = await fetcher(
310-
(options.endpointUrl ||
311-
'https://usage-reporting.api.apollographql.com') +
312-
'/api/ingress/traces',
313-
{
303+
// Note that once we require Node v16 we can use its global
304+
// AbortController instead of the one from `node-abort-controller`.
305+
const controller = new AbortController();
306+
const abortTimeout = setTimeout(() => {
307+
controller.abort();
308+
}, options.requestTimeoutMs ?? 30_000);
309+
let curResponse;
310+
try {
311+
const requestInit: RequestInit = {
314312
method: 'POST',
315313
headers: {
316314
'user-agent': 'ApolloServerPluginUsageReporting',
@@ -320,8 +318,26 @@ export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
320318
},
321319
body: compressed,
322320
agent: options.requestAgent,
323-
},
324-
);
321+
};
322+
// The apollo-server-env Fetch API doesn't have `signal` in
323+
// RequestInit, but it does work in node-fetch. We've added it
324+
// already to our `Fetcher` interface (`@apollo/utils.fetcher`)
325+
// that we're using in AS4 but making changes to
326+
// `apollo-server-env` that could cause custom AS3 fetchers to not
327+
// compile feels like a bad idea. The worst case scenario of
328+
// passing in an ignored `signal` is the timeout doesn't work, in
329+
// which case you're not getting the new feature but can change
330+
// your fetcher to make it work.
331+
(requestInit as any).signal = controller.signal;
332+
curResponse = await fetcher(
333+
(options.endpointUrl ||
334+
'https://usage-reporting.api.apollographql.com') +
335+
'/api/ingress/traces',
336+
requestInit,
337+
);
338+
} finally {
339+
clearTimeout(abortTimeout);
340+
}
325341

326342
if (curResponse.status >= 500 && curResponse.status < 600) {
327343
throw new Error(

packages/apollo-server-integration-testsuite/src/ApolloServer.ts

+18-9
Original file line numberDiff line numberDiff line change
@@ -1983,21 +1983,20 @@ export function testApolloServer<AS extends ApolloServerBase>(
19831983

19841984
describe('graphql server functions even when Apollo servers are down', () => {
19851985
async function testWithStatus(
1986-
status: number,
1986+
status: number | 'cannot-connect' | 'timeout',
19871987
expectedRequestCount: number,
19881988
) {
1989-
const networkError = status === 0;
1990-
19911989
const { closeServer, fakeUsageReportingUrl, writeResponseResolve } =
19921990
await makeFakeUsageReportingServer({
1993-
status,
1991+
// the 444 case shouldn't ever get to actually sending 444
1992+
status: typeof status === 'number' ? status : 444,
19941993
waitWriteResponse: true,
19951994
});
19961995

19971996
try {
19981997
// To simulate a network error, we create and close the server.
19991998
// This lets us still generate a port that is hopefully unused.
2000-
if (networkError) {
1999+
if (status == 'cannot-connect') {
20012000
await closeServer();
20022001
}
20032002

@@ -2034,6 +2033,8 @@ export function testApolloServer<AS extends ApolloServerBase>(
20342033
reportErrorFunction(error: Error) {
20352034
reportErrorPromiseResolve(error);
20362035
},
2036+
// Make sure the timeout test actually finishes in time
2037+
requestTimeoutMs: status === 'timeout' ? 10 : undefined,
20372038
}),
20382039
],
20392040
});
@@ -2049,27 +2050,32 @@ export function testApolloServer<AS extends ApolloServerBase>(
20492050
});
20502051
expect(result.data.something).toBe('hello');
20512052

2052-
if (!networkError) {
2053+
if (typeof status === 'number') {
20532054
// Allow reporting to return its response (for every retry).
2055+
// (But not if we're intentionally timing out!)
20542056
writeResponseResolve();
20552057
}
20562058

20572059
// Make sure we can get the error from reporting.
20582060
const sendingError = await reportErrorPromise;
20592061
expect(sendingError).toBeTruthy();
2060-
if (networkError) {
2062+
if (status === 'cannot-connect') {
20612063
expect(sendingError.message).toContain(
20622064
'Error sending report to Apollo servers',
20632065
);
20642066
expect(sendingError.message).toContain('ECONNREFUSED');
2067+
} else if (status === 'timeout') {
2068+
expect(sendingError.message).toBe(
2069+
'Error sending report to Apollo servers: The user aborted a request.',
2070+
);
20652071
} else {
20662072
expect(sendingError.message).toBe(
20672073
`Error sending report to Apollo servers: HTTP status ${status}, Important text in the body`,
20682074
);
20692075
}
20702076
expect(requestCount).toBe(expectedRequestCount);
20712077
} finally {
2072-
if (!networkError) {
2078+
if (status !== 'cannot-connect') {
20732079
await closeServer();
20742080
}
20752081
}
@@ -2079,7 +2085,10 @@ export function testApolloServer<AS extends ApolloServerBase>(
20792085
await testWithStatus(500, 3);
20802086
});
20812087
it('with network error', async () => {
2082-
await testWithStatus(0, 3);
2088+
await testWithStatus('cannot-connect', 3);
2089+
});
2090+
it('with timeout', async () => {
2091+
await testWithStatus('timeout', 3);
20832092
});
20842093
it('with non-retryable error', async () => {
20852094
await testWithStatus(400, 1);

0 commit comments

Comments
 (0)