Skip to content

Commit 66862e1

Browse files
Merge pull request #4187 from NativeScript/milanov/retry-stuck-http-reqs
fix: retry stuck requests/responses
2 parents e470e25 + 9609814 commit 66862e1

File tree

2 files changed

+105
-17
lines changed

2 files changed

+105
-17
lines changed

lib/common/declarations.d.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,10 @@ declare module Server {
163163
interface IRequestResponseData {
164164
statusCode: number;
165165
headers: { [index: string]: any };
166+
complete: boolean;
166167
pipe(destination: any, options?: { end?: boolean; }): IRequestResponseData;
167168
on(event: string, listener: Function): void;
169+
destroy(error?: Error): void;
168170
}
169171
}
170172

@@ -758,7 +760,7 @@ interface IAnalyticsSettingsService {
758760
* Gets information for projects that are exported from playground
759761
* @param projectDir Project directory path
760762
*/
761-
getPlaygroundInfo(projectDir?: string): Promise<IPlaygroundInfo>;
763+
getPlaygroundInfo(projectDir?: string): Promise<IPlaygroundInfo>;
762764
}
763765

764766
/**

lib/common/http-client.ts

+102-16
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,48 @@ import { HttpStatusCodes } from "./constants";
77
import * as request from "request";
88

99
export class HttpClient implements Server.IHttpClient {
10-
private defaultUserAgent: string;
1110
private static STATUS_CODE_REGEX = /statuscode=(\d+)/i;
11+
private static STUCK_REQUEST_ERROR_MESSAGE = "The request can't receive any response.";
12+
private static STUCK_RESPONSE_ERROR_MESSAGE = "Can't receive all parts of the response.";
13+
private static STUCK_REQUEST_TIMEOUT = 60000;
14+
// We receive multiple response packets every ms but we don't need to be very aggressive here.
15+
private static STUCK_RESPONSE_CHECK_INTERVAL = 10000;
16+
17+
private defaultUserAgent: string;
18+
private cleanupData: ICleanupRequestData[];
1219

1320
constructor(private $config: Config.IConfig,
1421
private $logger: ILogger,
22+
private $processService: IProcessService,
1523
private $proxyService: IProxyService,
16-
private $staticConfig: Config.IStaticConfig) { }
24+
private $staticConfig: Config.IStaticConfig) {
25+
this.cleanupData = [];
26+
this.$processService.attachToProcessExitSignals(this, () => {
27+
this.cleanupData.forEach(d => {
28+
this.cleanupAfterRequest(d);
29+
});
30+
});
31+
}
32+
33+
public async httpRequest(options: any, proxySettings?: IProxySettings): Promise<Server.IResponse> {
34+
try {
35+
const result = await this.httpRequestCore(options, proxySettings);
36+
return result;
37+
} catch (err) {
38+
if (err.message === HttpClient.STUCK_REQUEST_ERROR_MESSAGE || err.message === HttpClient.STUCK_RESPONSE_ERROR_MESSAGE) {
39+
// Retry the request immediately because there are at least 10 seconds between the two requests.
40+
// We have to retry only once the sporadically stuck requests/responses.
41+
// We can add exponential backoff retry here if we decide that we need to workaround bigger network issues on the client side.
42+
this.$logger.warn("%s Retrying request to %s...", err.message, options.url || options);
43+
const retryResult = await this.httpRequestCore(options, proxySettings);
44+
return retryResult;
45+
}
1746

18-
async httpRequest(options: any, proxySettings?: IProxySettings): Promise<Server.IResponse> {
47+
throw err;
48+
}
49+
}
50+
51+
private async httpRequestCore(options: any, proxySettings?: IProxySettings): Promise<Server.IResponse> {
1952
if (_.isString(options)) {
2053
options = {
2154
url: options,
@@ -73,6 +106,10 @@ export class HttpClient implements Server.IHttpClient {
73106

74107
const result = new Promise<Server.IResponse>((resolve, reject) => {
75108
let timerId: number;
109+
let stuckRequestTimerId: number;
110+
let hasResponse = false;
111+
const cleanupRequestData: ICleanupRequestData = Object.create({ timers: [] });
112+
this.cleanupData.push(cleanupRequestData);
76113

77114
const promiseActions: IPromiseActions<Server.IResponse> = {
78115
resolve,
@@ -82,8 +119,9 @@ export class HttpClient implements Server.IHttpClient {
82119

83120
if (options.timeout) {
84121
timerId = setTimeout(() => {
85-
this.setResponseResult(promiseActions, timerId, { err: new Error(`Request to ${unmodifiedOptions.url} timed out.`) }, );
122+
this.setResponseResult(promiseActions, cleanupRequestData, { err: new Error(`Request to ${unmodifiedOptions.url} timed out.`) });
86123
}, options.timeout);
124+
cleanupRequestData.timers.push(timerId);
87125

88126
delete options.timeout;
89127
}
@@ -94,6 +132,16 @@ export class HttpClient implements Server.IHttpClient {
94132

95133
this.$logger.trace("httpRequest: %s", util.inspect(options));
96134
const requestObj = request(options);
135+
cleanupRequestData.req = requestObj;
136+
137+
stuckRequestTimerId = setTimeout(() => {
138+
clearTimeout(stuckRequestTimerId);
139+
stuckRequestTimerId = null;
140+
if (!hasResponse) {
141+
this.setResponseResult(promiseActions, cleanupRequestData, { err: new Error(HttpClient.STUCK_REQUEST_ERROR_MESSAGE) });
142+
}
143+
}, options.timeout || HttpClient.STUCK_REQUEST_TIMEOUT);
144+
cleanupRequestData.timers.push(stuckRequestTimerId);
97145

98146
requestObj
99147
.on("error", (err: IHttpRequestError) => {
@@ -107,15 +155,26 @@ export class HttpClient implements Server.IHttpClient {
107155
const errorMessage = this.getErrorMessage(errorMessageStatusCode, null);
108156
err.proxyAuthenticationRequired = errorMessageStatusCode === HttpStatusCodes.PROXY_AUTHENTICATION_REQUIRED;
109157
err.message = errorMessage || err.message;
110-
this.setResponseResult(promiseActions, timerId, { err });
158+
this.setResponseResult(promiseActions, cleanupRequestData, { err });
111159
})
112160
.on("response", (response: Server.IRequestResponseData) => {
161+
cleanupRequestData.res = response;
162+
hasResponse = true;
163+
let lastChunkTimestamp = Date.now();
164+
cleanupRequestData.stuckResponseIntervalId = setInterval(() => {
165+
if (Date.now() - lastChunkTimestamp > HttpClient.STUCK_RESPONSE_CHECK_INTERVAL) {
166+
this.setResponseResult(promiseActions, cleanupRequestData, { err: new Error(HttpClient.STUCK_RESPONSE_ERROR_MESSAGE) });
167+
}
168+
}, HttpClient.STUCK_RESPONSE_CHECK_INTERVAL);
113169
const successful = helpers.isRequestSuccessful(response);
114170
if (!successful) {
115171
pipeTo = undefined;
116172
}
117173

118174
let responseStream = response;
175+
responseStream.on("data", (chunk: string) => {
176+
lastChunkTimestamp = Date.now();
177+
});
119178
switch (response.headers["content-encoding"]) {
120179
case "gzip":
121180
responseStream = responseStream.pipe(zlib.createGunzip());
@@ -128,7 +187,7 @@ export class HttpClient implements Server.IHttpClient {
128187
if (pipeTo) {
129188
pipeTo.on("finish", () => {
130189
this.$logger.trace("httpRequest: Piping done. code = %d", response.statusCode.toString());
131-
this.setResponseResult(promiseActions, timerId, { response });
190+
this.setResponseResult(promiseActions, cleanupRequestData, { response });
132191
});
133192

134193
responseStream.pipe(pipeTo);
@@ -144,13 +203,13 @@ export class HttpClient implements Server.IHttpClient {
144203
const responseBody = data.join("");
145204

146205
if (successful) {
147-
this.setResponseResult(promiseActions, timerId, { body: responseBody, response });
206+
this.setResponseResult(promiseActions, cleanupRequestData, { body: responseBody, response });
148207
} else {
149208
const errorMessage = this.getErrorMessage(response.statusCode, responseBody);
150209
const err: any = new Error(errorMessage);
151210
err.response = response;
152211
err.body = responseBody;
153-
this.setResponseResult(promiseActions, timerId, { err });
212+
this.setResponseResult(promiseActions, cleanupRequestData, { err });
154213
}
155214
});
156215
}
@@ -181,16 +240,12 @@ export class HttpClient implements Server.IHttpClient {
181240
return response;
182241
}
183242

184-
private setResponseResult(result: IPromiseActions<Server.IResponse>, timerId: number, resultData: { response?: Server.IRequestResponseData, body?: string, err?: Error }): void {
185-
if (timerId) {
186-
clearTimeout(timerId);
187-
timerId = null;
188-
}
189-
243+
private setResponseResult(result: IPromiseActions<Server.IResponse>, cleanupRequestData: ICleanupRequestData, resultData: { response?: Server.IRequestResponseData, body?: string, err?: Error }): void {
244+
this.cleanupAfterRequest(cleanupRequestData);
190245
if (!result.isResolved()) {
191246
result.isResolved = () => true;
192-
if (resultData.err) {
193-
return result.reject(resultData.err);
247+
if (resultData.err || !resultData.response.complete) {
248+
return result.reject(resultData.err || new Error("Request canceled"));
194249
}
195250

196251
const finalResult: any = resultData;
@@ -258,5 +313,36 @@ export class HttpClient implements Server.IHttpClient {
258313
this.$logger.trace("Using proxy: %s", options.proxy);
259314
}
260315
}
316+
317+
private cleanupAfterRequest(data: ICleanupRequestData): void {
318+
data.timers.forEach(t => {
319+
if (t) {
320+
clearTimeout(t);
321+
t = null;
322+
}
323+
});
324+
325+
if (data.stuckResponseIntervalId) {
326+
clearInterval(data.stuckResponseIntervalId);
327+
data.stuckResponseIntervalId = null;
328+
}
329+
330+
if (data.req) {
331+
data.req.abort();
332+
}
333+
334+
if (data.res) {
335+
data.res.destroy();
336+
}
337+
}
338+
261339
}
340+
341+
interface ICleanupRequestData {
342+
timers: number[];
343+
stuckResponseIntervalId: NodeJS.Timer;
344+
req: request.Request;
345+
res: Server.IRequestResponseData;
346+
}
347+
262348
$injector.register("httpClient", HttpClient);

0 commit comments

Comments
 (0)