Skip to content

Commit

Permalink
feat: add error handling for json api errors
Browse files Browse the repository at this point in the history
  • Loading branch information
nick-watson committed Nov 7, 2023
1 parent f2f6aa4 commit 73ed509
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 18 deletions.
5 changes: 4 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export enum ErrorCodes {
notFound = 404,
bigPayload = 413,
connectionRefused = 421,
unprocessableEntity = 422,
dnsNotFound = 452,
serverError = 500,
badGateway = 502,
Expand Down Expand Up @@ -63,6 +64,7 @@ export const DEFAULT_ERROR_MESSAGES: { [P in ErrorCodes]: string } = {
[ErrorCodes.unauthorizedBundleAccess]: 'Unauthorized access to requested bundle analysis',
[ErrorCodes.notFound]: 'Not found',
[ErrorCodes.bigPayload]: `Payload too large (max is ${MAX_PAYLOAD}b)`,
[ErrorCodes.unprocessableEntity]: 'Unable to process analysis',
};

export type GenericErrorTypes =
Expand All @@ -71,4 +73,5 @@ export type GenericErrorTypes =
| ErrorCodes.serviceUnavailable
| ErrorCodes.timeout
| ErrorCodes.connectionRefused
| ErrorCodes.dnsNotFound;
| ErrorCodes.dnsNotFound
| ErrorCodes.unprocessableEntity;
72 changes: 58 additions & 14 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,29 @@ import pick from 'lodash.pick';
import { gzip } from 'zlib';
import { promisify } from 'util';

import { ErrorCodes, GenericErrorTypes, DEFAULT_ERROR_MESSAGES, MAX_RETRY_ATTEMPTS } from './constants';
import { DEFAULT_ERROR_MESSAGES, ErrorCodes, GenericErrorTypes, MAX_RETRY_ATTEMPTS } from './constants';

import { BundleFiles, SupportedFiles } from './interfaces/files.interface';
import { AnalysisResult, ReportResult } from './interfaces/analysis-result.interface';
import { FailedResponse, makeRequest, Payload } from './needle';
import {
AnalysisOptions,
AnalysisContext,
AnalysisOptions,
ReportOptions,
ScmReportOptions,
} from './interfaces/analysis-options.interface';
import { getURL } from './utils/httpUtils';
import { JsonApiError } from './interfaces/json-api';

type ResultSuccess<T> = { type: 'success'; value: T };
type ResultError<E> = {

export type ResultError<E> = {
type: 'error';
error: {
statusCode: E;
statusText: string;
apiName: string;
detail?: string | undefined;
};
};

Expand All @@ -41,31 +44,58 @@ export interface ConnectionOptions {
// The trick to typecast union type alias
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isSubsetErrorCode<T>(code: any, messages: { [c: number]: string }): code is T {
if (code in messages) {
return true;
return code in messages;
}

function isJsonApiErrors(input: unknown): input is JsonApiError[] {
if (!Array.isArray(input)) {
return false;
}

for (const element of input) {
if (
typeof element !== 'object' ||
!('status' in element) ||
!('code' in element) ||
!('title' in element) ||
!('detail' in element)
) {
return false;
}
}
return false;

return true;
}

function generateError<E>(
errorCode: number,
messages: { [c: number]: string },
apiName: string,
error?: string,
errorMessage?: string,
errors?: unknown,
): ResultError<E> {
if (!isSubsetErrorCode<E>(errorCode, messages)) {
throw { errorCode, messages, apiName };
throw { statusCode: errorCode, statusText: errorMessage || 'unknown error occurred', apiName };
}

const statusCode = errorCode;
const statusText = error ?? messages[errorCode];
let statusText = errorMessage ?? messages[errorCode];
let detail;

if (isJsonApiErrors(errors)) {
const error = errors[0];
const errorLink = error.links?.about;
detail = `${error.title}${errorLink ? `, more info: ${errorLink}` : ``}`;
statusText = error.title;
}

return {
type: 'error',
error: {
apiName,
statusCode,
statusText,
detail,
},
};
}
Expand All @@ -82,6 +112,7 @@ const GENERIC_ERROR_MESSAGES: { [P in GenericErrorTypes]: string } = {
[ErrorCodes.timeout]: DEFAULT_ERROR_MESSAGES[ErrorCodes.timeout],
[ErrorCodes.dnsNotFound]: DEFAULT_ERROR_MESSAGES[ErrorCodes.dnsNotFound],
[ErrorCodes.connectionRefused]: DEFAULT_ERROR_MESSAGES[ErrorCodes.connectionRefused],
[ErrorCodes.unprocessableEntity]: DEFAULT_ERROR_MESSAGES[ErrorCodes.unprocessableEntity],
};

interface StartSessionOptions {
Expand All @@ -92,8 +123,7 @@ interface StartSessionOptions {
export async function compressAndEncode(payload: unknown): Promise<Buffer> {
// encode payload and compress;
const deflate = promisify(gzip);
const compressedPayload = await deflate(Buffer.from(JSON.stringify(payload)).toString('base64'));
return compressedPayload;
return await deflate(Buffer.from(JSON.stringify(payload)).toString('base64'));
}

export function startSession(options: StartSessionOptions): StartSessionResponseDto {
Expand Down Expand Up @@ -271,7 +301,13 @@ export async function createBundle(
if (res.success) {
return { type: 'success', value: res.body };
}
return generateError<CreateBundleErrorCodes>(res.errorCode, CREATE_BUNDLE_ERROR_MESSAGES, 'createBundle');
return generateError<CreateBundleErrorCodes>(
res.errorCode,
CREATE_BUNDLE_ERROR_MESSAGES,
'createBundle',
undefined,
res.errors,
);
}

export type CheckBundleErrorCodes =
Expand Down Expand Up @@ -431,8 +467,16 @@ export async function getAnalysis(
};

const res = await makeRequest<GetAnalysisResponseDto>(config);
if (res.success) return { type: 'success', value: res.body };
return generateError<GetAnalysisErrorCodes>(res.errorCode, GET_ANALYSIS_ERROR_MESSAGES, 'getAnalysis');
if (res.success) {
return { type: 'success', value: res.body };
}
return generateError<GetAnalysisErrorCodes>(
res.errorCode,
GET_ANALYSIS_ERROR_MESSAGES,
'getAnalysis',
undefined,
res.errors,
);
}

export type ReportErrorCodes =
Expand Down
9 changes: 9 additions & 0 deletions src/interfaces/json-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type JsonApiError = {
links?: {
about?: string;
};
status: string;
code: string;
title: string;
detail: string;
};
7 changes: 6 additions & 1 deletion src/needle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ interface SuccessResponse<T> {
success: true;
body: T;
}

export type FailedResponse = {
success: false;
errorCode: number;
error: Error | undefined;
errors?: unknown;
};

export async function makeRequest<T = void>(
Expand Down Expand Up @@ -109,6 +111,9 @@ export async function makeRequest<T = void>(

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const errorMessage = response?.body?.error;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const errors = response?.body?.errors as unknown;

if (errorMessage) {
error = error ?? new Error(errorMessage);
}
Expand All @@ -130,7 +135,7 @@ export async function makeRequest<T = void>(
await sleep(REQUEST_RETRY_DELAY);
} else {
attempts = 0;
return { success: false, errorCode, error };
return { success: false, errorCode, error, errors };
}
} while (attempts > 0);

Expand Down
164 changes: 162 additions & 2 deletions tests/http.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import needle from 'needle';
import { getVerifyCallbackUrl } from '../src/http';
import {
createBundle,
CreateBundleErrorCodes,
getAnalysis,
GetAnalysisErrorCodes,
getVerifyCallbackUrl,
ResultError,
} from '../src/http';
import { baseURL, source } from './constants/base';
import * as needle from '../src/needle';


describe('HTTP', () => {
const authHost = 'https://dev.snyk.io';
Expand Down Expand Up @@ -41,4 +50,155 @@ describe('HTTP', () => {

expect(url).toBe(`${authHost}/api/verify/callback`);
});

describe('getAnalysis', () => {
const options = {
baseURL,
sessionToken: 'token',
bundleHash: '123',
severity: 1,
source,
}

it('should return error on failed response', async () => {
jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false,
errorCode: 400,
error: new Error('uh oh')
})

const result = await getAnalysis(options) as ResultError<GetAnalysisErrorCodes>
expect(result.error).toEqual({
apiName: 'getAnalysis',
statusCode: 400,
statusText: 'Bad request',
});
});

it('should return error with detail for json api type errors on failed response', async () => {
const jsonApiError = {
status: '500',
code: 'SNYK_0001',
title: 'bad error',
detail: 'detail',
}

jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false,
errorCode: 422,
error: new Error('uh oh'),
errors: [jsonApiError]})

const result = await getAnalysis(options) as ResultError<GetAnalysisErrorCodes>

expect(result.type).toEqual('error')
expect(result.error).toEqual({
apiName: 'getAnalysis',
statusCode: 422,
statusText: 'bad error',
detail: 'bad error'
});
});

it('should return error with detail for json api type errors with link on failed response', async () => {
const jsonApiError = {
status: '500',
code: 'SNYK_0001',
title: 'bad error',
detail: 'detail',
links : {
about: 'https://snyk.io'
}
}

jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false,
errorCode: 422,
error: new Error('uh oh'),
errors: [jsonApiError]})

const result = await getAnalysis(options) as ResultError<GetAnalysisErrorCodes>

expect(result.type).toEqual('error')
expect(result.error).toEqual({
apiName: 'getAnalysis',
statusCode: 422,
statusText: 'bad error',
detail: 'bad error, more info: https://snyk.io'
});
});
})

describe('createBundle', () => {
const options = {
baseURL,
sessionToken: 'token',
bundleHash: '123',
severity: 1,
source,
files: {}
}

it('should return error on failed response', async () => {
jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false,
errorCode: 400,
error: new Error('uh oh')
})

const result = await createBundle(options) as ResultError<CreateBundleErrorCodes>
expect(result.error).toEqual({
apiName: 'createBundle',
statusCode: 400,
statusText: 'Request payload doesn\'t match the specifications',
});
});

it('should return error with detail for json api type errors on failed response', async () => {
const jsonApiError = {
status: '500',
code: 'SNYK_0001',
title: 'bad error',
detail: 'detail',
}

jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false,
errorCode: 422,
error: new Error('uh oh'),
errors: [jsonApiError]})

const result = await createBundle(options) as ResultError<CreateBundleErrorCodes>

expect(result.type).toEqual('error')
expect(result.error).toEqual({
apiName: 'createBundle',
statusCode: 422,
statusText: 'bad error',
detail: 'bad error'
});
});

it('should return error with detail for json api type errors with link on failed response', async () => {
const jsonApiError = {
status: '500',
code: 'SNYK_0001',
title: 'bad error',
detail: 'detail',
links : {
about: 'https://snyk.io'
}
}

jest.spyOn(needle, 'makeRequest').mockResolvedValue({ success: false,
errorCode: 422,
error: new Error('uh oh'),
errors: [jsonApiError]})

const result = await createBundle(options) as ResultError<CreateBundleErrorCodes>

expect(result.type).toEqual('error')
expect(result.error).toEqual({
apiName: 'createBundle',
statusCode: 422,
statusText: 'bad error',
detail: 'bad error, more info: https://snyk.io'
});
});
})
});

0 comments on commit 73ed509

Please sign in to comment.