Repository: kelektiv/node-cron (ts)

Issue:
error handling
Hello.

Is there a way to catch an error during the cronjob execution? As I see cron.start() doesn't return promise, so we can't just hang a .catch() there.

I guess it's common need so some people found a good approach to solve it.

Thank you a lot!

**Feature specification: https://github.com/kelektiv/node-cron/issues/426#issuecomment-1741837777**

Pull request:
feat: error handling on ticks
<!--- Provide a general summary of your changes in the Title above (following the Conventional Commits standard) -->
<!-- More infos: https://www.conventionalcommits.org -->
<!-- Commit types: https://github.com/insurgent-lab/conventional-changelog-preset#commit-types-->

## Description

<!--- Describe your changes in detail -->
I've added an error handling method in the `CronJob` class and added it to the constructors parameters. This error handling method will be called if any exception is thrown from the `onTick` method.  Also added two new tests to confirm that it works.
## Related Issue
Closes #426. 
<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps to reproduce -->
<!--- Please link to the issue here: -->

## Motivation and Context

<!--- Why is this change required? What problem does it solve? -->
If any kind of exception occurs from the `onTick` method the whole process stops currently. To solve this, this feature will allow users to pass their own error handler when exceptions occur.
## How Has This Been Tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
Added two new test cases called `should catch errors if errorhandler is provided` & `should throw errors if errorhandler is NOT provided`
## Screenshots (if appropriate):

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project.
- [x] My change requires a change to the documentation.
- [x] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [ ] If my change introduces a breaking change, I have added a `!` after the type/scope in the title (see the Conventional Commits standard).


Patch:
diff --git a/README.md b/README.md
index 21b395a..30a0b3d 100644
--- a/README.md
+++ b/README.md
@@ -196,6 +196,8 @@ day of week    0-7 (0 or 7 is Sunday, or use names)
 
 - `waitForCompletion`: [OPTIONAL] - If `true`, no additional instances of the `onTick` callback function will run until the current onTick callback has completed. Any new scheduled executions that occur while the current callback is running will be skipped entirely. Default is `false`.
 
+- `errorHandler`: [OPTIONAL] - Function to handle any exceptions that occur in the `onTick` method.
+
 #### Methods
 
 - `from` (static): Create a new CronJob object providing arguments as an object. See argument names and descriptions above.
diff --git a/src/job.ts b/src/job.ts
index f051d1a..730d926 100644
--- a/src/job.ts
+++ b/src/job.ts
@@ -22,6 +22,7 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		? CronOnCompleteCallback
 		: undefined;
 	waitForCompletion = false;
+	errorHandler?: CronJobParams<OC, C>['errorHandler'];
 
 	private _isCallbackRunning = false;
 	private _timeout?: NodeJS.Timeout;
@@ -41,7 +42,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		runOnInit?: CronJobParams<OC, C>['runOnInit'],
 		utcOffset?: null,
 		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
