Error Handling in Practice - Centralizing Operational Errors in Node.js

Introduction

In the ever-evolving realm of software development, errors and exceptions are constant companions. These errors can be broadly categorized into two types: programming errors and operational errors. While programming errors are typically caught during development and testing phases, operational errors are the real-world hurdles that applications must gracefully navigate in production environments. In this blog, we will shine a spotlight on the art of handling operational errors and delve into the pivotal role played by a central error class in Node.js.

Operational Errors: Navigating the Real-World Challenges

Operational errors are a natural part of any software application’s lifecycle. They can arise from various sources, including network issues, database failures, external service unavailability, and even user input. In a typical Node.js application, these errors can occur at different layers, such as the controller, service, and database access (Dao) layers. Handling these errors effectively is crucial for maintaining application reliability and providing a smooth user experience.

The Central Error Class: One for All

In the world of Node.js, it’s a common practice to employ a central error class to manage operational errors efficiently. This central error class serves as a hub for creating, formatting, and handling errors in a structured and consistent manner. By adhering to a single error class throughout your project, you can simplify error management and ensure uniformity in error responses.

For instance, we have MyProjectError as an central error class.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class MyProjectError extends Error {
public static UNKNOWN_ERROR_CODE = "99999";

/** The module in which the error occurred. */
private _module: string;
/** A code to uniquely identify the error in a module. */
private _code: number | string;
/** A unique id that identifies the error uniquely in the logs. */
private _guid: string;
/** A http status code that is assigned to the error. */
private _statusCode: number;
/** An array of error objects that lead to this error. Errors in causes do not have any causes themselves. */
protected _causes: MyProjectError[];
/** An optional message field for additional information. */
private _details?: string;
/** The host on which the error occurred. */
private _host: string;
/* Optional array of strings that are used to build the localization string */
private _i18nParameters: Array<string>;
/** Key to localization string that will be looked up along with a language key */
private _i18nKey: string;

/**
* Create a new MyProjectError.
* @param module - The module in which the error is thrown, e.g. x-glossary
* @param code - The error code number, e.g. 22500. It's also possible to use a string, but this is discouraged!
* @param message - The message that should be displayed with the error
* @param i18nKey - internationalization key
* @param i18nParameters - parameters for i18n translation
* @param statusCode - A status code that can be used for the http response
* @param causes - Additional errors that lead to this error
* @param details - An optional message field for additional information
* @param host - An optional indicator of the pod in which the error occured
*/
constructor(
module: string,
code: number | string,
message: string,
i18nParameters: Array<string> = [],
i18nKey: string = "",
statusCode: number = 500,
causes: Array<MyProjectError | Error> = [],
details?: string,
host = hostname()
) {
super(`${message}: ${details}`);

this.message = message;
this.name = "MyProjectError";
this._module = module;
this._i18nParameters = i18nParameters;
this._i18nKey = i18nKey;
this._code = code || MyProjectError.UNKNOWN_ERROR_CODE;
this._guid = uuidv4();
this._statusCode = statusCode;
this._causes = MyProjectError.flatten(
causes.map((_) => MyProjectError.wrap(_))
);
this._details = details;
this._host = host;
}

/** Returns the error as a JSON object. */
public toJSON(): JSON {
return {
message: this.message,
code: this._code,
module: this._module,
guid: this._guid,
statusCode: this._statusCode,
causes: this._causes.map((_) => _.toJSON()),
details: this._details,
hostname: this._host,
i18nParameters: this._i18nParameters,
i18nKey: this._i18nKey,
};
}

/** Add a cause to the error. */
public addCause(err: MyProjectError | Error) {
this._causes.push(MyProjectError.wrap(err));
}
}

The MyProjectError class is a custom error class that extends the built-in Error class. It includes additional properties and methods to handle and format errors in a structured way. By using this error class, we are able to create and handle specific types of errors based on their characteristics.
The MyProjectError class includes properties such as the module in which the error occurred, an error code, a unique identifier for the error, an HTTP status code, causes of the error, and additional details. It also provides methods to add causes to the error and convert the error to a JSON object.
By using this error class, we can create consistent and standardized error objects, making error handling more structured and maintainable.

Utilize Central Error Class

Around the central error class, we are able to utilize the error handling.

1. Unified Error Creation

The central error class allows developers to create and throw specific types of errors with ease. This consistency in error creation facilitates better communication between different layers of your application, ensuring that everyone speaks the same error language.

2. Send Error Responses

When an operational error occurs, it’s essential to communicate the issue to the client in a clear and standardized way. The central error class enables you to generate error responses that adhere to a predefined structure, making it easier for clients to understand and handle errors gracefully.

3. Convert Unknown Errors

Not all errors are predictable, and some may be unexpected or unfamiliar. In such cases, the central error class can help by converting these unknown errors into a generic format that can be handled more effectively. This approach ensures that your application can gracefully recover from unforeseen issues.

4. Localize/Translate Error Messages

In a globalized world, catering to users with diverse language preferences is crucial. The central error class can be extended to include localization and translation features. This allows error messages to be presented to users in their preferred language, enhancing the overall user experience.

For example, we throw the an error when ‘two tenant mode feature is not enabled’.

1
throw ERRORS.twoTenantModeFeatureNotEnabled(targetTenantUuid, systemAction);

The error is generated by function twoTenantModeFeatureNotEnabled

