Skip to content

Commit fdf401d

Browse files
xzhou-snykcarlos-snyk
authored andcommitted
feat: add retry to sendTestPayload
Co-authored-by: Carlos <[email protected]> Cu-authored-by: Lucian <[email protected]>
1 parent 3b256e5 commit fdf401d

File tree

11 files changed

+167
-9
lines changed

11 files changed

+167
-9
lines changed

src/lib/errors/bad-gateway-error.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { CustomError } from './custom-error';
2+
3+
export class BadGatewayError extends CustomError {
4+
private static ERROR_CODE = 502;
5+
private static ERROR_STRING_CODE = 'BAD_GATEWAY_ERROR';
6+
private static ERROR_MESSAGE = 'Bad gateway error';
7+
8+
constructor(userMessage) {
9+
super(BadGatewayError.ERROR_MESSAGE);
10+
this.code = BadGatewayError.ERROR_CODE;
11+
this.strCode = BadGatewayError.ERROR_STRING_CODE;
12+
this.userMessage = userMessage || BadGatewayError.ERROR_MESSAGE;
13+
}
14+
}

src/lib/errors/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export { ConnectionTimeoutError } from './connection-timeout-error';
1010
export { FailedToLoadPolicyError } from './failed-to-load-policy-error';
1111
export { PolicyNotFoundError } from './policy-not-found-error';
1212
export { InternalServerError } from './internal-server-error';
13+
export { BadGatewayError } from './bad-gateway-error';
14+
export { ServiceUnavailableError } from './service-unavailable-error';
1315
export { FailedToGetVulnerabilitiesError } from './failed-to-get-vulnerabilities-error';
1416
export { FailedToGetVulnsFromUnavailableResource } from './failed-to-get-vulns-from-unavailable-resource';
1517
export { UnsupportedPackageManagerError } from './unsupported-package-manager-error';
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { CustomError } from './custom-error';
2+
3+
export class ServiceUnavailableError extends CustomError {
4+
private static ERROR_CODE = 503;
5+
private static ERROR_STRING_CODE = 'SERVICE_UNAVAILABLE_ERROR';
6+
private static ERROR_MESSAGE = 'Service unavailable error';
7+
8+
constructor(userMessage) {
9+
super(ServiceUnavailableError.ERROR_MESSAGE);
10+
this.code = ServiceUnavailableError.ERROR_CODE;
11+
this.strCode = ServiceUnavailableError.ERROR_STRING_CODE;
12+
this.userMessage = userMessage || ServiceUnavailableError.ERROR_MESSAGE;
13+
}
14+
}

src/lib/snyk-test/common.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,6 @@ export enum FAIL_ON {
6565
}
6666

6767
export type FailOn = 'all' | 'upgradable' | 'patchable';
68+
69+
export const RETRY_ATTEMPTS = 3;
70+
export const RETRY_DELAY = 500;

src/lib/snyk-test/run-test.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
NoSupportedManifestsFoundError,
3131
NotFoundError,
3232
errorMessageWithRetry,
33+
BadGatewayError,
34+
ServiceUnavailableError,
3335
} from '../errors';
3436
import * as snyk from '../';
3537
import { isCI } from '../is-ci';
@@ -68,6 +70,8 @@ import { assembleEcosystemPayloads } from './assemble-payloads';
6870
import { makeRequest } from '../request';
6971
import { spinner } from '../spinner';
7072
import { hasUnknownVersions } from '../dep-graph';
73+
import { sleep } from '../common';
74+
import { RETRY_ATTEMPTS, RETRY_DELAY } from './common';
7175

7276
const debug = debugModule('snyk:run-test');
7377

