Skip to content

Commit

Permalink
feat(cactus-common): add createRuntimeErrorWithCause() & newRex()
Browse files Browse the repository at this point in the history
Utility functions to conveniently re-throw excpetions typed as unknown
by their catch block (which is the default since Typescript v4.4).

Example usage can and much more documentation can be seen here:

`packages/cactus-common/src/main/typescript/exception/create-runtime-error-with-cause.ts`
and here
`packages/cactus-common/src/test/typescript/unit/exception/create-runtime-error-with-cause.test.ts`

Co-authored-by: Peter Somogyvari <peter.somogyvari@accenture.com>

Closes: #1702

[skip ci]

Signed-off-by: Michael Courtin <michael.courtin@accenture.com>
Signed-off-by: Peter Somogyvari <peter.somogyvari@accenture.com>
  • Loading branch information
m-courtin authored and petermetz committed Sep 19, 2023
1 parent 8255134 commit b3a508c
Show file tree
Hide file tree
Showing 9 changed files with 600 additions and 38 deletions.
69 changes: 31 additions & 38 deletions packages/cactus-cmd-api-server/src/main/typescript/api-server.ts
Expand Up @@ -48,6 +48,7 @@ import {
Bools,
Logger,
LoggerProvider,
newRex,
Servers,
} from "@hyperledger/cactus-common";

Expand Down Expand Up @@ -248,17 +249,17 @@ export class ApiServer {
}

return { addressInfoCockpit, addressInfoApi, addressInfoGrpc };
} catch (ex) {
const errorMessage = `Failed to start ApiServer: ${ex.stack}`;
this.log.error(errorMessage);
} catch (ex1: unknown) {
const context = "Failed to start ApiServer";
this.log.error(context, ex1);
this.log.error(`Attempting shutdown...`);
try {
await this.shutdown();
this.log.info(`Server shut down after crash OK`);
} catch (ex) {
this.log.error(ApiServer.E_POST_CRASH_SHUTDOWN, ex);
} catch (ex2: unknown) {
this.log.error(ApiServer.E_POST_CRASH_SHUTDOWN, ex2);
}
throw new Error(errorMessage);
throw newRex(context, ex1);
}
}

Expand Down Expand Up @@ -304,11 +305,11 @@ export class ApiServer {
await this.getPluginImportsCount(),
);
return this.pluginRegistry;
} catch (e) {
} catch (ex: unknown) {
this.pluginRegistry = new PluginRegistry({ plugins: [] });
const errorMessage = `Failed init PluginRegistry: ${e.stack}`;
this.log.error(errorMessage);
throw new Error(errorMessage);
const context = "Failed to init PluginRegistry";
this.log.debug(context, ex);
throw newRex(context, ex);
}
}

Expand Down Expand Up @@ -368,15 +369,10 @@ export class ApiServer {
await plugin.onPluginInit();

return plugin;
} catch (error) {
const errorMessage = `${fnTag} failed instantiating plugin '${packageName}' with the instanceId '${options.instanceId}'`;
this.log.error(errorMessage, error);

if (error instanceof Error) {
throw new RuntimeError(errorMessage, error);
} else {
throw new RuntimeError(errorMessage, JSON.stringify(error));
}
} catch (ex: unknown) {
const context = `${fnTag} failed instantiating plugin '${packageName}' with the instanceId '${options.instanceId}'`;
this.log.debug(context, ex);
throw newRex(context, ex);
}
}

Expand All @@ -397,10 +393,10 @@ export class ApiServer {
try {
await fs.mkdirp(pluginPackageDir);
this.log.debug(`${pkgName} plugin package dir: %o`, pluginPackageDir);
} catch (ex) {
const errorMessage =
} catch (ex: unknown) {
const context =
"Could not create plugin installation directory, check the file-system permissions.";
throw new RuntimeError(errorMessage, ex);
throw newRex(context, ex);
}
try {
lmify.setPackageManager("npm");
Expand All @@ -418,19 +414,15 @@ export class ApiServer {
// "--ignore-workspace-root-check",
]);
this.log.debug("%o install result: %o", pkgName, out);
if (out.exitCode !== 0) {
throw new RuntimeError("Non-zero exit code: ", JSON.stringify(out));
if (out?.exitCode && out.exitCode !== 0) {
const eMsg = "Non-zero exit code returned by lmify.install() indicating that the underlying npm install OS process had encountered a problem:";
throw newRex(eMsg, out);
}
this.log.info(`Installed ${pkgName} OK`);
} catch (ex) {
const errorMessage = `${fnTag} failed installing plugin '${pkgName}`;
this.log.error(errorMessage, ex);

if (ex instanceof Error) {
throw new RuntimeError(errorMessage, ex);
} else {
throw new RuntimeError(errorMessage, JSON.stringify(ex));
}
} catch (ex: unknown) {
const context = `${fnTag} failed installing plugin '${pkgName}`;
this.log.debug(ex, context);
throw newRex(context, ex);
}
}