-		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion']
+		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
+		errorHandler?: CronJobParams<OC, C>['errorHandler']
 	);
 	constructor(
 		cronTime: CronJobParams<OC, C>['cronTime'],
@@ -53,7 +55,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		runOnInit?: CronJobParams<OC, C>['runOnInit'],
 		utcOffset?: CronJobParams<OC, C>['utcOffset'],
 		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
-		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion']
+		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
+		errorHandler?: CronJobParams<OC, C>['errorHandler']
 	);
 	constructor(
 		cronTime: CronJobParams<OC, C>['cronTime'],
@@ -65,11 +68,14 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 		runOnInit?: CronJobParams<OC, C>['runOnInit'],
 		utcOffset?: CronJobParams<OC, C>['utcOffset'],
 		unrefTimeout?: CronJobParams<OC, C>['unrefTimeout'],
-		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion']
+		waitForCompletion?: CronJobParams<OC, C>['waitForCompletion'],
+		errorHandler?: CronJobParams<OC, C>['errorHandler']
 	) {
 		this.context = (context ?? this) as CronContext<C>;
 		this.waitForCompletion = Boolean(waitForCompletion);
 
+		this.errorHandler = errorHandler;
+
 		// runtime check for JS users
 		if (timeZone != null && utcOffset != null) {
 			throw new ExclusiveParametersError('timeZone', 'utcOffset');
@@ -128,7 +134,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.runOnInit,
 				params.utcOffset,
 				params.unrefTimeout,
-				params.waitForCompletion
+				params.waitForCompletion,
+				params.errorHandler
 			);
 		} else if (params.utcOffset != null) {
 			return new CronJob<OC, C>(
@@ -141,7 +148,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.runOnInit,
 				params.utcOffset,
 				params.unrefTimeout,
-				params.waitForCompletion
+				params.waitForCompletion,
+				params.errorHandler
 			);
 		} else {
 			return new CronJob<OC, C>(
@@ -154,7 +162,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				params.runOnInit,
 				params.utcOffset,
 				params.unrefTimeout,
-				params.waitForCompletion
+				params.waitForCompletion,
+				params.errorHandler
 			);
 		}
 	}
@@ -224,7 +233,8 @@ export class CronJob<OC extends CronOnCompleteCommand | null = null, C = null> {
 				if (this.waitForCompletion) await result;
 			}
 		} catch (error) {
-			console.error('[Cron] error in callback', error);
+			if (this.errorHandler != null) this.errorHandler(error);
+			else console.error('[Cron] error in callback', error);
 		} finally {
 			this._isCallbackRunning = false;
 		}
diff --git a/src/types/cron.types.ts b/src/types/cron.types.ts
index 4df8f09..dda5b34 100644
--- a/src/types/cron.types.ts
+++ b/src/types/cron.types.ts
@@ -16,6 +16,7 @@ interface BaseCronJobParams<
 	runOnInit?: boolean | null;
 	unrefTimeout?: boolean | null;
 	waitForCompletion?: boolean | null;
+	errorHandler?: ((error: unknown) => void) | null;
 }
 
 export type CronJobParams<


Test patch:
diff --git a/tests/cron.test.ts b/tests/cron.test.ts
index be72224..4572948 100644
--- a/tests/cron.test.ts
+++ b/tests/cron.test.ts
@@ -1151,6 +1151,44 @@ describe('cron', () => {
 		expect(callback).toHaveBeenCalledTimes(1);
 	});
 
+	it('should catch errors every time, if errorHandler is provided', () => {
+		const clock = sinon.useFakeTimers();
+		const errorFunc = jest.fn().mockImplementation(() => {
+			throw Error('Exception');
+		});
+		const handlerFunc = jest.fn();
+		const job = CronJob.from({
+			cronTime: '* * * * * *',
+			onTick: errorFunc,
+			errorHandler: handlerFunc,
+			start: true
+		});
+		clock.tick(1000);
+		expect(errorFunc).toHaveBeenCalledTimes(1);
+		expect(handlerFunc).toHaveBeenCalledTimes(1);
+		expect(handlerFunc).toHaveBeenLastCalledWith(new Error('Exception'));
+		clock.tick(1000);
+		expect(errorFunc).toHaveBeenCalledTimes(2);
+		expect(handlerFunc).toHaveBeenCalledTimes(2);
+		expect(handlerFunc).toHaveBeenLastCalledWith(new Error('Exception'));
+
+		job.stop();
+		clock.restore();
+	});
+
+	it('should log errors if errorHandler is NOT provided', () => {
+		const errorFunc = jest.fn().mockImplementation(() => {
+			throw Error('Exception');
+		});
+		console.error = jest.fn();
+		CronJob.from({
+			cronTime: '* * * * * *',
+			onTick: errorFunc,
+			runOnInit: true
+		});
+		expect(console.error).toHaveBeenCalled();
+	});
+
 	describe('waitForCompletion and job status tracking', () => {
 		it('should wait for async job completion when waitForCompletion is true', async () => {
 			const clock = sinon.useFakeTimers();