Skip to content

Commit

Permalink
feat(core): Introduce ErrorHandlerStrategy
Browse files Browse the repository at this point in the history
This new strategy enables a unified, global way of responding to errors across the application,
including server and worker.
  • Loading branch information
michaelbromley committed Jan 18, 2024
1 parent aeebbdd commit 066e524
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 4 deletions.
5 changes: 5 additions & 0 deletions docs/docs/guides/developer-guide/the-api-layer/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ import { MyCustomGuard, MyCustomInterceptor, MyCustomExceptionFilter } from './m
useClass: MyCustomInterceptor,
},
{
// Note: registering a global "catch all" exception filter
// must be used with caution as it will override the built-in
// Vendure exception filter. See https://github.com/nestjs/nest/issues/3252
// To implement custom error handling, it is recommended to use
// a custom ErrorHandlerStrategy instead.
provide: APP_FILTER,
useClass: MyCustomExceptionFilter,
},
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/api/middleware/exception-logger.filter.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { ArgumentsHost, ExceptionFilter, HttpException } from '@nestjs/common';
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';

import { Logger, LogLevel } from '../../config';
import { ConfigService, Logger, LogLevel } from '../../config';
import { HEALTH_CHECK_ROUTE } from '../../health-check/constants';
import { I18nError } from '../../i18n/i18n-error';
import { parseContext } from '../common/parse-context';

