Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework implementation with initial .cause support #304

Open
wants to merge 45 commits into
base: next
Choose a base branch
from

Conversation

kanongil
Copy link
Contributor

@kanongil kanongil commented Dec 5, 2023

This PR features an extensive rework of the Boom internals. While it is quite expansive, it is mostly a refactor while trimming some less used features. As it is, no code changes are required to use this in hapi itself.

The main motivation for this, is to utilize the new Error.cause property to boomify() existing errors without modifying or cloning the object. The non-standard modifying and cloning has both been a cause of errors over the years!

new Boom() now creates an actual Boom error object (named "Boom"), setting the cause according to the options. boomify() itself sets a passed error as the cause of a created Boom object. This means that the printed stack will be a composite of the place where the Boom object is created, and the stack of the cause, making debugging more powerful.

Regular boom errors using a string message should largely be unaffected.

Removed features

  1. In-place boomifying (1813bef). This should never be used, and was a side-effect of the old logic.
  2. Preserving boomified error attributes (1813bef). Does not seem to be used. Still available at boom.cause.<attr>.
  3. Special handling for message option with type Error (c346820). Makes the API interface simpler and more consistent.
  4. The "decorate" option (19e5e2a). It can be easily applied by caller using Object.assign().
  5. payload.attributes property from unauthorized() (d277413). Nonsensical and never used.
  6. The err.typeof property (60893c0). Unsafe and mostly unused. Only used in cookie.

Added features

  1. Preserves stack chain using Error.cause property (1813bef).
  2. Does not modify or clone any inputs, except boomify() called on a Boom error (1813bef).
  3. Allow non-Error in calls to boomify() (9600c08). This allows any catched "error" to be safely passed.
  4. A headers option to new Boom() (ded25b4).

I also found and fixed a bug in 2137697.

The new implementation should be cross-compatible with the current version in normal usage, allowing mix-and-match of versions which is a likely scenario in common deployments.

Note that while this PR means that Boom now uses and supports Error.cause, it does not enable it as an explicit option for the helpers, as suggested in #300. It is used implicitly though, in boomify() and in the server error helpers when the data argument is a non-Boom error. Whether it makes sense to update the API, as suggested in #300 is considered future work.

FYI, I have done a review across the hapi repos, and the only conflicting usage I found, was the one in cookie. Besides that I found multiple uses of the decorate option in mozilla/hawk, where they even also use the Object.assign() alternative.

This rework also fixes #291, fixes #300, and fixes #302.

TODO

The functionality is complete, and I would appreciate reviews. The only thing missing is updating the docs.

@kanongil kanongil added feature New functionality or improvement breaking changes Change that can breaking existing code labels Dec 5, 2023
@devinivy
Copy link
Member

devinivy commented Dec 5, 2023

I need to do a little more in-depth review, but based on the description I agree with all of the choices made here. This is great, a much appreciated modernization of boom 👍

@Nargonath
Copy link
Member

I second Devin here, thanks Gil for the thorough description of the work and for the work itself of course. I agree with what's described so far and I'll take some more time later on for a more thorough review.

lib/index.js Outdated Show resolved Hide resolved
lib/index.js Outdated Show resolved Hide resolved
@matthieusieben
Copy link

As a regular user of Boom, I like this very much. This is a much more traditional way of creating objects and makes the code much easier to understand. Finally being able to use cause out of the box is great !

Here are a few questions I have:

  • The ability to decorate is both useful internally and for users of the lib. Why not keep this option in the constructor ?
  • The second argument of the Error constructor will be completely ignored on older runtimes. This means that in those runtimes the err.cause prop won't be set. Having the cause prop can however be useful for error reporting tools (e.g. Sentry). Do you explicitly want to drop support for older runtimes ?
  • Since this is a breaking change, would it not be better to include changes required for Add support for native cause #300 as part of the same discussion? The idea is that we probably want to avoid two breaking releases in a row and Add support for native cause #300 kinda makes it obsolete to use a Boom as data.

lib/index.js Outdated Show resolved Hide resolved
lib/index.js Outdated Show resolved Hide resolved
@kanongil
Copy link
Contributor Author

kanongil commented Dec 7, 2023

Thanks for the feedback – those are all great questions.

  • The ability to decorate is both useful internally and for users of the lib. Why not keep this option in the constructor ?

