Skip to content

Commit 0a90af1

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

7 files changed

Lines changed: 203 additions & 22 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()) {

cliv2/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ require (
1919
github.com/snyk/cli-extension-sbom v0.0.0-20260109124810-cfdd074f8eeb
2020
github.com/snyk/container-cli v0.0.0-20250321132345-1e2e01681dd7
2121
github.com/snyk/error-catalog-golang-public v0.0.0-20251222142433-dbdc288a6e98
22-
github.com/snyk/go-application-framework v0.0.0-20260106115317-a1fb6f13accd
22+
github.com/snyk/go-application-framework v0.0.0-20260109164424-8d73019f7ddd
2323
github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65
2424
github.com/snyk/snyk-iac-capture v0.6.5
2525
github.com/snyk/snyk-ls v0.0.0-20260108085345-39b92d542121
@@ -249,7 +249,7 @@ require (
249249
// version 2491eb6c1c75 contains a valid license
250250
replace github.com/mattn/go-localereader v0.0.1 => github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75
251251

252-
//replace github.com/snyk/go-application-framework => ../../go-application-framework
252+
// replace github.com/snyk/go-application-framework => ../../go-application-framework
253253

254254
//replace github.com/snyk/snyk-ls => ../../snyk-ls
255255
//replace github.com/snyk/code-client-go => ../../code-client-go

cliv2/go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,8 +1318,8 @@ github.com/snyk/dep-graph/go v0.0.0-20251128083058-1972edcff6cf h1:RZ3KGLcbH37DU
13181318
github.com/snyk/dep-graph/go v0.0.0-20251128083058-1972edcff6cf/go.mod h1:hTr91da/4ze2nk9q6ZW1BmfM2Z8rLUZSEZ3kK+6WGpc=
13191319
github.com/snyk/error-catalog-golang-public v0.0.0-20251222142433-dbdc288a6e98 h1:ucaLtBucnO9U8mrUmKovxObkflB1aQUQTB+orFt9rug=
13201320
github.com/snyk/error-catalog-golang-public v0.0.0-20251222142433-dbdc288a6e98/go.mod h1:Ytttq7Pw4vOCu9NtRQaOeDU2dhBYUyNBe6kX4+nIIQ4=
1321-
github.com/snyk/go-application-framework v0.0.0-20260106115317-a1fb6f13accd h1:mGFCdZOB+e7CdNbJ1+FezEVW9i7tc4PgC72bMwJmbxU=
1322-
github.com/snyk/go-application-framework v0.0.0-20260106115317-a1fb6f13accd/go.mod h1:T+dt4+4XFAJ4PmoGgt/hrx7LiY+vaz+m9V4UYe24Rpc=
1321+
github.com/snyk/go-application-framework v0.0.0-20260109164424-8d73019f7ddd h1:d554v64Ds1qXZEDZ90+RWY+PvKa83lVuB7DI04m0o/A=
1322+
github.com/snyk/go-application-framework v0.0.0-20260109164424-8d73019f7ddd/go.mod h1:T+dt4+4XFAJ4PmoGgt/hrx7LiY+vaz+m9V4UYe24Rpc=
13231323
github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65 h1:CEQuYv0Go6MEyRCD3YjLYM2u3Oxkx8GpCpFBd4rUTUk=
13241324
github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65/go.mod h1:88KbbvGYlmLgee4OcQ19yr0bNpXpOr2kciOthaSzCAg=
13251325
github.com/snyk/policy-engine v1.1.0 h1:vFbFZbs3B0Y3XuGSur5om2meo4JEcCaKfNzshZFGOUs=

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: 137 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,138 @@ describe('exit code behaviour - general', () => {
3841

3942
expect(code).toEqual(EXIT_CODES.EX_UNAVAILABLE);
4043
});
44+
45+
describe('[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+
describe('no retry-after header in error response', () => {
105+
beforeEach(async () => {
106+
server.setGlobalResponse(
107+
errorObject,
108+
parseInt(errorObject['errors'][0].status),
109+
);
110+
})
111+
112+
it('Does not attempt any retries', async () => {
113+
await runSnykCLI(`test -d --log-level=trace`, {
114+
env: {
115+
...fakeServerEnv,
116+
// apply a user configured attempts of 10
117+
INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '10',
118+
},
119+
});
120+
121+
// Count how many times an endpoint was hit
122+
const requests = server.getRequests();
123+
const testEndpointHits = requests.filter(r =>
124+
r.url.includes('/test-dep-graph') || r.url.includes('/vuln/')
125+
).length;
126+
127+
expect(testEndpointHits).toBe(1); // Only 1 attempt, no retries
128+
});
129+
});
130+
131+
describe('retry-after header in error response', () => {
132+
it('Respects retry-after header', async () => {
133+
server.setGlobalResponse(
134+
errorObject,
135+
parseInt(errorObject['errors'][0].status),
136+
{ 'retry-after': '1' },
137+
);
138+
139+
const { stderr } = await runSnykCLI(`test -d --log-level=trace`, {
140+
env: {
141+
...fakeServerEnv,
142+
INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '2',
143+
},
144+
});
145+
146+
const expectedRetryAfterDebugMsg = 'Retrying request, reason: retry after 1s';
147+
const expectedRetryAfterHeaderDebugMsg = 'Retry-After:[1]';
148+
expect(stderr).toContain(expectedRetryAfterDebugMsg);
149+
expect(stderr).toContain(expectedRetryAfterHeaderDebugMsg);
150+
151+
const requests = server.getRequests();
152+
const testEndpointHits = requests.filter(r =>
153+
r.url.includes('/test-dep-graph') || r.url.includes('/vuln/')
154+
).length;
155+
156+
expect(testEndpointHits).toBe(2); // expected 2 network attempts
157+
});
158+
});
159+
160+
it('Correct exit code', async () => {
161+
server.setGlobalResponse(
162+
errorObject,
163+
parseInt(errorObject['errors'][0].status),
164+
);
165+
166+
const { code, stdout } = await runSnykCLI(`test`, {
167+
env: {
168+
...fakeServerEnv,
169+
INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '1',
170+
},
171+
});
172+
173+
174+
expect(stdout).toContain(errorObject['errors'][0].code);
175+
expect(code).toEqual(EXIT_CODES.EX_TEMPFAIL);
176+
});
177+
});
41178
});

0 commit comments

Comments
 (0)