Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
55 changes: 48 additions & 7 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ export type PlainObject = Record<string, any>;
* @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) {
Expand All @@ -143,15 +148,45 @@ 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 {
Comment thread
andrii-bodnar marked this conversation as resolved.
Outdated
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' &&
Expand All @@ -160,6 +195,13 @@ export function handleHttpClientError(error: HttpClientError): never {
: 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[] = [];
Expand Down Expand Up @@ -188,14 +230,13 @@ export function handleHttpClientError(error: HttpClientError): never {
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) {
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(error.message, httpStatus, crowdinResponseErrors);
}
throw new CrowdinError(`unknown http error: ${String(error)}`, 500, crowdinResponseErrors);
}
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