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 8, 2023
1 parent f2f6aa4 commit 569d212
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 23 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;
60 changes: 42 additions & 18 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,28 @@ 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 { generateJsonApiError, getURL, isJsonApiErrors } from './utils/httpUtils';

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

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

Expand All @@ -41,24 +43,26 @@ 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 false;
return code in messages;
}

function generateError<E>(
errorCode: number,
messages: { [c: number]: string },
apiName: string,
error?: string,
errorMessage?: string,
errors?: unknown,
): ResultError<E> {
if (isJsonApiErrors(errors)) {
return generateJsonApiError<E>(errors, errorCode, apiName);
}

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];
const statusText = errorMessage ?? messages[errorCode];

return {
type: 'error',
Expand All @@ -82,6 +86,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 +97,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 +275,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 @@ -359,7 +369,13 @@ export async function extendBundle(
isJson: false,
});
if (res.success) return { type: 'success', value: res.body };
return generateError<ExtendBundleErrorCodes>(res.errorCode, EXTEND_BUNDLE_ERROR_MESSAGES, 'extendBundle');
return generateError<ExtendBundleErrorCodes>(
res.errorCode,
EXTEND_BUNDLE_ERROR_MESSAGES,
'extendBundle',
undefined,
res.errors,
);
}

// eslint-disable-next-line no-shadow
Expand Down Expand Up @@ -431,8 +447,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
38 changes: 38 additions & 0 deletions src/utils/httpUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ORG_ID_REGEXP } from '../constants';
import { JsonApiError } from '../interfaces/json-api';
import { ResultError } from '../http';

export function getURL(baseURL: string, path: string, orgId?: string): string {
if (routeToGateway(baseURL)) {
Expand All @@ -17,3 +19,39 @@ function routeToGateway(baseURL: string): boolean {
function isValidOrg(orgId?: string): boolean {
return orgId !== undefined && ORG_ID_REGEXP.test(orgId);
}

export 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 true;
}

export function generateJsonApiError<E>(errors: JsonApiError[], statusCode: number, apiName: string): ResultError<E> {
const error = errors[0];
const errorLink = error.links?.about;
const detail = `${error.title}: ${error.detail}${errorLink ? ` (more info: ${errorLink})` : ``}`;
const statusText = error.title;
return {
type: 'error',
error: {
apiName,
statusCode,
statusText,
detail,
},
};
}
123 changes: 121 additions & 2 deletions tests/http.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import needle from 'needle';
import { getVerifyCallbackUrl } from '../src/http';
import { createBundle, extendBundle, getAnalysis, getVerifyCallbackUrl, ResultError } from '../src/http';
import { baseURL, source } from './constants/base';
import * as needle from '../src/needle';
import { GenericErrorTypes } from '../src/constants';

const jsonApiError = {
status: '422',
code: 'SNYK_0001',
title: 'bad error',
detail: 'detail',
links: {
about: 'https://snyk.io',
},
};

describe('HTTP', () => {
const authHost = 'https://dev.snyk.io';
Expand Down Expand Up @@ -41,4 +53,111 @@ 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<GenericErrorTypes>;

expect(result.error.apiName).toEqual('getAnalysis');
expect(result.error.statusText).toBeTruthy();
expect(result.error.statusCode).toEqual(400);
});

it('should return error with detail for json api type errors on failed response', async () => {
jest
.spyOn(needle, 'makeRequest')
.mockResolvedValue({ success: false, errorCode: 422, error: new Error('uh oh'), errors: [jsonApiError] });

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

expect(result.error.apiName).toEqual('getAnalysis');
expect(result.error.statusText).toBeTruthy();
expect(result.error.statusCode).toEqual(422);
expect(result.error.detail).toBeTruthy();
});
});

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<GenericErrorTypes>;

expect(result.error.apiName).toEqual('createBundle');
expect(result.error.statusText).toBeTruthy();
expect(result.error.statusCode).toEqual(400);
});

it('should return error with detail for json api type errors on failed response', async () => {
jest
.spyOn(needle, 'makeRequest')
.mockResolvedValue({ success: false, errorCode: 422, error: new Error('uh oh'), errors: [jsonApiError] });

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

expect(result.error.apiName).toEqual('createBundle');
expect(result.error.statusText).toBeTruthy();
expect(result.error.statusCode).toEqual(422);
expect(result.error.detail).toBeTruthy();
});
});

describe('extendBundle', () => {
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 extendBundle(options)) as ResultError<GenericErrorTypes>;

expect(result.error.apiName).toEqual('extendBundle');
expect(result.error.statusText).toBeTruthy();
expect(result.error.statusCode).toEqual(400);
});

it('should return error with detail for json api type errors on failed response', async () => {
jest
.spyOn(needle, 'makeRequest')
.mockResolvedValue({ success: false, errorCode: 422, error: new Error('uh oh'), errors: [jsonApiError] });

const result = (await extendBundle(options)) as ResultError<GenericErrorTypes>;

expect(result.error.apiName).toEqual('extendBundle');
expect(result.error.statusText).toBeTruthy();
expect(result.error.statusCode).toEqual(422);
expect(result.error.detail).toBeTruthy();
});
});
});
Loading

0 comments on commit 569d212

Please sign in to comment.