/**
* Logs thrown I18nErrors via the configured VendureLogger.
*/
@Catch()
export class ExceptionLoggerFilter implements ExceptionFilter {
catch(exception: Error | HttpException | I18nError, host: ArgumentsHost) {
constructor(private configService: ConfigService) {}

catch(exception: Error, host: ArgumentsHost) {
for (const handler of this.configService.systemOptions.errorHandlers) {
void handler.handleServerError(exception, { host });
}
const { req, res, info, isGraphQL } = parseContext(host);
let message = '';
let statusCode = 500;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/config/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
const { customPaymentProcess, process: paymentProcess } = this.configService.paymentOptions;
const { entityIdStrategy: entityIdStrategyDeprecated } = this.configService;
const { entityIdStrategy } = this.configService.entityOptions;
const { healthChecks } = this.configService.systemOptions;
const { healthChecks, errorHandlers } = this.configService.systemOptions;
const { assetImportStrategy } = this.configService.importExportOptions;
return [
...adminAuthenticationStrategy,
Expand Down Expand Up @@ -134,6 +134,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
stockAllocationStrategy,
stockDisplayStrategy,
...healthChecks,
...errorHandlers,
assetImportStrategy,
changedPriceHandlingStrategy,
...(Array.isArray(activeOrderStrategy) ? activeOrderStrategy : [activeOrderStrategy]),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config/default-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,5 +211,6 @@ export const defaultConfig: RuntimeVendureConfig = {
plugins: [],
systemOptions: {
healthChecks: [new TypeORMHealthCheckStrategy()],
errorHandlers: [],
},
};
1 change: 1 addition & 0 deletions packages/core/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export * from './shipping-method/shipping-calculator';
export * from './shipping-method/shipping-eligibility-checker';
export * from './shipping-method/shipping-line-assignment-strategy';
export * from './system/health-check-strategy';
export * from './system/error-handler-strategy';
export * from './tax/default-tax-line-calculation-strategy';
export * from './tax/default-tax-zone-strategy';
export * from './tax/tax-line-calculation-strategy';
Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/config/system/error-handler-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { ArgumentsHost } from '@nestjs/common';

import { InjectableStrategy } from '../../common/index';
import { Job } from '../../job-queue/index';

/**
* @description
* This strategy defines logic for handling errors thrown during on both the server
* and the worker. It can be used for additional logging & monitoring, or for sending error
* reports to external services.
*
* :::info
*
* This is configured via the `systemOptions.errorHandlers` property of
* your VendureConfig.
*
* :::
*
* @example
* ```ts
* import { ArgumentsHost, ExecutionContext } from '\@nestjs/common';
* import { GqlContextType, GqlExecutionContext } from '\@nestjs/graphql';
* import { ErrorHandlerStrategy, I18nError, Injector, Job, LogLevel } from '\@vendure/core';
*
* import { MonitoringService } from './monitoring.service';
*
* export class CustomErrorHandlerStrategy implements ErrorHandlerStrategy {
* private monitoringService: MonitoringService;
*
* init(injector: Injector) {
* this.monitoringService = injector.get(MonitoringService);
* }
*
* handleServerError(error: Error, { host }: { host: ArgumentsHost }) {
* const errorContext: any = {};
* if (host?.getType<GqlContextType>() === 'graphql') {
* const gqlContext = GqlExecutionContext.create(host as ExecutionContext);
* const info = gqlContext.getInfo();
* errorContext.graphQlInfo = {
* fieldName: info.fieldName,
* path: info.path,
* };
* }
* this.monitoringService.captureException(error, errorContext);
* }
*
* handleWorkerError(error: Error, { job }: { job: Job }) {
* const errorContext = {
* queueName: job.queueName,
* jobId: job.id,
* };
* this.monitoringService.captureException(error, errorContext);
* }
* }
* ```
*
* @since 2.2.0
* @docsCategory Errors
*/
export interface ErrorHandlerStrategy extends InjectableStrategy {
/**
* @description
* This method will be invoked for any error thrown during the execution of the
* server.
*/
handleServerError(exception: Error, context: { host: ArgumentsHost }): void | Promise<void>;

/**
* @description
* This method will be invoked for any error thrown during the execution of a
* job on the worker.
*/
handleWorkerError(exception: Error, context: { job: Job }): void | Promise<void>;
}
10 changes: 10 additions & 0 deletions packages/core/src/config/vendure-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { SessionCacheStrategy } from './session-cache/session-cache-strategy';
import { ShippingCalculator } from './shipping-method/shipping-calculator';
import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
import { ShippingLineAssignmentStrategy } from './shipping-method/shipping-line-assignment-strategy';
import { ErrorHandlerStrategy } from './system/error-handler-strategy';
import { HealthCheckStrategy } from './system/health-check-strategy';
import { TaxLineCalculationStrategy } from './tax/tax-line-calculation-strategy';
import { TaxZoneStrategy } from './tax/tax-zone-strategy';
Expand Down Expand Up @@ -1019,6 +1020,15 @@ export interface SystemOptions {
* @since 1.6.0
*/
healthChecks?: HealthCheckStrategy[];
/**
* @description
* Defines an array of {@link ErrorHandlerStrategy} instances which are used to define logic to be executed
* when an error occurs, either on the server or the worker.
*
* @default []
* @since 2.2.0
*/
errorHandlers?: ErrorHandlerStrategy[];
}

/**
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/job-queue/job-queue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export class JobQueueService implements OnModuleDestroy {
if (this.configService.jobQueueOptions.prefix) {
options = { ...options, name: `${this.configService.jobQueueOptions.prefix}${options.name}` };
}
const wrappedProcessFn = this.createWrappedProcessFn(options.process);
options = { ...options, process: wrappedProcessFn };
const queue = new JobQueue(options, this.jobQueueStrategy, this.jobBufferService);
if (this.hasStarted && this.shouldStartQueue(queue.name)) {
await queue.start();
Expand Down Expand Up @@ -164,6 +166,28 @@ export class JobQueueService implements OnModuleDestroy {
}));
}

/**
* We wrap the process function in order to catch any errors thrown and pass them to
* any configured ErrorHandlerStrategies.
*/
private createWrappedProcessFn<Data extends JobData<Data>>(
processFn: (job: Job<Data>) => Promise<any>,
): (job: Job<Data>) => Promise<any> {
const { errorHandlers } = this.configService.systemOptions;
return async (job: Job<Data>) => {
try {
return await processFn(job);
} catch (e) {
for (const handler of errorHandlers) {
if (e instanceof Error) {
void handler.handleWorkerError(e, { job });
}
}
throw e;
}
};
}

private shouldStartQueue(queueName: string): boolean {
if (this.configService.jobQueueOptions.activeQueues.length > 0) {
if (!this.configService.jobQueueOptions.activeQueues.includes(queueName)) {
Expand Down

0 comments on commit 066e524

Please sign in to comment.