I considered reverting this, especially after I started to use it internally. I'm very open to change it back.

  • The second argument of the Error constructor will be completely ignored on older runtimes. This means that in those runtimes the err.cause prop won't be set. Having the cause prop can however be useful for error reporting tools (e.g. Sentry). Do you explicitly want to drop support for older runtimes ?

I explicitly want to drop support for old node runtimes. Older browser runtimes, probably not. As it is, they would need some kind of polyfill. Alternatively, I guess a simple this.cause ??= cause after the super() call could fix this (though they would not see the full printed stack trace).

I see those changes as a separate PR, that could very well go into the same breaking release.

Though I'm not sure they are worth the bother, once this is merged. new Boom() and boomify() are already done, so it would only be for the helpers. Here cause is implicitly supported through the data argument of the 500+ helpers, and you can always (except for unauthorized and methodNotAllowed) create a base object instead: new Boom(message, { statusCode, cause: err }.

@kanongil
Copy link
Contributor Author

kanongil commented Dec 7, 2023

I added support for old web runtimes in 7c7f86c.

FYI, this exposed a coverage reporting issue in lab. It seems it doesn't handle the ??= assignment operator, and reports full coverage even if the RHS logic is never called.

@kanongil
Copy link
Contributor Author

kanongil commented Dec 7, 2023

Regarding the decorate option, I had a look at reverting it and found another reason to remove the feature.

Specifically it does not seem possible to model with typescript, as in defining a class Boom<Data, Decoration> with extra properties from the Decoration generic.

lib/index.d.ts Outdated Show resolved Hide resolved
@matthieusieben
Copy link

matthieusieben commented Dec 11, 2023

Regarding the decorate option, I had a look at reverting it and found another reason to remove the feature.

Specifically it does not seem possible to model with typescript, as in defining a class Boom<Data, Decoration> with extra properties from the Decoration generic.

Errors are generally used as thrown values. As such, strongly typing them does not make a lot of sense. Even the Data generic will be lost once the error is thrown. I personally never had any use for it. My approach usually consist of using type assertions in catch block to properly narrow the content of the boom error:

type Foo = { bar: string }
declare const isFoo = (x: unknown): x is Foo

try {
  // ...
} catch (err) {
  if (isBoom(err) && isFoo(err.data)) {
    // err is Boom<Foo> here
  }

  // Unexpected error
  throw err
}

The same goes for decorations:

type Foo = { bar: string }
declare const isFoo = (x: unknown): x is Foo

try {
  // ...
} catch (err) {
  if (isBoom(err) && isFoo(err)) {
    // err is Boom<unknown> & Foo here (decorated with `Foo`)
  }

  // Unexpected error
  throw err
}

The following notation can be used to model decorations with Typescript:

interface Boom<Data = unknown> extends Error {
  isBoom: true
  data: Data
}

interface BoomConstructor {
  new <Data, Decoration>(options: { data: Data; decorate: Decoration }): Boom<Data> & Decoration
  readonly prototype: Boom;
}

declare const Boom: BoomConstructor

Note that this notation might actually be the proper way of typing things in the d.ts file since isBoom checks for an interface match rather than an actual instanceof operation.

lib/index.d.ts Outdated Show resolved Hide resolved
lib/index.js Outdated Show resolved Hide resolved
lib/index.js Outdated Show resolved Hide resolved
@kanongil
Copy link
Contributor Author

I removed the decorate option again. It provides too little benefit compared to the difficulty of typing it and its interactions with boomify(). Again, custom properties can easily be applied with an Object.assign().

@matthieusieben
Copy link

There is no necessity to type it though. As long as the returned value matches the Boom interface (decorate can't override Boom properties such as data, output, status code, etc) the type contract will be respected.

On the other end cause might be a good enough placeholder for most use cases.

@kanongil
Copy link
Contributor Author

kanongil commented Nov 1, 2024

The decorate option can always be added back in, in a non-breaking change.

I don't know how it is used in the wild, but the feature is not used within the hapijs org itself.

I did find some use in the legacy "hawk" module. Notice that it already uses Object.assign() to decorate the error from utility types which doesn't support any normal options.

If you want to decorate your boom object, a better solution will probably be to subclass Boom and handle it using a custom option.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking changes Change that can breaking existing code feature New functionality or improvement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants