Skip to content

Commit be859d1

Browse files
PeterSchaferj-luong
authored andcommitted
feat: handle maintenance error
CLI-1268
1 parent f0c0a89 commit be859d1

5 files changed

Lines changed: 204 additions & 18 deletions

File tree

cliv2/cmd/cliv2/behavior/maperrortoexitcode.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package behavior
33
import (
44
"github.com/snyk/error-catalog-golang-public/aibom"
55
"github.com/snyk/error-catalog-golang-public/code"
6+
"github.com/snyk/error-catalog-golang-public/snyk"
67
"github.com/snyk/error-catalog-golang-public/snyk_errors"
78

89
"github.com/snyk/cli/cliv2/internal/constants"
@@ -15,6 +16,7 @@ func mapErrorToExitCode(err *snyk_errors.Error, defaultValue int) int {
1516
var errorCatalogToExitCodeMap = map[string]int{
1617
code.NewUnsupportedProjectError("").ErrorCode: constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS,
1718
aibom.NewNoSupportedFilesError("").ErrorCode: constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS,
19+
snyk.NewMaintenanceWindowError("").ErrorCode: constants.SNYK_EXIT_CODE_EX_TEMPFAIL,
1820
// Add new mappings here
1921
}
2022

cliv2/cmd/cliv2/main.go

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,9 @@ func MainWithErrorCode() int {
528528
ua := networking.UserAgent(networking.UaWithConfig(globalConfiguration), networking.UaWithRuntimeInfo(rInfo), networking.UaWithOS(internalOS))
529529
networkAccess := globalEngine.GetNetworkAccess()
530530
networkAccess.AddErrorHandler(func(err error, ctx context.Context) error {
531+
if err == nil {
532+
return nil
533+
}
531534
errorListMutex.Lock()
532535
defer errorListMutex.Unlock()
533536

@@ -624,18 +627,12 @@ func MainWithErrorCode() int {
624627
}
625628

626629
if err != nil {
627-
err = decorateError(err)
630+
err, errorList = processError(err, errorList)
628631

629-
errorList = append(errorList, err)
630632
for _, tempError := range errorList {
631-
cliAnalytics.AddError(tempError)
632-
}
633-
634-
err = legacyCLITerminated(err, errorList)
635-
636-
// ensure to apply exit code mapping based on errors
637-
if exitCode := mapErrorToExitCode(err); exitCode != unsetExitCode {
638-
err = createErrorWithExitCode(exitCode, err)
633+
if tempError != nil {
634+
cliAnalytics.AddError(tempError)
635+
}
639636
}
640637
}
641638

@@ -665,13 +662,32 @@ func MainWithErrorCode() int {
665662
return exitCode
666663
}
667664

668-
func legacyCLITerminated(err error, errorList []error) error {
669-
exitErr, isExitError := err.(*exec.ExitError)
670-
if isExitError && exitErr.ExitCode() == constants.SNYK_EXIT_CODE_TS_CLI_TERMINATED {
665+
func processError(err error, errorList []error) (error, []error) {
666+
// ensure to use generic fallback error catalog error if no other is available
667+
err = decorateError(err)
668+
669+
// filter legacycli terminate errors since it is only used for internal purposes
670+
if exitErr, isExitError := err.(*exec.ExitError); isExitError && exitErr.ExitCode() == constants.SNYK_EXIT_CODE_TS_CLI_TERMINATED {
671+
err = nil
672+
}
673+
674+
// add all errors to analytics
675+
if err != nil {
671676
errorList = append([]error{err}, errorList...)
677+
}
678+
679+
// create a single error from all errors
680+
if len(errorList) == 1 {
681+
err = errorList[0]
682+
} else if len(errorList) > 1 {
672683
err = errors.Join(errorList...)
673684
}
674-
return err
685+
686+
// ensure to apply exit code mapping based on errors
687+
if exitCode := mapErrorToExitCode(err); exitCode != unsetExitCode {
688+
err = createErrorWithExitCode(exitCode, err)
689+
}
690+
return err, errorList
675691
}
676692

677693
func setTimeout(config configuration.Configuration, onTimeout func()) {

test/acceptance/fake-server.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export type FakeServer = {
5555
setSarifResponse: (next: Record<string, unknown>) => void;
5656
setNextResponse: (r: any) => void;
5757
setNextStatusCode: (c: number) => void;
58-
setGlobalResponse: (response: Record<string, unknown>, code: number) => void;
58+
setGlobalResponse: (response: Record<string, unknown>, code: number, headers?: Record<string, any>) => void;
5959

6060
setEndpointResponse: (
6161
endpoint: string,
@@ -96,6 +96,7 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
9696
let nextResponse: any = undefined;
9797
let endpointResponses: Map<string, Record<string, unknown>> = new Map();
9898
let endpointStatusCodes: Map<string, number> = new Map();
99+
let endpointHeaders: Map<string, string> = new Map();
99100
let customResponse: Record<string, unknown> | undefined = undefined;
100101
let sarifResponse: Record<string, unknown> | undefined = undefined;
101102
let server: http.Server | undefined = undefined;
@@ -108,6 +109,7 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
108109
sarifResponse = undefined;
109110
endpointResponses = new Map();
110111
endpointStatusCodes = new Map();
112+
endpointHeaders = new Map();
111113
featureFlags = featureFlagDefaults();
112114
availableSettings = new Map();
113115
unauthorizedActions = new Map();
@@ -168,9 +170,15 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
168170
const setGlobalResponse = (
169171
response: Record<string, unknown>,
170172
code: number,
173+
headers?: Record<string, string>,
171174
) => {
172175
endpointResponses.set('*', response);
173176
endpointStatusCodes.set('*', code);
177+
if (headers) {
178+
for (const [key, value] of Object.entries(headers)) {
179+
endpointHeaders.set(key, value);
180+
}
181+
}
174182
};
175183

176184
const setEndpointResponse = (
@@ -226,6 +234,13 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => {
226234
endpointStatusCode = endpointStatusCodes.get(endpoint);
227235
}
228236

237+
// configure any response headers
238+
if (endpointHeaders.size > 0) {
239+
endpointHeaders.forEach((value, key) => {
240+
res.set(key, value);
241+
});
242+
}
243+
229244
if (endpointResponse) {
230245
res.status(endpointStatusCode || 200);
231246
res.send(endpointResponse);

test/jest/acceptance/error-catalog.spec.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { CLI } from '@snyk/error-catalog-nodejs-public';
77
const TEST_DISTROLESS_STATIC_IMAGE =
88
'gcr.io/distroless/static@sha256:7198a357ff3a8ef750b041324873960cf2153c11cc50abb9d8d5f8bb089f6b4e';
99

10+
const TEST_WINDOWS_AMD64_IMAGE =
11+
'mcr.microsoft.com/windows/nanoserver:ltsc2019';
12+
1013
interface Workflow {
1114
type: string;
1215
cmd: string;
@@ -25,11 +28,19 @@ const integrationWorkflows: Workflow[] = [
2528
type: 'typescript',
2629
cmd: 'monitor',
2730
},
28-
{
31+
];
32+
33+
if (isWindowsOperatingSystem()) {
34+
integrationWorkflows.push({
35+
type: 'typescript',
36+
cmd: `container monitor ${TEST_WINDOWS_AMD64_IMAGE}`,
37+
});
38+
} else {
39+
integrationWorkflows.push({
2940
type: 'typescript',
3041
cmd: `container monitor ${TEST_DISTROLESS_STATIC_IMAGE}`,
31-
},
32-
];
42+
});
43+
}
3344

3445
const snykOrg = '11111111-2222-3333-4444-555555555555';
3546

test/jest/acceptance/exitcode.spec.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { runSnykCLI } from '../util/runSnykCLI';
22
import { isWindowsOperatingSystem, describeIf } from '../../utils';
33
import { EXIT_CODES } from '../../../src/cli/exit-codes';
4+
import { FakeServer, fakeServer, getFirstIPv4Address } from '../../acceptance/fake-server';
5+
import { getAvailableServerPort } from '../util/getServerPort';
6+
const { promisify } = require('util');
47

58
jest.setTimeout(1000 * 60);
69

@@ -38,4 +41,143 @@ describe('exit code behaviour - general', () => {
3841

3942
expect(code).toEqual(EXIT_CODES.EX_UNAVAILABLE);
4043
});
44+
45+
describe.only('[SNYK-0099] Maintenance error', () => {
46+
let server: FakeServer;
47+
let apiPort: string;
48+
let fakeServerUrl: string;
49+
let fakeServerHost: string;
50+
let fakeServerEnv: Record<string, string>;
51+
52+
const serverToken = 'random';
53+
const apiPath = '/api/v1';
54+
55+
const errorObject = {
56+
jsonapi: { version: '1.0' },
57+
errors: [
58+
{
59+
id: '11111111-2222-3333-4444-555555555555',
60+
links: {
61+
about:
62+
'https://docs.snyk.io/scan-with-snyk/error-catalog#snyk-0099',
63+
},
64+
status: '503',
65+
code: 'SNYK-0099',
66+
title: 'Unavailable due to maintenance',
67+
detail: '',
68+
meta: {
69+
links: [
70+
'https://status.snyk.io/',
71+
'https://privatecloudstatus.snyk.io',
72+
],
73+
isErrorCatalogError: true,
74+
classification: 'UNSUPPORTED',
75+
level: 'error',
76+
},
77+
},
78+
],
79+
description:
80+
'We are currently unavailable due to a maintenance window. For additional information please visit our status pages. Thank you for your patience.',
81+
};
82+
83+
beforeEach(async () => {
84+
apiPort = await getAvailableServerPort(process);
85+
fakeServerHost = 'http://' + getFirstIPv4Address() + ':' + apiPort;
86+
fakeServerUrl = fakeServerHost + apiPath;
87+
fakeServerEnv = {
88+
...process.env,
89+
SNYK_API: fakeServerUrl,
90+
SNYK_DISABLE_ANALYTICS: '1',
91+
SNYK_HTTP_PROTOCOL_UPGRADE: '0',
92+
};
93+
94+
server = fakeServer(apiPath, serverToken);
95+
const serverListen = promisify(server.listen);
96+
await serverListen(apiPort);
97+
});
98+
99+
afterEach(async () => {
100+
const serverClose = promisify(server.close);
101+
await serverClose();
102+
});
103+
104+
// TODO: unskip this once this is supported
105+
describe.skip('no retry-after header in error response', () => {
106+
beforeEach(async () => {
107+
server.setGlobalResponse(
108+
errorObject,
109+
parseInt(errorObject['errors'][0].status),
110+
);
111+
})
112+
113+
it('Does not attempt any retries', async () => {
114+
await runSnykCLI(`test -d --log-level=trace`, {
115+
env: {
116+
...fakeServerEnv,
117+
// apply a user configured attempts of 10
118+
INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '10',
119+
},
120+
});
121+
122+
// // we should expect to override user configured attempts
123+
// const expectedRetryAfterDebugMsg = 'Retry ultimately failed after 1 attempts';
124+
// expect(stderr).toContain(expectedRetryAfterDebugMsg);
125+
126+
// Count how many times an endpoint was hit
127+
const requests = server.getRequests();
128+
const testEndpointHits = requests.filter(r =>
129+
r.url.includes('/test-dep-graph') || r.url.includes('/vuln/')
130+
).length;
131+
132+
expect(testEndpointHits).toBe(1); // Only 1 attempt, no retries
133+
});
134+
});
135+
136+
describe('retry-after header in error response', () => {
137+
it('Respects retry-after header', async () => {
138+
server.setGlobalResponse(
139+
errorObject,
140+
parseInt(errorObject['errors'][0].status),
141+
{ 'retry-after': '1' },
142+
);
143+
144+
const { stderr } = await runSnykCLI(`test -d --log-level=trace`, {
145+
env: {
146+
...fakeServerEnv,
147+
INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '2',
148+
},
149+
});
150+
151+
const expectedRetryAfterDebugMsg = 'Retrying request, reason: retry after 1s';
152+
const expectedRetryAfterHeaderDebugMsg = 'Retry-After:[1]';
153+
expect(stderr).toContain(expectedRetryAfterDebugMsg);
154+
expect(stderr).toContain(expectedRetryAfterHeaderDebugMsg);
155+
156+
const requests = server.getRequests();
157+
const testEndpointHits = requests.filter(r =>
158+
r.url.includes('/test-dep-graph') || r.url.includes('/vuln/')
159+
).length;
160+
161+
expect(testEndpointHits).toBe(2); // expected 2 network attempts
162+
});
163+
});
164+
165+
it('Correct exit code', async () => {
166+
server.setGlobalResponse(
167+
errorObject,
168+
parseInt(errorObject['errors'][0].status),
169+
);
170+
171+
const { code, stdout } = await runSnykCLI(`test`, {
172+
env: {
173+
...fakeServerEnv,
174+
INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '1',
175+
},
176+
});
177+
178+
179+
expect(stdout).toContain(errorObject['errors'][0].code);
180+
expect(code).toEqual(EXIT_CODES.EX_TEMPFAIL);
181+
});
182+
});
41183
});

0 commit comments

Comments
 (0)