1
2
twoTenantModeFeatureNotEnabled: (tenantUuid: string, systemAction: SystemAction): MyProjectError
=> create('10045', 'errOdc04045', [systemAction, tenantUuid], 400)

The function create is the single entry point to create an error that instances of MyProjectError. It receives an error code, an error message localization key, a set of parameters for translation, and an HTTP response code.

In the localization properties, we have the definition of errOdc04045 for building an error message.

1
2
3
#YMSE: {0} is the action name of the system
#YMSE: {1} is the target system to create/restore.
errOdc04045=Cannot {0} the system {1} because two tenant mode is not enabled

The error message is built during the serialization phase before sending it to clients.

Error handling in Three Layers of backend service

In a typical backend architecture, your application may consist of three primary layers: the controller, service, and database access (Dao) layers. Each of these layers plays a role in processing requests and handling operational errors.

Controller Layer

The controller layer serves as the entry point for incoming requests and plays a crucial role in error handling and response generation. e.g. we have the entry createSystems for POST /v1/systems to create a set of systems in batch as below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Create a set of systems. Only BTP systems have been implemented so far.
* @param req
* @param res
*/
export async function createSystems(req: Request, res: Response) {
try {
const serviceContext = await getServiceContext(req);
const systemService = new SystemService(serviceContext);

const systemsToCreate = getRequestBody < ISystemsToCreate > req.systems;
await systemService.createSystems(systemsToCreate);
res.status(201).end();
} catch (error) {
await sendErrorResponse(req, res, error);
}
}

Error handling in controller layer

In the provided code snippet, we see a global try-catch block wrapping the entry function of the controller. This approach ensures that any exceptions thrown during request processing are caught and handled gracefully. The controller is responsible for crafting and sending responses back to clients. In the provided code, HTTP status codes and appropriate responses are sent, ensuring a consistent and user-friendly experience.

Handling errors isn’t just about catching exceptions; it’s also about providing meaningful error messages to clients. In the provided example, the need for localization of error messages is evident. The error messages need to be translated according to the client’s language preference. To achieve this, a translation search key, such as errOdc04044, is used to retrieve the correct localized text. This localization information is crucial for delivering a user-centric experience, especially in applications with a global user base.

Service Layer

The service layer is where complex business logic resides. It involves tasks such as data validation, interaction with the database, and building output data. In this layer, error handling is equally critical.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public async createSystems(systemsToCreate: SystemToCreate[]) {
try {
const systemsAndConnectionsToCreateDb = await ServiceHelper.buildSystemsAndConnectionsToCreateDb(
this.context.requestContext,
systemsToCreate
);
const affectedSystems = await this.saveSystemsAndConnections(systemsAndConnectionsToCreateDb);
await this.updateSearchView();

const btpSystemsToCreate: IBtpSystemToCreate[] = systemsToCreate.filter(ServiceHelper.isBtpSystem);
if (btpSystemsToCreate.length > 0) {
await this.syncUpTenantRelationshipsForCreation(btpSystemsToCreate);
}
await this.context.client.commit();
trace.exiting();
return affectedSystems;
} catch (e) {
await this.context.client.rollback();
const error = tryToConvertToGenericError(e);
throw error;
}
}
  1. Data Validation: Input data received from the controller must be validated and converted to a suitable format for processing. This step helps prevent invalid or malicious data from causing issues further down the line.
  2. Database Interaction: When interacting with the database, error handling is paramount. Database errors, network issues, or unexpected data inconsistencies can all lead to exceptions. Handling these exceptions gracefully ensures the integrity of the application.
  3. Output Data: After processing, the service layer is responsible for building and formatting the output data that will be sent back to the controller for response generation.

Error handling in the service layer

The function in the service layer is wrapped in a global try-catch block. In this layer, errors can originate from utilities, helpers, or the database access layer. If an error is not recognized, it should be converted to a generic error.

Database Access Layer (Dao)

The database access layer is responsible for interacting with the database and handling errors that may arise during data retrieval or modification.

1
2
3
4
5
6
7
8
9
10
11
private async saveSystemsAndConnections(
systemsAndConnectionsToCreateDb: ISystemAndConnectionToCreateDb[]
): Promise<ISystemDb[]> {
const affectedSystems = await this.sharedDBAccess.createSystemsAndConnections(systemsAndConnectionsToCreateDb);

if (affectedSystems.length > 1) {
throw ERRORS.duplicatedSystem()
}

return _.merge(affectedSystems.createdSystems, affectedSystems.updatedSystems);
}

The errors could come from this layer directly. We generate error by a unified entry point and throw it to the caller. We don’t need to cope with the error too much in this layer. However, we need a set of error utilities to generate the error easily.

Conclusion: Streamlining Error Handling for a Reliable Application

In software development, mistakes are not only hurdles but helpful tools for enhancing dependability and user experience. By adhering to recommended methods in managing mistakes, such as sending error responses, transforming unknown errors into a standard format, adopting a unified approach to creating errors, offering error categories, and localizing error messages, developers can build software systems that are more resilient and user-friendly. Embracing mistakes as a part of the development process can result in more robust and reliable applications that serve users better.

本文标题:Error Handling in Practice - Centralizing Operational Errors in Node.js

文章作者:Pylon, Syncher

发布时间:2023年10月10日 - 22:10

最后更新:2023年10月11日 - 14:10

原始链接:https://0x400.com/experience/practice/error-handling-in-practice/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。