Compare commits

...
7 changed files with 244 additions and 46 deletions

No files matched your search

@@ -28,6 +28,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
);
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.type).toBe(errors.ExpoErrorType.SYSTEM);
});
it('detects log for invalid bundler and reports it to user', async () => {
@@ -46,6 +47,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
);
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
expect(err.type).toBe(errors.ExpoErrorType.USER);
});
it('does not detect errors if they show up in different build phase', async () => {
@@ -94,8 +94,10 @@ export async function resolveBuildPhaseErrorAsync(
const errorCode =
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
userFacingError.errorCode;
const type = isUnknownUserError ? errors.ExpoErrorType.SYSTEM : errors.ExpoErrorType.USER;
return new errors.BuildError(message, {
type,
errorCode,
userFacingErrorCode: userFacingError.errorCode,
userFacingMessage: userFacingError.message,
@@ -1,4 +1,5 @@
import { UserFacingError } from '../errors';
import { BuildMode } from '../common';
import { ErrorCode, ExpoErrorType, UserFacingError, toBuildError } from '../errors';
describe(UserFacingError, () => {
it('supports docsUrl in options', () => {
@@ -28,3 +29,22 @@ describe(UserFacingError, () => {
expect(error.cause).toBe(cause);
});
});
describe(toBuildError, () => {
it('converts UserFacingError to a user-type BuildError', () => {
const err = toBuildError(new UserFacingError('EAS_BUILD_TEST_USER_ERROR', 'user message'));
expect(err.type).toBe(ExpoErrorType.USER);
expect(err.errorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
expect(err.userFacingErrorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
expect(err.userFacingMessage).toBe('user message');
});
it('maps unknown custom errors to UNKNOWN_CUSTOM_BUILD_ERROR', () => {
const err = toBuildError(new Error('oops'), { mode: BuildMode.CUSTOM });
expect(err.type).toBe(ExpoErrorType.SYSTEM);
expect(err.errorCode).toBe(ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR);
expect(err.userFacingErrorCode).toBe(ErrorCode.UNKNOWN_CUSTOM_BUILD_ERROR);
});
});
+122 -14
View File
@@ -1,3 +1,4 @@
import { BuildMode } from './common';
import { BuildPhase, buildPhaseDisplayName } from './logs';
export enum ErrorCode {
@@ -10,6 +11,11 @@ export enum ErrorCode {
BUILD_TIMEOUT_ERROR = 'EAS_BUILD_TIMEOUT_ERROR',
}
export enum ExpoErrorType {
USER = 'USER',
SYSTEM = 'SYSTEM',
}
export interface ExternalBuildError {
errorCode: string;
message: string;
@@ -17,32 +23,52 @@ export interface ExternalBuildError {
buildPhase?: BuildPhase;
}
interface BuildErrorDetails {
interface ExpoErrorDetails {
type: ExpoErrorType;
errorCode: string;
userFacingMessage: string;
userFacingErrorCode: string;
docsUrl?: string;
innerError?: Error;
buildPhase?: BuildPhase;
metadata?: Record<string, unknown>;
cause?: unknown;
}
export class BuildError extends Error {
interface BuildErrorDetails {
type?: ExpoErrorType;
errorCode: string;
userFacingMessage: string;
userFacingErrorCode: string;
docsUrl?: string;
innerError?: Error;
buildPhase?: BuildPhase;
metadata?: Record<string, unknown>;
cause?: unknown;
}
export class ExpoError extends Error {
public type: ExpoErrorType;
public errorCode: string;
public userFacingMessage: string;
public userFacingErrorCode: string;
public docsUrl?: string;
public innerError?: Error;
public buildPhase?: BuildPhase;
public metadata: Record<string, unknown>;
constructor(message: string, details: BuildErrorDetails) {
super(message);
constructor(message: string, details: ExpoErrorDetails) {
super(message, { cause: details.cause });
this.type = details.type;
this.errorCode = details.errorCode;
this.userFacingErrorCode = details.userFacingErrorCode;
this.userFacingMessage = details.userFacingMessage;
this.docsUrl = details.docsUrl;
this.innerError = details.innerError;
this.buildPhase = details.buildPhase;
this.metadata = details.metadata ?? {};
}
public format(): ExternalBuildError {
return {
errorCode: this.userFacingErrorCode,
@@ -53,19 +79,66 @@ export class BuildError extends Error {
}
}
export class UserFacingError extends Error {
public docsUrl?: string;
constructor(
public errorCode: string,
public message: string,
options?: { docsUrl?: string; cause?: unknown }
) {
super(message, { cause: options?.cause });
this.docsUrl = options?.docsUrl;
export class BuildError extends ExpoError {
constructor(message: string, details: BuildErrorDetails) {
super(message, {
...details,
type: details.type ?? ExpoErrorType.SYSTEM,
});
}
}
interface UserErrorOptions {
docsUrl?: string;
cause?: unknown;
buildPhase?: BuildPhase;
metadata?: Record<string, unknown>;
}
export class UserError extends ExpoError {
constructor(errorCode: string, message: string, options?: UserErrorOptions) {
super(message, {
type: ExpoErrorType.USER,
errorCode,
userFacingErrorCode: errorCode,
userFacingMessage: message,
docsUrl: options?.docsUrl,
innerError: options?.cause instanceof Error ? options.cause : undefined,
buildPhase: options?.buildPhase,
metadata: options?.metadata,
cause: options?.cause,
});
}
}
interface SystemErrorOptions {
userFacingErrorCode?: string;
userFacingMessage?: string;
docsUrl?: string;
innerError?: Error;
buildPhase?: BuildPhase;
metadata?: Record<string, unknown>;
cause?: unknown;
}
export class SystemError extends ExpoError {
constructor(errorCode: string, message: string, options?: SystemErrorOptions) {
super(message, {
type: ExpoErrorType.SYSTEM,
errorCode,
userFacingErrorCode: options?.userFacingErrorCode ?? errorCode,
userFacingMessage: options?.userFacingMessage ?? message,
docsUrl: options?.docsUrl,
innerError: options?.innerError,
buildPhase: options?.buildPhase,
metadata: options?.metadata,
cause: options?.cause,
});
}
}
export class UserFacingError extends UserError {}
export class UnknownError extends UserFacingError {
constructor(buildPhase?: BuildPhase) {
super(
@@ -106,3 +179,38 @@ export class CredentialsDistCertMismatchError extends UserFacingError {
super('EAS_BUILD_CREDENTIALS_DIST_CERT_MISMATCH', message);
}
}
export function toBuildError(
error: unknown,
options?: {
mode?: BuildMode;
buildPhase?: BuildPhase;
}
): BuildError {
if (error instanceof BuildError) {
return error;
}
if (error instanceof ExpoError) {
return new BuildError(error.message, {
type: error.type,
errorCode: error.errorCode,
userFacingErrorCode: error.userFacingErrorCode,
userFacingMessage: error.userFacingMessage,
docsUrl: error.docsUrl,
innerError: error.innerError,
buildPhase: error.buildPhase ?? options?.buildPhase,
metadata: error.metadata,
cause: error.cause,
});
}
const isCustomMode =
options?.mode === BuildMode.CUSTOM || options?.mode === BuildMode.REPACK;
const fallback = isCustomMode ? new UnknownCustomBuildError() : new UnknownBuildError();
fallback.buildPhase = options?.buildPhase;
if (error && typeof error === 'object') {
fallback.innerError = error as Error;
}
return fallback;
}
@@ -320,6 +320,7 @@ describe('State sync mechanism', () => {
expect(message.applicationArchiveName).toBe(`application-${buildId}`);
expect(message.type).toBe('state-response');
expect(message.status).toBe('error');
expect(message.internalErrorCode).toBe(errors.ErrorCode.SERVER_ERROR);
} catch (err) {
throw err;
} finally {
@@ -417,5 +418,72 @@ describe('State sync mechanism', () => {
ws.close();
clearTimeout(messageTimeout);
});
it('should include user-facing error details when builder throws UserFacingError', async () => {
jest.mocked(Builders.androidBuilder).mockImplementation(async () => {
throw new errors.UserFacingError('EAS_BUILD_TEST_USER_ERROR', 'This is user-visible');
});
const dispatchWS = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const dispatchHelper = new WsHelper(dispatchWS);
await dispatchHelper.onOpen();
dispatchWS.send(
JSON.stringify({
type: 'dispatch',
buildId,
job: createTestAndroidJob(),
initiatingUserId: '14367e1b-26fc-4c00-aedb-0629d78f8286',
metadata: {
trackingContext: {},
},
})
);
dispatchWS.close();
await dispatchHelper.onClose();
const ws = new WebSocket(`ws://localhost:${port}?expo_vm_name=${hostname()}`);
const helper = new WsHelper(ws);
let stateResponsePromiseResolve: () => void;
const stateResponsePromise = new Promise<void>(res => {
stateResponsePromiseResolve = res;
});
const onMessage = jest.fn((message: any) => {
logger.debug('message received');
clearTimeout(messageTimeout);
try {
expect(message).toBeTruthy();
expect(message.type).toBe('state-response');
expect(message.status).toBe('error');
expect(message.externalBuildError).toEqual({
errorCode: 'EAS_BUILD_TEST_USER_ERROR',
message: 'This is user-visible',
});
expect(message.internalErrorCode).toBe('EAS_BUILD_TEST_USER_ERROR');
} catch (err) {
throw err;
} finally {
stateResponsePromiseResolve();
}
});
const openPromise = helper.onOpen();
helper.onMessage(onMessage);
await openPromise;
messageTimeout = setTimeout(() => {
unreachableCode('state-response timeout');
}, 3000);
ws.send(JSON.stringify({ type: 'state-query', buildId }));
logger.debug('sent state-query');
await stateResponsePromise;
expect(helper.onErrorCb).not.toHaveBeenCalled();
expect(helper.onOpenCb).toHaveBeenCalled();
expect(helper.onMessageCb).toHaveBeenCalled();
expect(helper.onCloseCb).not.toHaveBeenCalled();
ws.close();
clearTimeout(messageTimeout);
});
});
});
+23 -30
View File
@@ -111,6 +111,8 @@ export default class BuildService {
public async finishError(err: errors.BuildError, artifacts: Artifacts | null): Promise<void> {
logger.error({ err }, 'Job finished with error');
const websocketErrorCode =
err.type === errors.ExpoErrorType.SYSTEM ? errors.ErrorCode.SERVER_ERROR : err.errorCode;
this.state.finish(Worker.Status.ERROR, {
applicationArchiveName: artifacts?.APPLICATION_ARCHIVE ?? null,
@@ -128,7 +130,7 @@ export default class BuildService {
applicationArchiveName: artifacts?.APPLICATION_ARCHIVE ?? null,
buildArtifactsName: artifacts?.BUILD_ARTIFACTS ?? null,
externalBuildError: err.format(),
internalErrorCode: err.errorCode,
internalErrorCode: websocketErrorCode,
});
}
this.checkForHangingWorker(isSocketClosed);
@@ -308,30 +310,42 @@ export default class BuildService {
await this.finishSuccess(artifacts);
} catch (error: any) {
const maybeArtifacts = (error.artifacts as Artifacts | undefined) ?? null;
const err = toBuildError(error, job);
const maybeRawError = error instanceof errors.BuildError ? error.innerError : error;
const err = errors.toBuildError(error, {
mode: 'mode' in job ? job.mode : undefined,
});
const maybeRawError = err.innerError ?? (error instanceof Error ? error : undefined);
const maybeRawErrorWithOutput = maybeRawError as
| (Error & { stdout?: string; stderr?: string })
| undefined;
sentry.handleError(err.message, maybeRawError, {
tags: {
...(err.buildPhase ? { buildPhase: err.buildPhase } : {}),
expoErrorType: err.type,
errorCode: err.errorCode,
userFacingErrorCode: err.userFacingErrorCode,
...('type' in job ? { workflow: job.type } : {}),
},
extras: {
buildId: this.buildId,
...(maybeRawError.stdout ? { stdout: getLastNLines(100, maybeRawError.stdout) } : {}),
...(maybeRawError.stderr ? { stderr: getLastNLines(100, maybeRawError.stderr) } : {}),
...(maybeRawErrorWithOutput?.stdout
? { stdout: getLastNLines(100, maybeRawErrorWithOutput.stdout) }
: {}),
...(maybeRawErrorWithOutput?.stderr
? { stderr: getLastNLines(100, maybeRawErrorWithOutput.stderr) }
: {}),
},
level: err.type === errors.ExpoErrorType.USER ? 'warning' : 'error',
});
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken && err.errorCode === errors.ErrorCode.UNKNOWN_ERROR) {
let rawErrorMessage: string = '';
if (maybeRawError.stderr) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
if (maybeRawErrorWithOutput?.stderr) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawErrorWithOutput.stderr);
}
if (maybeRawError.stdout) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stdout);
if (maybeRawErrorWithOutput?.stdout) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawErrorWithOutput.stdout);
}
await turtleFetch(
@@ -367,27 +381,6 @@ export default class BuildService {
}
}
function toBuildError(error: unknown, job: Job): errors.BuildError {
if (error instanceof errors.BuildError) {
return error;
}
if (error instanceof errors.UserFacingError) {
const innerError = error.cause instanceof Error ? error.cause : error;
return new errors.BuildError(error.message, {
errorCode: error.errorCode,
userFacingErrorCode: error.errorCode,
userFacingMessage: error.message,
docsUrl: error.docsUrl,
innerError,
});
}
return 'mode' in job && [BuildMode.CUSTOM, BuildMode.REPACK].includes(job.mode)
? new errors.UnknownCustomBuildError()
: new errors.UnknownBuildError();
}
function getLastNLines(numberOfLines: number, stream: string): string {
const lines = stream.split('\n');
if (lines.length <= numberOfLines) {
+6 -1
View File
@@ -43,6 +43,11 @@ class State {
}
public stateResponse(): WorkerMessage.StateResponse {
const websocketErrorCode =
this.userError?.type === errors.ExpoErrorType.SYSTEM
? errors.ErrorCode.SERVER_ERROR
: this.userError?.errorCode;
return {
type: WorkerMessage.MessageType.STATE_RESPONSE,
status: this.status,
@@ -50,7 +55,7 @@ class State {
applicationArchiveName: this.applicationArchiveName ?? null,
...(this.userError && {
externalBuildError: this.userError.format(),
internalErrorCode: this.userError.errorCode,
internalErrorCode: websocketErrorCode,
}),
abortReason: this.abortReason,
};