Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/core/error-handling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { AxiosError } from 'axios';
import { HttpClientError } from './http-client-error';
import { FetchClientJsonPayloadError } from './internal/fetch/fetchClientError';

/**
* @internal
*/
export class CrowdinError extends Error {
/**
* The error payload the API returned: the `error`/`errors` member of a Crowdin error response,
* or the whole `{ data: … }` body when an application returned the error through the
* applications proxy (`/applications/{identifier}/api/{path}`).
*/
public apiError: any;
public code: number;
constructor(message: string, code: number, apiError: any) {
super(message);
this.code = code;
this.apiError = apiError;
}
}

/**
* @internal
*/
export class CrowdinValidationError extends CrowdinError {
public validationCodes: { key: string; codes: string[] }[];
constructor(message: string, validationCodes: { key: string; codes: string[] }[], apiError: any) {
super(message, 400, apiError);
this.validationCodes = validationCodes;
}
}

function isAxiosError(error: any): error is AxiosError {
return error instanceof AxiosError || !!error.response?.data;
}

/**
* Reads the error an application returned through the applications proxy
* (`/applications/{identifier}/api/{path}`). Crowdin forwards the application's status code and
* wraps its body in the same `data` envelope as a successful response, so the error sits one level
* deeper than in Crowdin's own error responses. Applications report errors in several shapes:
* `{ error: { message, code? } }`, `{ error: 'message' }`, or `{ message, code? }`.
* Returns undefined when the body is not an application error.
*/
function proxiedApplicationError(body: unknown): { message: string; code?: number } | undefined {
const data = isRecord(body) ? body.data : undefined;
if (!isRecord(data)) {
return undefined;
}
if (typeof data.error === 'string') {
return { message: data.error };
}
const source = isRecord(data.error) && typeof data.error.message === 'string' ? data.error : data;
if (typeof source.message !== 'string') {
return undefined;
}
return { message: source.message, code: typeof source.code === 'number' ? source.code : undefined };
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

/**
* @internal
*/
export function handleHttpClientError(error: HttpClientError): never {
let responseBody: unknown = null;
let crowdinResponseErrors: any = null;

if (isAxiosError(error)) {
responseBody = error.response?.data;
crowdinResponseErrors = (error.response?.data as any)?.errors || (error.response?.data as any)?.error;
} else if (error instanceof FetchClientJsonPayloadError) {
responseBody = error.jsonPayload;
crowdinResponseErrors =
error.jsonPayload &&
typeof error.jsonPayload === 'object' &&
('errors' in error.jsonPayload || 'error' in error.jsonPayload)
? error.jsonPayload.errors || error.jsonPayload.error
: null;
}

const httpStatus =
error instanceof AxiosError && error.response?.status
? error.response?.status
: error instanceof FetchClientJsonPayloadError
? error.statusCode
: 500;

if (Array.isArray(crowdinResponseErrors)) {
const validationCodes: { key: string; codes: string[] }[] = [];
const validationMessages: string[] = [];
crowdinResponseErrors.forEach((e: any) => {
if (typeof e.index === 'number' || typeof e.error?.key === 'number') {
throw new CrowdinValidationError(
JSON.stringify(crowdinResponseErrors, null, 2),
[],
crowdinResponseErrors,
);
}
if (e.error?.key && Array.isArray(e.error?.errors)) {
const codes: string[] = [];
e.error.errors.forEach((er: any) => {
if (er.message && er.code) {
codes.push(er.code);
validationMessages.push(er.message);
}
});
validationCodes.push({ key: e.error.key, codes });
}
});
const message = validationMessages.length === 0 ? 'Validation error' : validationMessages.join(', ');
throw new CrowdinValidationError(message, validationCodes, crowdinResponseErrors);
} else if (crowdinResponseErrors?.message && crowdinResponseErrors?.code) {
throw new CrowdinError(crowdinResponseErrors.message, crowdinResponseErrors.code, crowdinResponseErrors);
}

const applicationError = proxiedApplicationError(responseBody);
if (applicationError) {
throw new CrowdinError(applicationError.message, applicationError.code ?? httpStatus, responseBody);
}

if (error instanceof Error) {
throw new CrowdinError(error.message, httpStatus, crowdinResponseErrors);
}
throw new CrowdinError(`unknown http error: ${String(error)}`, 500, crowdinResponseErrors);
}
91 changes: 3 additions & 88 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { AxiosError } from 'axios';
import { handleHttpClientError } from './error-handling';
import { HttpClient } from './http-client';
import { HttpClientError, toHttpClientError } from './http-client-error';
import { toHttpClientError } from './http-client-error';
import { AxiosProvider } from './internal/axios/axiosProvider';
import { FetchClient } from './internal/fetch/fetchClient';
import { FetchClientJsonPayloadError } from './internal/fetch/fetchClientError';
import { RetryConfig, RetryService } from './internal/retry';

export * from './error-handling';
export * from './http-client';

export type HttpClientType = 'axios' | 'fetch';
Expand Down Expand Up @@ -115,91 +115,6 @@ export interface Attribute {

export type PlainObject = Record<string, any>;

/**
* @internal
*/
export class CrowdinError extends Error {
public apiError: any;
public code: number;
constructor(message: string, code: number, apiError: any) {
super(message);
this.code = code;
this.apiError = apiError;
}
}

/**
* @internal
*/
export class CrowdinValidationError extends CrowdinError {
public validationCodes: { key: string; codes: string[] }[];
constructor(message: string, validationCodes: { key: string; codes: string[] }[], apiError: any) {
super(message, 400, apiError);
this.validationCodes = validationCodes;
}
}

function isAxiosError(error: any): error is AxiosError {
return error instanceof AxiosError || !!error.response?.data;
}

/**
* @internal
*/
export function handleHttpClientError(error: HttpClientError): never {
let crowdinResponseErrors: any = null;

if (isAxiosError(error)) {
crowdinResponseErrors = (error.response?.data as any)?.errors || (error.response?.data as any)?.error;
} else if (error instanceof FetchClientJsonPayloadError) {
crowdinResponseErrors =
error.jsonPayload &&
typeof error.jsonPayload === 'object' &&
('errors' in error.jsonPayload || 'error' in error.jsonPayload)
? error.jsonPayload.errors || error.jsonPayload.error
: null;
}

if (Array.isArray(crowdinResponseErrors)) {
const validationCodes: { key: string; codes: string[] }[] = [];
const validationMessages: string[] = [];
crowdinResponseErrors.forEach((e: any) => {
if (typeof e.index === 'number' || typeof e.error?.key === 'number') {
throw new CrowdinValidationError(
JSON.stringify(crowdinResponseErrors, null, 2),
[],
crowdinResponseErrors,
);
}
if (e.error?.key && Array.isArray(e.error?.errors)) {
const codes: string[] = [];
e.error.errors.forEach((er: any) => {
if (er.message && er.code) {
codes.push(er.code);
validationMessages.push(er.message);
}
});
validationCodes.push({ key: e.error.key, codes });
}
});
const message = validationMessages.length === 0 ? 'Validation error' : validationMessages.join(', ');
throw new CrowdinValidationError(message, validationCodes, crowdinResponseErrors);
} else if (crowdinResponseErrors?.message && crowdinResponseErrors?.code) {
throw new CrowdinError(crowdinResponseErrors.message, crowdinResponseErrors.code, crowdinResponseErrors);
}

if (error instanceof Error) {
const code =
error instanceof AxiosError && error.response?.status
? error.response?.status
: error instanceof FetchClientJsonPayloadError
? error.statusCode
: 500;
throw new CrowdinError(error.message, code, crowdinResponseErrors);
}
throw new CrowdinError(`unknown http error: ${String(error)}`, 500, crowdinResponseErrors);
}

export abstract class CrowdinApi {
private static readonly CROWDIN_API_DOMAIN: string = 'api.crowdin.com';
private static readonly AXIOS_INSTANCE = new AxiosProvider();
Expand Down
102 changes: 99 additions & 3 deletions tests/core/error-handling.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AxiosError } from 'axios';
import { CrowdinValidationError, handleHttpClientError } from '../../src/core/';
import { CrowdinError, CrowdinValidationError, handleHttpClientError } from '../../src/core/';
import { FetchClientJsonPayloadError } from '../../src/core/internal/fetch/fetchClientError';

const genericCrowdinErrorPayload = {
Expand Down Expand Up @@ -84,14 +84,46 @@ const unrecognizedErrorPayload = {
errors: [{ foo: 'bar' }],
};

const createAxiosError = (errorPayload: unknown): AxiosError => {
/**
* Errors an application returns through the applications proxy
* (`/applications/{identifier}/api/{path}`) arrive with the application's own status code and
* body, wrapped by Crowdin in the same `data` envelope as a successful response.
*/
const proxiedApplicationErrorPayload = {
data: {
error: {
message: 'Missing required parameter: projectId',
},
},
};

const proxiedApplicationStringErrorPayload = {
data: {
error: 'Access denied',
},
};

const proxiedApplicationCodedErrorPayload = {
data: {
message: 'Supplied crowdinToken is invalid or does not belong to this organization',
code: 402,
},
};

const proxiedApplicationDataPayload = {
data: {
foo: 'bar',
},
};

const createAxiosError = (errorPayload: unknown, status = 200): AxiosError => {
/**
* Create an axios error matching Crowdin error responses.
* @see https://github.com/axios/axios/blob/3772c8fe74112a56e3e9551f894d899bc3a9443a/test/specs/core/AxiosError.spec.js#L7
*/
const request = { path: '/api/foo' };
const response = {
status: 200,
status,
data: errorPayload,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -159,6 +191,70 @@ describe('core http error handling', () => {
}
});

it('should surface an application error returned through the applications proxy with axios client', () => {
const error = createAxiosError(proxiedApplicationErrorPayload, 400);
try {
handleHttpClientError(error);
throw new Error('expected re-throw');
} catch (e) {
expect(e).toBeInstanceOf(CrowdinError);
expect(e).not.toBeInstanceOf(CrowdinValidationError);
const err = e as CrowdinError;
expect(err.message).toBe('Missing required parameter: projectId');
expect(err.code).toBe(400);
expect(err.apiError).toEqual(proxiedApplicationErrorPayload);
}
});

it('should surface an application error returned through the applications proxy with fetch client', () => {
const error = new FetchClientJsonPayloadError('foo', proxiedApplicationErrorPayload, 403);
try {
handleHttpClientError(error);
throw new Error('expected re-throw');
} catch (e) {
const err = e as CrowdinError;
expect(err.message).toBe('Missing required parameter: projectId');
expect(err.code).toBe(403);
expect(err.apiError).toEqual(proxiedApplicationErrorPayload);
}
});

it('should surface a plain string application error returned through the applications proxy', () => {
const error = createAxiosError(proxiedApplicationStringErrorPayload, 403);
try {
handleHttpClientError(error);
throw new Error('expected re-throw');
} catch (e) {
const err = e as CrowdinError;
expect(err.message).toBe('Access denied');
expect(err.code).toBe(403);
}
});

it('should prefer the numeric code an application error carries over the http status', () => {
const error = createAxiosError(proxiedApplicationCodedErrorPayload, 400);
try {
handleHttpClientError(error);
throw new Error('expected re-throw');
} catch (e) {
const err = e as CrowdinError;
expect(err.message).toBe('Supplied crowdinToken is invalid or does not belong to this organization');
expect(err.code).toBe(402);
}
});

it('should not mistake proxied application data without an error member for an error payload', () => {
const error = createAxiosError(proxiedApplicationDataPayload, 500);
try {
handleHttpClientError(error);
throw new Error('expected re-throw');
} catch (e) {
const err = e as CrowdinError;
expect(err.message).toBe('Boom!');
expect(err.code).toBe(500);
}
});

it('should produce meaningful error messages on non-Crowdin http errors', () => {
const error = new Error('generic_error');
try {
Expand Down
Loading