@@ -234,13 +238,38 @@ async function sendAndParseResults(
234238
response: any;
235239
};
236240
const requests: (() => Promise<TestResponse>)[] = [];
237-
for (const payload of payloads) {
241+
for (const originalPayload of payloads) {
238242
const request = async (): Promise<TestResponse> => {
239-
/** sendTestPayload() deletes the request.body from the payload once completed. */
240-
const originalPayload = Object.assign({}, payload);
241-
const response = await sendTestPayload(payload);
242-
return { payload, originalPayload, response };
243+
let step = 0;
244+
let error;
245+
246+
while (step < RETRY_ATTEMPTS) {
247+
debug(`sendTestPayload retry step ${step} out of ${RETRY_ATTEMPTS}`);
248+
try {
249+
/** sendTestPayload() deletes the request.body from the payload once completed. */
250+
const payload = Object.assign({}, originalPayload);
251+
const response = await sendTestPayload(payload);
252+
253+
return { payload, originalPayload, response };
254+
} catch (err) {
255+
error = err;
256+
step++;
257+
258+
if (
259+
err instanceof InternalServerError ||
260+
err instanceof BadGatewayError ||
261+
err instanceof ServiceUnavailableError
262+
) {
263+
await sleep(RETRY_DELAY);
264+
} else {
265+
break;
266+
}
267+
}
268+
}
269+
270+
throw error;
243271
};
272+
244273
requests.push(request);
245274
}
246275

@@ -516,6 +545,14 @@ function handleTestHttpErrorResponse(res, body) {
516545
err = new InternalServerError(userMessage);
517546
err.innerError = body.stack;
518547
break;
548+
case 502:
549+
err = new BadGatewayError(userMessage);
550+
err.innerError = body.stack;
551+
break;
552+
case 503:
553+
err = new ServiceUnavailableError(userMessage);
554+
err.innerError = body.stack;
555+
break;
519556
default:
520557
err = new FailedToGetVulnerabilitiesError(userMessage, statusCode);
521558
err.innerError = body.error;

test/acceptance/fake-server.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type FakeServer = {
2121
setDepGraphResponse: (next: Record<string, unknown>) => void;
2222
setNextResponse: (r: any) => void;
2323
setNextStatusCode: (c: number) => void;
24+
setStatusCode: (c: number) => void;
25+
setStatusCodes: (c: number[]) => void;
2426
setFeatureFlag: (featureFlag: string, enabled: boolean) => void;
2527
unauthorizeAction: (action: string, reason?: string) => void;
2628
listen: (port: string | number, callback: () => void) => void;
@@ -39,12 +41,17 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
3941
let requests: express.Request[] = [];
4042
let featureFlags: Map<string, boolean> = featureFlagDefaults();
4143
let unauthorizedActions = new Map();
44+
// the status code to return for the next request, overriding statusCode
4245
let nextStatusCode: number | undefined = undefined;
46+
// the status code to return for all the requests
47+
let statusCode: number | undefined = undefined;
48+
let statusCodes: number[] = [];
4349
let nextResponse: any = undefined;
4450
let depGraphResponse: Record<string, unknown> | undefined = undefined;
4551
let server: http.Server | undefined = undefined;
4652

4753
const restore = () => {
54+
statusCode = undefined;
4855
requests = [];
4956
depGraphResponse = undefined;
5057
featureFlags = featureFlagDefaults();
@@ -79,6 +86,14 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
7986
nextStatusCode = code;
8087
};
8188

89+
const setStatusCode = (code: number) => {
90+
statusCode = code;
91+
};
92+
93+
const setStatusCodes = (codes: number[]) => {
94+
statusCodes = codes;
95+
};
96+
8297
const setFeatureFlag = (featureFlag: string, enabled: boolean) => {
8398
featureFlags.set(featureFlag, enabled);
8499
};
@@ -129,7 +144,7 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
129144
if (
130145
req.url?.includes('/iac-org-settings') ||
131146
req.url?.includes('/cli-config/feature-flags/') ||
132-
(!nextResponse && !nextStatusCode)
147+
(!nextResponse && !nextStatusCode && !statusCode)
133148
) {
134149
return next();
135150
}
@@ -139,7 +154,10 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
139154
const code = nextStatusCode;
140155
nextStatusCode = undefined;
141156
res.status(code);
157+
} else if (statusCode) {
158+
res.status(statusCode);
142159
}
160+
143161
res.send(response);
144162
});
145163

