Skip to content

Commit

Permalink
feat(framework, web, application-generic): Propagate Bridge server er…
Browse files Browse the repository at this point in the history
…rors to Bridge client (#6726)
  • Loading branch information
rifont authored Oct 21, 2024
1 parent 13aa3f8 commit fc5b3cb
Show file tree
Hide file tree
Showing 35 changed files with 1,418 additions and 912 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function ErrorPrettyRender({ error: unparsedError }) {
{isExpanded && (
<pre
className={css({
whiteSpace: 'pre-wrap',
overflow: 'auto',
border: 'solid',
borderColor: 'input.border.error/40',
Expand All @@ -74,7 +75,8 @@ export function ErrorPrettyRender({ error: unparsedError }) {
fontFamily: 'mono',
})}
>
{JSON.stringify(error.data, null, 2)}
{error.data?.stack}
{!error.data?.stack && JSON.stringify(error.data, null, 2)}
</pre>
)}
</Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,18 @@ export class ExecuteBridgeJob {
action: PostActionEnum.EXECUTE,
searchParams,
afterResponse: async (response) => {
const body = response?.body as string | undefined;
const body = response?.body as string;

if (response.statusCode >= 400) {
let rawMessage: Record<string, unknown>;
try {
rawMessage = JSON.parse(body);
} catch {
Logger.error(`Unexpected body received from Bridge: ${body}`, LOG_CONTEXT);
rawMessage = {
error: `Unexpected body received from Bridge: ${body}`,
};
}
const createExecutionDetailsCommand: CreateExecutionDetailsCommand = {
...CreateExecutionDetailsCommand.getDetailsFromJob(job),
detail: DetailEnum.FAILED_BRIDGE_RETRY,
Expand All @@ -231,7 +240,7 @@ export class ExecuteBridgeJob {
statusCode: response.statusCode,
retryCount: response.retryCount,
message: response.statusMessage,
...(body && body?.length > 0 ? { raw: JSON.parse(body) } : {}),
...(body && body?.length > 0 ? { raw: rawMessage } : {}),
}),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
HttpHeaderKeysEnum,
HttpQueryKeysEnum,
GetActionEnum,
ErrorCodeEnum,
isFrameworkError,
} from '@novu/framework';
import { EnvironmentRepository } from '@novu/dal';
import { HttpRequestHeaderKeysEnum, WorkflowOriginEnum } from '@novu/shared';
Expand Down Expand Up @@ -258,11 +258,8 @@ export class ExecuteBridgeRequest {
body = {};
}

if (
error instanceof HTTPError &&
Object.values(ErrorCodeEnum).includes(body.code as ErrorCodeEnum)
) {
// Handle known Bridge errors. Propagate the error code and message.
if (error instanceof HTTPError && isFrameworkError(body)) {
// Handle known Framework errors. Propagate the error code and message.
throw new HttpException(body, error.response.statusCode);
}

Expand Down
6 changes: 4 additions & 2 deletions packages/framework/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@
"lint:fix": "pnpm lint -- --fix",
"format": "prettier --check --ignore-path .gitignore .",
"format:fix": "prettier --write --ignore-path .gitignore .",
"build": "tsup",
"build": "tsup && pnpm check:circulars",
"build:watch": "tsup --watch",
"$comment:bump:prerelease": "This is a workaround to support `npm version prerelease` with lerna",
"bump:prerelease": "npm version prerelease --preid=alpha & PID=$!; (sleep 1 && kill -9 $PID) & wait $PID",
"release:alpha": "pnpm bump:prerelease || pnpm build && npm publish",
"devtool": "tsx ./scripts/devtool.ts"
"devtool": "tsx ./scripts/devtool.ts",
"check:circulars": "madge --circular --extensions ts --exclude ../../shared ./src"
},
"keywords": [
"novu",
Expand Down Expand Up @@ -151,6 +152,7 @@
"aws-lambda": "^1.0.7",
"express": "^4.19.2",
"h3": "^1.11.1",
"madge": "^8.0.0",
"next": "^13.5.4",
"prettier": "^3.2.5",
"ts-node": "^10.9.2",
Expand Down
67 changes: 67 additions & 0 deletions packages/framework/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Client } from './client';
import {
ExecutionEventPayloadInvalidError,
ExecutionStateCorruptError,
ProviderExecutionFailedError,
StepExecutionFailedError,
StepNotFoundError,
WorkflowNotFoundError,
} from './errors';
Expand Down Expand Up @@ -1624,6 +1626,71 @@ describe('Novu Client', () => {
await expect(client.executeWorkflow(event)).rejects.toThrow(Error);
});

it('should throw a StepExecutionFailedError error when step execution fails', async () => {
const newWorkflow = workflow('test-workflow', async ({ step }) => {
await step.email('send-email', async () => {
throw new Error('Step execution failed');
});
});

client.addWorkflows([newWorkflow]);

const event: Event = {
action: PostActionEnum.EXECUTE,
workflowId: 'test-workflow',
stepId: 'send-email',
subscriber: {},
state: [],
data: {},
payload: {},
inputs: {},
controls: {},
};

await expect(client.executeWorkflow(event)).rejects.toThrow(
new StepExecutionFailedError('send-email', PostActionEnum.EXECUTE, new Error('Step execution failed'))
);
});

it('should throw a ProviderExecutionFailed error when preview execution fails', async () => {
const newWorkflow = workflow('test-workflow', async ({ step }) => {
await step.email(
'send-email',
async () => {
return {
body: 'Test Body',
subject: 'Subject',
};
},
{
providers: {
sendgrid: () => {
throw new Error('Preview execution failed');
},
},
}
);
});

client.addWorkflows([newWorkflow]);

const event: Event = {
action: PostActionEnum.EXECUTE,
workflowId: 'test-workflow',
stepId: 'send-email',
subscriber: {},
state: [],
data: {},
payload: {},
inputs: {},
controls: {},
};

await expect(client.executeWorkflow(event)).rejects.toThrow(
new ProviderExecutionFailedError('sendgrid', PostActionEnum.EXECUTE, new Error('Preview execution failed'))
);
});

it('should sanitize the step output of all channel step types by default', async () => {
const script = `<script>alert('Hello there')</script>`;

Expand Down
52 changes: 31 additions & 21 deletions packages/framework/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
StepNotFoundError,
WorkflowAlreadyExistsError,
WorkflowNotFoundError,
StepExecutionFailedError,
isFrameworkError,
} from './errors';
import type {
ActionStep,
Expand Down Expand Up @@ -272,7 +274,7 @@ export class Client {
return async (stepId, stepResolve, options) => {
const step = this.getStep(event.workflowId, stepId);
const controls = await this.createStepControls(step, event);
const isPreview = event.action === 'preview';
const isPreview = event.action === PostActionEnum.PREVIEW;

if (!isPreview && (await this.shouldSkip(options?.skip as typeof step.options.skip, controls))) {
if (stepId === event.stepId) {
Expand Down Expand Up @@ -356,14 +358,10 @@ export class Client {
const actionMessages = {
[PostActionEnum.EXECUTE]: 'Executing',
[PostActionEnum.PREVIEW]: 'Previewing',
};
} as const;

const actionMessage = (() => {
if (event.action === 'execute') return 'Executed';
if (event.action === 'preview') return 'Previewed';
const actionMessage = actionMessages[event.action];

return 'Invalid action';
})();
const actionMessageFormatted = `${actionMessage} workflowId:`;
// eslint-disable-next-line no-console
console.log(`\n${log.bold(log.underline(actionMessageFormatted))} '${event.workflowId}'`);
Expand Down Expand Up @@ -401,7 +399,7 @@ export class Client {
let executionError: Error | undefined;
try {
if (
event.action === 'execute' && // TODO: move this validation to the handler layer
event.action === PostActionEnum.EXECUTE && // TODO: move this validation to the handler layer
!event.payload &&
!event.data
) {
Expand Down Expand Up @@ -445,9 +443,12 @@ export class Client {
const elapsedTimeInMilliseconds = elapsedSeconds * 1_000 + elapsedNanoseconds / 1_000_000;

const emoji = executionError ? EMOJI.ERROR : EMOJI.SUCCESS;
const resultMessage =
// eslint-disable-next-line no-nested-ternary
event.action === 'execute' ? 'Executed' : event.action === 'preview' ? 'Previewed' : 'Invalid action';
const resultMessages = {
[PostActionEnum.EXECUTE]: 'Executed',
[PostActionEnum.PREVIEW]: 'Previewed',
} as const;
const resultMessage = resultMessages[event.action];

// eslint-disable-next-line no-console
console.log(`${emoji} ${resultMessage} workflowId: \`${event.workflowId}\``);

Expand All @@ -474,7 +475,7 @@ export class Client {
workflow: DiscoverWorkflowOutput
): Promise<Record<string, unknown>> {
let payload = event.payload || event.data;
if (event.action === 'preview') {
if (event.action === PostActionEnum.PREVIEW) {
const mockResult = this.mock(workflow.payload.schema);

payload = Object.assign(mockResult, payload);
Expand All @@ -493,9 +494,11 @@ export class Client {

private prettyPrintExecute(event: Event, duration: number, error?: Error): void {
const successPrefix = error ? EMOJI.ERROR : EMOJI.SUCCESS;
const actionMessage =
// eslint-disable-next-line no-nested-ternary
event.action === 'execute' ? 'Executed' : event.action === 'preview' ? 'Previewed' : 'Invalid action';
const actionMessages = {
[PostActionEnum.EXECUTE]: 'Executed',
[PostActionEnum.PREVIEW]: 'Previewed',
} as const;
const actionMessage = actionMessages[event.action];
const message = error ? 'Failed to execute' : actionMessage;
const executionLog = error ? log.error : log.success;
const logMessage = `${successPrefix} ${message} workflowId: '${event.workflowId}`;
Expand All @@ -519,7 +522,7 @@ export class Client {
const result = await acc;
const previewProviderHandler = this.previewProvider.bind(this);
const executeProviderHandler = this.executeProvider.bind(this);
const handler = event.action === 'preview' ? previewProviderHandler : executeProviderHandler;
const handler = event.action === PostActionEnum.PREVIEW ? previewProviderHandler : executeProviderHandler;

const providerResult = await handler(event, step, provider, outputs);

Expand Down Expand Up @@ -589,9 +592,7 @@ export class Client {
symbol: EMOJI.ERROR,
text: `Failed to execute provider: \`${provider.type}\``,
});
throw new ProviderExecutionFailedError(
`Failed to execute provider: '${provider.type}'.\n${(error as Error).message}`
);
throw new ProviderExecutionFailedError(provider.type, event.action, error);
}
}

Expand Down Expand Up @@ -628,7 +629,11 @@ export class Client {
symbol: EMOJI.ERROR,
text: `Failed to execute stepId: \`${step.stepId}\``,
});
throw error;
if (isFrameworkError(error)) {
throw error;
} else {
throw new StepExecutionFailedError(step.stepId, event.action, error);
}
}
} else {
const spinner = ora({ indent: 1 }).start(`Hydrating stepId: \`${step.stepId}\``);
Expand Down Expand Up @@ -752,7 +757,12 @@ export class Client {
symbol: EMOJI.ERROR,
text: `Failed to preview stepId: \`${step.stepId}\``,
});
throw error;

if (isFrameworkError(error)) {
throw error;
} else {
throw new StepExecutionFailedError(step.stepId, event.action, error);
}
}
}

Expand Down
37 changes: 18 additions & 19 deletions packages/framework/src/constants/error.constants.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
import { testErrorCodeEnumValidity } from '../types/error.types';

export enum ErrorCodeEnum {
WORKFLOW_NOT_FOUND_ERROR = 'WorkflowNotFoundError',
WORKFLOW_ALREADY_EXISTS_ERROR = 'WorkflowAlreadyExistsError',
WORKFLOW_EXECUTION_FAILED_ERROR = 'WorkflowExecutionFailedError',
EXECUTION_STATE_OUTPUT_INVALID_ERROR = 'ExecutionStateOutputInvalidError',
EXECUTION_STATE_RESULT_INVALID_ERROR = 'ExecutionStateResultInvalidError',
EXECUTION_PROVIDER_OUTPUT_INVALID_ERROR = 'ExecutionProviderOutputInvalidError',
PROVIDER_NOT_FOUND_ERROR = 'ProviderNotFoundError',
PROVIDER_EXECUTION_FAILED_ERROR = 'ProviderExecutionFailedError',
STEP_NOT_FOUND_ERROR = 'StepNotFoundError',
STEP_ALREADY_EXISTS_ERROR = 'StepAlreadyExistsError',
STEP_EXECUTION_FAILED_ERROR = 'StepExecutionFailedError',
EXECUTION_STATE_CORRUPT_ERROR = 'ExecutionStateCorruptError',
EXECUTION_EVENT_PAYLOAD_INVALID_ERROR = 'ExecutionEventPayloadInvalidError',
BRIDGE_ERROR = 'BridgeError',
EXECUTION_EVENT_CONTROL_INVALID_ERROR = 'ExecutionEventControlInvalidError',
EXECUTION_EVENT_PAYLOAD_INVALID_ERROR = 'ExecutionEventPayloadInvalidError',
EXECUTION_PROVIDER_OUTPUT_INVALID_ERROR = 'ExecutionProviderOutputInvalidError',
EXECUTION_STATE_CONTROL_INVALID_ERROR = 'ExecutionStateControlInvalidError',
STEP_CONTROL_COMPILATION_FAILED_ERROR = 'StepControlCompilationFailedError',
METHOD_NOT_ALLOWED_ERROR = 'MethodNotAllowedError',
EXECUTION_STATE_CORRUPT_ERROR = 'ExecutionStateCorruptError',
EXECUTION_STATE_OUTPUT_INVALID_ERROR = 'ExecutionStateOutputInvalidError',
EXECUTION_STATE_RESULT_INVALID_ERROR = 'ExecutionStateResultInvalidError',
INVALID_ACTION_ERROR = 'InvalidActionError',
METHOD_NOT_ALLOWED_ERROR = 'MethodNotAllowedError',
MISSING_SECRET_KEY_ERROR = 'MissingSecretKeyError',
PROVIDER_EXECUTION_FAILED_ERROR = 'ProviderExecutionFailedError',
PROVIDER_NOT_FOUND_ERROR = 'ProviderNotFoundError',
SIGNATURE_EXPIRED_ERROR = 'SignatureExpiredError',
SIGNATURE_INVALID_ERROR = 'SignatureInvalidError',
SIGNATURE_MISMATCH_ERROR = 'SignatureMismatchError',
SIGNATURE_NOT_FOUND_ERROR = 'SignatureNotFoundError',
SIGNATURE_INVALID_ERROR = 'SignatureInvalidError',
SIGNATURE_EXPIRED_ERROR = 'SignatureExpiredError',
SIGNING_KEY_NOT_FOUND_ERROR = 'SigningKeyNotFoundError',
BRIDGE_ERROR = 'BridgeError',
SIGNATURE_VERSION_INVALID_ERROR = 'SignatureVersionInvalidError',
SIGNING_KEY_NOT_FOUND_ERROR = 'SigningKeyNotFoundError',
STEP_ALREADY_EXISTS_ERROR = 'StepAlreadyExistsError',
STEP_CONTROL_COMPILATION_FAILED_ERROR = 'StepControlCompilationFailedError',
STEP_EXECUTION_FAILED_ERROR = 'StepExecutionFailedError',
STEP_NOT_FOUND_ERROR = 'StepNotFoundError',
WORKFLOW_ALREADY_EXISTS_ERROR = 'WorkflowAlreadyExistsError',
WORKFLOW_NOT_FOUND_ERROR = 'WorkflowNotFoundError',
WORKFLOW_PAYLOAD_INVALID_ERROR = 'WorkflowPayloadInvalidError',
}

Expand Down
4 changes: 1 addition & 3 deletions packages/framework/src/constants/version.constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
import { version } from '../../package.json';

export const SDK_VERSION = version;
export { version as SDK_VERSION } from '../../package.json';
export const FRAMEWORK_VERSION = '2024-06-26';
26 changes: 24 additions & 2 deletions packages/framework/src/errors/base.errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { isNativeError } from 'node:util/types';

import { DeepRequired } from '../types';
import { HttpStatusEnum } from '../constants';
import { ErrorCodeEnum } from '../constants/error.constants';

Expand Down Expand Up @@ -33,8 +36,27 @@ export abstract class UnauthorizedError extends FrameworkError {
statusCode = HttpStatusEnum.UNAUTHORIZED;
}

export abstract class InternalServerError extends FrameworkError {
statusCode = HttpStatusEnum.INTERNAL_SERVER_ERROR;
export abstract class ServerError extends FrameworkError {
data: {
/**
* The stack trace of the error.
*/
stack: string;
};

constructor(...[message, { cause }]: DeepRequired<ConstructorParameters<typeof Error>>) {
if (isNativeError(cause)) {
super(`${message}: ${cause.message}`);
this.data = {
stack: cause.stack ?? message,
};
} else {
super(`${message}: ${JSON.stringify(cause, null, 2)}`);
this.data = {
stack: message,
};
}
}
}

export abstract class ConflictError extends FrameworkError {
Expand Down
Loading

0 comments on commit fc5b3cb

Please sign in to comment.