Expand All @@ -451,24 +443,25 @@ export class ApiServer {
this.log.info(`Stopped ${webServicesShutdown.length} WS plugin(s) OK`);

if (this.httpServerApi?.listening) {
this.log.info(`Closing HTTP server of the API...`);
this.log.info(`Closing Cacti HTTP server of the API...`);
await Servers.shutdown(this.httpServerApi);
this.log.info(`Close HTTP server of the API OK`);
}

if (this.httpServerCockpit?.listening) {
this.log.info(`Closing HTTP server of the cockpit ...`);
this.log.info(`Closing Cacti HTTP server of the cockpit ...`);
await Servers.shutdown(this.httpServerCockpit);
this.log.info(`Close HTTP server of the cockpit OK`);
}

if (this.grpcServer) {
this.log.info(`Closing gRPC server ...`);
this.log.info(`Closing Cacti gRPC server ...`);
await new Promise<void>((resolve, reject) => {
this.grpcServer.tryShutdown((ex?: Error) => {
if (ex) {
this.log.error("Failed to shut down gRPC server: ", ex);
reject(ex);
const eMsg = "Failed to shut down gRPC server of the Cacti API server.";
this.log.debug(eMsg, ex);
reject(newRex(eMsg, ex));
} else {
resolve();
}
Expand Down
5 changes: 5 additions & 0 deletions packages/cactus-common/package.json
Expand Up @@ -33,6 +33,11 @@
"name": "Peter Somogyvari",
"email": "peter.somogyvari@accenture.com",
"url": "https://accenture.com"
},
{
"name": "Michael Courtin",
"email": "michael.courtin@accenture.com",
"url": "https://accenture.com"
}
],
"main": "dist/lib/main/typescript/index.js",
Expand Down
@@ -0,0 +1,46 @@
import stringify from "fast-safe-stringify";
import { ErrorFromUnknownThrowable } from "./error-from-unknown-throwable";
import { ErrorFromSymbol } from "./error-from-symbol";

/**
* Safely converts `unknown` to an `Error` with doing a best effort to ensure
* that root cause analysis information is not lost. The idea here is to help
* people who are reading logs of errors while trying to figure out what went
* wrong after a crash.
*
* Often in Javascript this is much harder than it could be due to lack of
* runtime checks by the JSVM (Javascript Virtual Machine) on the values/objects
* that are being thrown.
*
* @param x The value/object whose type information is completely unknown at
* compile time, such as the input parameter of a catch block (which could
* be anything because the JS runtime has no enforcement on it at all, e.g.
* you can throw null, undefined, empty strings of whatever else you'd like.)
* @returns An `Error` object that is the original `x` if it was an `Error`
* instance to begin with or a stringified JSON representation of `x` otherwise.
*/
export function coerceUnknownToError(x: unknown): Error {
if (typeof x === "symbol") {
const symbolAsStr = x.toString();
return new ErrorFromSymbol(symbolAsStr);
} else if (x instanceof Error) {
return x;
} else {
const xAsJson = stringify(x, (_, value) =>
typeof value === "bigint" ? value.toString() + "n" : value,
);
return new ErrorFromUnknownThrowable(xAsJson);
}
}

/**
* This is an alias to `coerceUnknownToError(x: unknown)`.
*
* The shorter name allows for different style choices to be made by the person
* writing the error handling code.
*
* @see #coerceUnknownToError
*/
export function asError(x: unknown): Error {
return coerceUnknownToError(x);
}
@@ -0,0 +1,104 @@
import { RuntimeError } from "run-time-error";
import { coerceUnknownToError } from "./coerce-unknown-to-error";

/**
* ### STANDARD EXCEPTION HANDLING - EXAMPLE WITH RE-THROW:
*
* Use the this utility function and pass in any throwable of whatever type and format
* The underlying implementation will take care of determining if it's a valid
* `Error` instance or not and act accordingly with avoding information loss
* being the number one priority.
*
* You can perform a fast-fail re-throw with additional context like the snippet
* below.
* Notice that we log on the debug level inside the catch block to make sure that
* if somebody higher up in the callstack ends up handling this exception then
* it will never get logged on the error level which is good because if it did
* that would be a false-positive, annoying system administrators who have to
* figure out which errors in their production logs need to be ignored and which
* ones are legitimate.
* The trade-off with the above is trust: Specifically, we are trusting the
* person above us in the callstack to either correctly handle the exception
* or make sure that it does get logged on the error level. If they fail to do
* either one of those, then we'll have silent failures on our hand that will
* be hard to debug.
* Lack of the above kind of trust is usually what pushes people to just go for
* it and log their caught exceptions on the error level but this most likely
* a mistake in library code where there just isn't enough context to know if
* an error is legitimate or not most of the time. If you are writing application
* logic then it's usually a simpler decision with more information at your
* disposal.
*
* The underlying concept is that if you log something on an error level, you
* indicate that another human should fix a bug that is in the code. E.g.,
* when they see the error logs, they should go and fix something.
*
* ```typescript
* public doSomething(): void {
* try {
* someSubTaskToExecute();
* } catch (ex) {
* const eMsg = "Failed to run **someSubTask** while doing **something**:"
* this.log.debug(eMsg, ex);
* throw createRuntimeErrorWithCause(eMsg, ex);
* }
* ```
*
* ### EXCEPTION HANDLING WITH CONDITIONAL HANDLING AND RE-THROW - EXAMPLE:
*
* In case you need to do a conditional exception-handling:
* - Use the RuntimeError to re-throw and
* provide the previous exception as cause in the new RuntimeError to retain
* the information and distinguish between an exception you can handle and
* recover from and one you can't
*
* ```typescript
* public async doSomething(): Promise<number> {
* try {
* await doSubTaskThatsAPartOfDoingSomething();
* } catch (ex) {
* if (ex instanceof MyErrorThatICanHandleAndRecoverFrom) {
* // An exception with a fixable scenario we can recover from thru an additional handling
* // do something here to handle and fix the issue
* // where "fixing" means that the we end up recovering
* // OK instead of having to crash. Recovery means that
* // we are confident that the second sub-task is safe to proceed with
* // despite of the error that was caught here
* this.log.debug("We've got an failure in 'doSubTaskThatsAPartOfDoingSomething()' but we could fix it and recover to continue".);
* } else {
* // An "unexpected exception" where we want to fail immediately
* // to avoid follow-up problems
* const context = "We got an severe failure in 'doSubTaskThatsAPartOfDoingSomething()' and need to stop directly here to avoid follow-up problems";
* this.log.erorr(context, ex);
* throw newRex(context, ex);
* }
* }
* const result = await doSecondAndFinalSubTask();
* return result; // 42
* }
* ```
*
* @param message The contextual information that will be passed into the
* constructor of the returned {@link RuntimeError} instance.
* @param cause The caught throwable which we do not know the exact type of but
* need to make sure that whatever information is in t here is not lost.
* @returns The instance that has the combined information of the input parameters.
*/
export function createRuntimeErrorWithCause(
message: string,
cause: unknown,
): RuntimeError {
const innerEx = coerceUnknownToError(cause);
return new RuntimeError(message, innerEx);
}

/**
* An alias to the `createRuntimeErrorWithCause` function for those prefering
* a shorter utility for their personal style.
*
* @see {@link createRuntimeErrorWithCause}
* @returns `RuntimeError`
*/
export function newRex(message: string, cause: unknown): RuntimeError {
return createRuntimeErrorWithCause(message, cause);
}
@@ -0,0 +1 @@
export class ErrorFromSymbol extends Error {}
@@ -0,0 +1,18 @@
/**
* A custom `Error` class designed to encode information about the origin of
* the information contained inside.
*
* Specifically this class is to be used when a catch block has encountered a
* throwable [1] that was not an instance of `Error`.
*
* This should help people understand the contents a little more while searching
* for the root cause of a crash (by letting them know that we had encoutnered
* a non-Error catch block parameter and we wrapped it in this `Error` sub-class
* purposefully to make it easier to deal with it)
*
* [1]: A throwable is a value or object that is possible to be thrown in the
* place of an `Error` object. This - as per the rules of Javascript - can be
* literally anything, NaN, undefined, null, etc.
*/
export class ErrorFromUnknownThrowable extends Error {
}
6 changes: 6 additions & 0 deletions packages/cactus-common/src/main/typescript/public-api.ts
Expand Up @@ -27,3 +27,9 @@ export {
} from "./authzn/i-jose-fitting-jwt-params";

export { isRecord } from "./types/is-record";
export { hasKey } from "./types/has-key";

export { asError, coerceUnknownToError } from "./exception/coerce-unknown-to-error";
export { createRuntimeErrorWithCause, newRex } from "./exception/create-runtime-error-with-cause";
export { ErrorFromUnknownThrowable } from "./exception/error-from-unknown-throwable";
export { ErrorFromSymbol } from "./exception/error-from-symbol";
6 changes: 6 additions & 0 deletions packages/cactus-common/src/main/typescript/types/has-key.ts
@@ -0,0 +1,6 @@
export function hasKey<T extends string>(
x: unknown,
key: T,
): x is { [key in T]: unknown } {
return Boolean(typeof x === "object" && x && key in x);
}

0 comments on commit b3a508c

Please sign in to comment.