@@ -195,6 +213,12 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
195213
return next();
196214
}
197215

216+
const statusCode = statusCodes.shift();
217+
if (statusCode && statusCode !== 200) {
218+
res.sendStatus(statusCode);
219+
return next();
220+
}
221+
198222
if (depGraphResponse) {
199223
res.send(depGraphResponse);
200224
return next();
@@ -574,6 +598,8 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
574598
setDepGraphResponse,
575599
setNextResponse,
576600
setNextStatusCode,
601+
setStatusCode,
602+
setStatusCodes,
577603
setFeatureFlag,
578604
unauthorizeAction,
579605
listen,

test/jest/acceptance/snyk-fix/fix.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe('snyk fix', () => {
9090

9191
it('fails when api requests fail', async () => {
9292
const project = await createProjectFromWorkspace('no-vulns');
93-
server.setNextStatusCode(500);
93+
server.setStatusCode(500);
9494
const { code, stdout, stderr } = await runSnykCLI(
9595
'fix --org=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
9696
{
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { fakeServer } from '../../../acceptance/fake-server';
2+
import { createProjectFromWorkspace } from '../../util/createProject';
3+
import { runSnykCLI } from '../../util/runSnykCLI';
4+
import { RETRY_ATTEMPTS } from '../../../../src/lib/snyk-test/common';
5+
6+
jest.setTimeout(2000 * 60);
7+
8+
describe('snyk test retry mechanism', () => {
9+
let server;
10+
let env: Record<string, string>;
11+
12+
beforeAll((done) => {
13+
const port = process.env.PORT || process.env.SNYK_PORT || '12345';
14+
const baseApi = '/api/v1';
15+
env = {
16+
...process.env,
17+
SNYK_API: 'http://localhost:' + port + baseApi,
18+
SNYK_HOST: 'http://localhost:' + port,
19+
SNYK_TOKEN: '123456789',
20+
SNYK_DISABLE_ANALYTICS: '1',
21+
};
22+
server = fakeServer(baseApi, env.SNYK_TOKEN);
23+
server.listen(port, () => {
24+
done();
25+
});
26+
});
27+
28+
afterAll((done) => {
29+
server.close(() => {
30+
done();
31+
});
32+
});
33+
34+
test('run `snyk test` on an arbitrary cocoapods project and expect retries in case of failures', async () => {
35+
const project = await createProjectFromWorkspace('cocoapods-app');
36+
const statuses = Array(RETRY_ATTEMPTS - 1)
37+
.fill(500)
38+
.concat([200]);
39+
server.setStatusCodes(statuses);
40+
41+
const { code } = await runSnykCLI('test', {
42+
cwd: project.path(),
43+
env,
44+
});
45+
46+
expect(code).toEqual(0);
47+
});
48+
49+
test('run `snyk test` on an arbitrary cocoapods project and expect failure when the retry budget is consumed', async () => {
50+
const project = await createProjectFromWorkspace('cocoapods-app');
51+
server.setStatusCodes(Array(RETRY_ATTEMPTS).fill(500));
52+
53+
const { code } = await runSnykCLI('test', {
54+
cwd: project.path(),
55+
env,
56+
});
57+
58+
expect(code).toEqual(2);
59+
});
60+
});

test/tap/cli-test.acceptance.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ test(GenericTests.language, async (t) => {
113113
{ chdirWorkspaces },
114114
),
115115
);
116+
server.restore();
116117
}
117118
});
118119

@@ -125,6 +126,7 @@ test(AllProjectsTests.language, async (t) => {
125126
{ chdirWorkspaces },
126127
),
127128
);
129+
server.restore();
128130
}
129131
});
130132

test/tap/cli-test/cli-test.generic.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ export const GenericTests: AcceptanceTests = {
405405
'error 500 handling': (params, utils) => async (t) => {
406406
utils.chdirWorkspaces();
407407

408-
params.server.setNextStatusCode(500);
408+
params.server.setStatusCode(500);
409409

410410
try {
411411
await params.cli.test('ruby-app-thresholds');

0 commit comments

Comments
 (0)