Skip to content

Commit 88ff2d0

Browse files
authored
feat: warn on too many concurrent requests (#165)
* feat: warn on too many concurrent requests * fix: decrease concurrent request count on error too * fix: do not use scientific notation, link to multipart GH issue for skipped tests * feat: expose teeny stats, allow resetting stats, allow reading copy of options * chore: remove unused proxyquire dev dep
1 parent 2ab8155 commit 88ff2d0

File tree

4 files changed

+688
-1
lines changed

4 files changed

+688
-1
lines changed

src/TeenyStatistics.ts

+199
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*!
2+
* Copyright 2020 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export interface TeenyStatisticsOptions {
18+
/**
19+
* A positive number representing when to issue a warning about the number
20+
* of concurrent requests using teeny-request.
21+
* Set to 0 to disable this warning.
22+
* Corresponds to the TEENY_REQUEST_WARN_CONCURRENT_REQUESTS environment
23+
* variable.
24+
*/
25+
concurrentRequests?: number;
26+
}
27+
28+
type TeenyStatisticsConfig = Required<TeenyStatisticsOptions>;
29+
30+
/**
31+
* TeenyStatisticsCounters is distinct from TeenyStatisticsOptions:
32+
* Used when dumping current counters and other internal metrics.
33+
*/
34+
export interface TeenyStatisticsCounters {
35+
concurrentRequests: number;
36+
}
37+
38+
/**
39+
* @class TeenyStatisticsWarning
40+
* @extends Error
41+
* @description While an error, is used for emitting warnings when
42+
* meeting certain configured thresholds.
43+
* @see process.emitWarning
44+
*/
45+
export class TeenyStatisticsWarning extends Error {
46+
static readonly CONCURRENT_REQUESTS = 'ConcurrentRequestsExceededWarning';
47+
48+
public threshold = 0;
49+
public type = '';
50+
public value = 0;
51+
52+
/**
53+
* @param {string} message
54+
*/
55+
constructor(message: string) {
56+
super(message);
57+
this.name = this.constructor.name;
58+
Error.captureStackTrace(this, this.constructor);
59+
}
60+
}
61+
62+
/**
63+
* @class TeenyStatistics
64+
* @description Maintain various statistics internal to teeny-request. Tracking
65+
* is not automatic and must be instrumented within teeny-request.
66+
*/
67+
export class TeenyStatistics {
68+
/**
69+
* @description A default threshold representing when to warn about excessive
70+
* in-flight/concurrent requests.
71+
* @type {number}
72+
* @static
73+
* @readonly
74+
* @default 5000
75+
*/
76+
static readonly DEFAULT_WARN_CONCURRENT_REQUESTS = 5000;
77+
78+
/**
79+
* @type {TeenyStatisticsConfig}
80+
* @private
81+
*/
82+
private _options: TeenyStatisticsConfig;
83+
84+
/**
85+
* @type {number}
86+
* @private
87+
* @default 0
88+
*/
89+
private _concurrentRequests = 0;
90+
91+
/**
92+
* @type {boolean}
93+
* @private
94+
* @default false
95+
*/
96+
private _didConcurrentRequestWarn = false;
97+
98+
/**
99+
* @param {TeenyStatisticsOptions} [opts]
100+
*/
101+
constructor(opts?: TeenyStatisticsOptions) {
102+
this._options = TeenyStatistics._prepareOptions(opts);
103+
}
104+
105+
/**
106+
* Returns a copy of the current options.
107+
* @return {TeenyStatisticsOptions}
108+
*/
109+
getOptions(): TeenyStatisticsOptions {
110+
return Object.assign({}, this._options);
111+
}
112+
113+
/**
114+
* Change configured statistics options. This will not preserve unspecified
115+
* options that were previously specified, i.e. this is a reset of options.
116+
* @param {TeenyStatisticsOptions} [opts]
117+
* @returns {TeenyStatisticsConfig} The previous options.
118+
* @see _prepareOptions
119+
*/
120+
setOptions(opts?: TeenyStatisticsOptions): TeenyStatisticsConfig {
121+
const oldOpts = this._options;
122+
this._options = TeenyStatistics._prepareOptions(opts);
123+
return oldOpts;
124+
}
125+
126+
/**
127+
* @readonly
128+
* @return {TeenyStatisticsCounters}
129+
*/
130+
get counters(): TeenyStatisticsCounters {
131+
return {
132+
concurrentRequests: this._concurrentRequests,
133+
};
134+
}
135+
136+
/**
137+
* @description Should call this right before making a request.
138+
*/
139+
requestStarting(): void {
140+
this._concurrentRequests++;
141+
142+
if (
143+
this._options.concurrentRequests > 0 &&
144+
this._concurrentRequests >= this._options.concurrentRequests &&
145+
!this._didConcurrentRequestWarn
146+
) {
147+
this._didConcurrentRequestWarn = true;
148+
const warning = new TeenyStatisticsWarning(
149+
'Possible excessive concurrent requests detected. ' +
150+
this._concurrentRequests +
151+
' requests in-flight, which exceeds the configured threshold of ' +
152+
this._options.concurrentRequests +
153+
'. Use the TEENY_REQUEST_WARN_CONCURRENT_REQUESTS environment ' +
154+
'variable or the concurrentRequests option of teeny-request to ' +
155+
'increase or disable (0) this warning.'
156+
);
157+
warning.type = TeenyStatisticsWarning.CONCURRENT_REQUESTS;
158+
warning.value = this._concurrentRequests;
159+
warning.threshold = this._options.concurrentRequests;
160+
process.emitWarning(warning);
161+
}
162+
}
163+
164+
/**
165+
* @description When using `requestStarting`, call this after the request
166+
* has finished.
167+
*/
168+
requestFinished() {
169+
// TODO negative?
170+
this._concurrentRequests--;
171+
}
172+
173+
/**
174+
* Configuration Precedence:
175+
* 1. Dependency inversion via defined option.
176+
* 2. Global numeric environment variable.
177+
* 3. Built-in default.
178+
* This will not preserve unspecified options previously specified.
179+
* @param {TeenyStatisticsOptions} [opts]
180+
* @returns {TeenyStatisticsOptions}
181+
* @private
182+
*/
183+
private static _prepareOptions({
184+
concurrentRequests: diConcurrentRequests,
185+
}: TeenyStatisticsOptions = {}): TeenyStatisticsConfig {
186+
let concurrentRequests = this.DEFAULT_WARN_CONCURRENT_REQUESTS;
187+
188+
const envConcurrentRequests = Number(
189+
process.env.TEENY_REQUEST_WARN_CONCURRENT_REQUESTS
190+
);
191+
if (diConcurrentRequests !== undefined) {
192+
concurrentRequests = diConcurrentRequests;
193+
} else if (!Number.isNaN(envConcurrentRequests)) {
194+
concurrentRequests = envConcurrentRequests;
195+
}
196+
197+
return {concurrentRequests};
198+
}
199+
}

src/index.ts

+21
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import fetch, * as f from 'node-fetch';
2020
import {PassThrough, Readable} from 'stream';
2121
import * as uuid from 'uuid';
2222
import {getAgent} from './agents';
23+
import {TeenyStatistics} from './TeenyStatistics';
2324
// eslint-disable-next-line @typescript-eslint/no-var-requires
2425
const streamEvents = require('stream-events');
2526

@@ -206,8 +207,10 @@ function teenyRequest(
206207
options.body = createMultipartStream(boundary, multipart);
207208

208209
// Multipart upload
210+
teenyRequest.stats.requestStarting();
209211
fetch(uri, options).then(
210212
res => {
213+
teenyRequest.stats.requestFinished();
211214
const header = res.headers.get('content-type');
212215
const response = fetchToRequestResponse(options, res);
213216
const body = response.body;
@@ -238,6 +241,7 @@ function teenyRequest(
238241
);
239242
},
240243
err => {
244+
teenyRequest.stats.requestFinished();
241245
callback(err, null!, null);
242246
}
243247
);
@@ -259,8 +263,11 @@ function teenyRequest(
259263
}
260264
});
261265
options.compress = false;
266+
267+
teenyRequest.stats.requestStarting();
262268
fetch(uri, options).then(
263269
res => {
270+
teenyRequest.stats.requestFinished();
264271
responseStream = res.body;
265272

266273
responseStream.on('error', (err: Error) => {
@@ -271,6 +278,7 @@ function teenyRequest(
271278
requestStream.emit('response', response);
272279
},
273280
err => {
281+
teenyRequest.stats.requestFinished();
274282
requestStream.emit('error', err);
275283
}
276284
);
@@ -280,9 +288,12 @@ function teenyRequest(
280288
// stream.
281289
return requestStream as Request;
282290
}
291+
283292
// GET or POST with callback
293+
teenyRequest.stats.requestStarting();
284294
fetch(uri, options).then(
285295
res => {
296+
teenyRequest.stats.requestFinished();
286297
const header = res.headers.get('content-type');
287298
const response = fetchToRequestResponse(options, res);
288299
const body = response.body;
@@ -319,6 +330,7 @@ function teenyRequest(
319330
);
320331
},
321332
err => {
333+
teenyRequest.stats.requestFinished();
322334
callback(err, null!, null);
323335
}
324336
);
@@ -335,4 +347,13 @@ teenyRequest.defaults = (defaults: CoreOptions) => {
335347
};
336348
};
337349

350+
/**
351+
* Single instance of an interface for keeping track of things.
352+
*/
353+
teenyRequest.stats = new TeenyStatistics();
354+
355+
teenyRequest.resetStats = (): void => {
356+
teenyRequest.stats = new TeenyStatistics(teenyRequest.stats.getOptions());
357+
};
358+
338359
export {teenyRequest};

0 commit comments

Comments
 (0)