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

Thrown AbortErrors are not DOMExceptions #40692

Open
kanongil opened this issue Nov 1, 2021 · 64 comments
Open

Thrown AbortErrors are not DOMExceptions #40692

kanongil opened this issue Nov 1, 2021 · 64 comments
Labels
errors Issues and PRs related to JavaScript errors originated in Node.js core.

Comments

@kanongil
Copy link
Contributor

kanongil commented Nov 1, 2021

Version

v17.0.1

Platform

any

Subsystem

No response

What steps will reproduce the bug?

import { readFile } from 'fs/promises';

const ref = new DOMException('The operation was aborted', 'AbortError');
console.log('REF INSTANCE', ref instanceof DOMException);
console.log('REF OBJECT', ref);

const ab = new AbortController();
ab.abort();

try {
    await readFile('does_not_matter', { signal: ab.signal });
}
catch (err) {
    console.log('ERR INSTANCE', err instanceof DOMException);
    console.log('ERR OBJECT', err);
}

How often does it reproduce? Is there a required condition?

100%, including for all other native APIs that supports signals.

What is the expected behavior?

From the docs:

If a request is aborted the promise returned is rejected with an AbortError

There is no mention that this is a non-standard new DOMException(message, 'AbortError') error. As such, I expect 'REF' and 'ERR' logged objects to be the same (except the stack).

What do you see instead?

REF INSTANCE true
REF OBJECT DOMException [AbortError]: The operation was aborted
    at file://<snip>/fail.mjs:3:13
    at ModuleJob.run (node:internal/modules/esm/module_job:185:25)
    at async Promise.all (index 0)
    at async ESMLoader.import (node:internal/modules/esm/loader:281:24)
    at async loadESM (node:internal/process/esm_loader:88:5)
    at async handleMainPromise (node:internal/modules/run_main:65:12)
ERR INSTANCE false
ERR OBJECT AbortError: The operation was aborted
    at checkAborted (node:internal/fs/promises:373:11)
    at readFile (node:internal/fs/promises:845:3)
    at file://<snip>/fail.mjs:11:11
    at ModuleJob.run (node:internal/modules/esm/module_job:185:25)
    at async Promise.all (index 0)
    at async ESMLoader.import (node:internal/modules/esm/loader:281:24)
    at async loadESM (node:internal/process/esm_loader:88:5)
    at async handleMainPromise (node:internal/modules/run_main:65:12) {
  code: 'ABORT_ERR'
}

Additional information

This is only an issue in node 17+, since previous versions did not have a DOMException global.

@benjamingr
Copy link
Member

Hey, this is by design, you can see the context and meeting in #36084. Node.js does throw DOMExceptions for DOM APIs. For its own APIs Node.js prefers a simpler AbortError with the same .name property (which is the preferred way to check for AbortErrors according to the DOM spec). The reason for this is that Node.js vendors some of its modules (like readable-stream) and having to ship DOMException with them would be hard.

@kanongil
Copy link
Contributor Author

kanongil commented Nov 2, 2021

@benjamingr That design does not appear to have been revised when node added support for native DOMException. This is causing extra confusion, and the root of this issue.

Essentially, this decision puts the onus on API consumers to handle the strange complexity, and on API documenters to clearly convey this, just so node can potentially simpler vendor readable-stream (which has not actually happened due to other complexities).

Given that there is no obvious central place to document this complexity, any API that uses a signal must clearly communicate this, which is currently failing.

I hope you will take a chance to reconsider this. You are prioritizing the efforts of a few core developers against the documentation team, and the entire developer base.

@benjamingr
Copy link
Member

benjamingr commented Nov 2, 2021

@benjamingr That design does not appear to have been revised when node added support for native DOMException. This is causing extra confusion, and the root of this issue.

Node has added DOMException before AbortSignal - the decision was to move away from DOMExceptions for non DOM APIs using AbortSignal and stick to .name (the reasoning is in the thread I linked to and in the meeting minutes).

(Node does vendor readable-stream as the readable-stream package on NPM btw - but it hasn't been updated)

Given that there is no obvious central place to document this complexity, any API that uses a signal must clearly communicate this, which is currently failing.

Would it help if the documentations clarified the AbortError interface we use like we do for AbortSignal and friends?

I hope you will take a chance to reconsider this. You are prioritizing the efforts of a few core developers against the documentation team, and the entire developer base.

I don't think that's an entirely fair or an accurate representation of how things transpired:

  • AbortController/AbortSignal was added to the platform to enable cancellation scenarios.
  • We iterated on them and added them to more APIS while soliciting responses from the community, library authors, WHATWG folks and other subject-matter-experts.
  • When soliciting feedback from library vendors and core - the concern about DOMException being complicated and different was raised. There were no objections to changing it - you are in fact the first person to bring up this complaint in the ±year we've shipped this.
  • The process was open and in the public, anyone from the community was welcome to participate and many were invited specifically so we collect user feedback. We've had a meeting about this in the summit (online, also open to everyone) and several other discussions.
  • This decision was made considering the people who built AbortController/AbortSignal/AbortError in the DOM in the first place, people who worked on other AbortSignal APIs, the community, library authors and more.
  • Things quieted down pretty quickly and we're getting positive feedback.

Also I want to point out that the decision isn't final, we can revisit anything here. We did not receive any pushback about this change from the documentation team nor developer base. We are happily willing to listen to feedback.

All of this is relatively new and improvement opportunities (in docs, APIs, debugging experience and more) are welcome. Contributions are welcome and Node.js in general and this area in particular is eager to receive them.

@kanongil
Copy link
Contributor Author

kanongil commented Nov 2, 2021

Thanks for the thorough response, and good to know that there is still room for iterating the approach.

DOMException has only just been made public with node v17, so has only been there for a few weeks for API consumers. It was merged August 6'th in e4b1fb5, and I don't see any considerations on how this impacts AbortError in any of the relevant PRs. Issue #39098 explicitly mentions the ability to do instanceof checks. #39176 can hardly have been a consideration almost a year ago in #36084.

Also, most feedback you will have gotten so far is likely not from the wider dev community, as many still support node v12, and are only just starting to look into using the features v14+ provides due to v12 EOL coming up.

FYI, I'm very exited about this new feature, which is why I want to see it done right. Here I think unifying on new DOMException(message, 'AbortError') for node native and modules targeting v17+ will make it simpler to use. Both as providers (those throwing) and consumers (those passing signal / calling abort()) that are likely to have to ignore the subsequent rejections. Besides making it simpler to use the feature, it also enables compatibility with browser runtimes.

This still leaves module that target v14/v16 and v17+ "in the dust", having to deal with extra complexity to handle two different error implementations. However, they are no worse of than the current situation, and will eventually be able to drop the legacy codepaths.

Given that there is no obvious central place to document this complexity, any API that uses a signal must clearly communicate this, which is currently failing.

Would it help if the documentations clarified the AbortError interface we use like we do for AbortSignal and friends?

Yes – this would be simpler to document if the internal AbortError was public (as discussed in #38361).

@benjamingr
Copy link
Member

Thanks for the thorough response, and good to know that there is still room for iterating the approach.

Absolutely.

DOMException has only just been made public with node v17, so has only been there for a few weeks for API consumers. It was merged August 6'th in e4b1fb5, and I don't see any considerations on how this impacts AbortError in any of the relevant PRs. Issue #39098 explicitly mentions the ability to do instanceof checks.

It's true that DOMException has been made public in Node.js 17 but it has been in core for a few years now (since things like URL raise it). Note that a public DOMException is even worse for stuff like readable-stream that exists for compatibility across versions since people may expect instanceof checks to work (and they won't) - neither will instanceof checks work across vm contexts and other "realms".

In general it makes perfect sense to expose DOMException as well as AbortError but people should never rely on instanceof checks for errors. That's why there were proposals for stuff like Error.isError.

If you read the WHATWG fetch specification carefully (I am happy to dig up the discussions where we initially met to discuss AbortController) - you will see it makes no requirements on raising AbortError. In fact when I added an API myself (signal in addEventListener) there were no errors involved.

DOM APIs are encouraged but not required to reject with an AbortError DOMException - non DOM APIs have no such requirements. If you look at the spec example it encourages checking .name rather than instanceof (for the aforementioned reasons) that is quite intentional.

#39176 can hardly have been a consideration almost a year ago in #36084.

I actually recall seeing (but not having time to review) that and trusting reviewers since I think it's overall a good idea.

Also, most feedback you will have gotten so far is likely not from the wider dev community, as many still support node v12, and are only just starting to look into using the features v14+ provides due to v12 EOL coming up.

This was less "users approaching us" and more "us approaching users and asking what they think" and "us talking to people who have experimented with userland AbortSignals (like SDK authors) and asking them what feedback their users are giving. Gathering user and library maintainer feedback is always a big challenge in Node.js and always something we could use more of.

FYI, I'm very exited about this new feature, which is why I want to see it done right. Here I think unifying on new DOMException(message, 'AbortError') for node native and modules targeting v17+ will make it simpler to use. Both as providers (those throwing) and consumers (those passing signal / calling abort()) that are likely to have to ignore the subsequent rejections. Besides making it simpler to use the feature, it also enables compatibility with browser runtimes.

Note that we switched from DOMException - I think this is where it was added after the meeting and consensus.

Unless the concerns raised by @mcollina regarding this being hard for readable stream and other parts were addressed in his perspective - I don't see us switching back to DOMExceptions (which wouldn't break a lot of code probably since people are supposed to check .name but it might).

Yes – this would be simpler to document if the internal AbortError was public (as discussed in #38361).

Is that something you'd be interested in contributing? I know we've talked about this in the past but I am honestly not sure what it's blocked on and I am happy for us to start with a simpler flagged errors module and add more codes/errors.

How can I help you with this? (I can review, ping the TSC to discuss the module inclusion, try to get more people to look at this etc).

@mcollina
Copy link
Member

mcollina commented Nov 2, 2021

I don't have much time right now for joining a conversation with this depth. From a quick skim @benjamingr summarizes this correctly. My position has not changed on this matter.

@kanongil
Copy link
Contributor Author

kanongil commented Nov 2, 2021

We agree that it makes sense for user code to distinguish AbortError from regular errors, eg. to avoid logging it, right? Thus it makes sense that the error included in the rejection (when triggered) is clearly identifiable. As you say, this is currently done through checking the .name property, but it relies on APIs to reject using properly crafted errors.

While the node APIs can be aligned on what is thrown, it is anyones guess what third-party modules will throw. And since those modules might forward the signal to submodules, the same API call could be able to reject using two or more completely different abort errors, depending on when it is aborted. Ie. you can't even reliably test against how abort errors are emitted.

For a different take on this, node could expose helpers like this to enable tests for .aborted and throwing using a standardized AbortError, making it less likely that third party modules get it wrong. And probably also something for the rare cases that the 'abort' event is used. This can be done without exporting the AbortError class.

Btw, this issue is really about standardizing the rejected error, be it through always using DOMException or some other means. I'm naturally inclined towards DOMException since it better aligns with browsers, but if it is a clear no-go, then there are still other ways to improve the situation.

@benjamingr
Copy link
Member

I think we are all very much in favor of standardizing third-party thrown exceptions and providing guidance to people authoring async APIs with cancellation. I think that's a good idea and I don't think that was contested.

I think that is mostly blocked on someone doing the work. If exposing errors is a problem (I think it can be done and think the block is just technical) I am fine with exposing it elsewhere.

@targos targos added the errors Issues and PRs related to JavaScript errors originated in Node.js core. label Nov 6, 2021
@domenic
Copy link
Contributor

domenic commented Nov 11, 2021

I saw claims like this a few times:

If you look at the spec example it encourages checking .name rather than instanceof (for the aforementioned reasons) that is quite intentional.

I can state that is not intentional, and using .name is not the recommended way. That one example in the DOM spec is just an example. All coding styles are valid and OK on the web; instanceof, for example, is fine (if you're working within a single realm). As is checking err.code === DOMException.ABORT_ERR.

I do think it would be beneficial to the ecosystem if Node didn't create its own separate AbortError type, and instead used and encouraged the standard DOMException. I think as @kanongil points out the fact that there are two is going to cause a lot of ecosystem confusion, as e.g. code that wants to be web/Deno compatible uses DOMException, and code which doesn't care about such compatibility uses Node's one-off version, and so on.

@ljharb
Copy link
Member

ljharb commented Nov 11, 2021

Why would it be a good thing for environments without a DOM to have or use a DOM exception? If it was meant to be a universal exception type then it seems it was unwisely named.

@domenic
Copy link
Contributor

domenic commented Nov 11, 2021

Yes, it's pretty easy to complain about names of things in the JavaScript ecosystem; as I'm sure you're aware we haven't named everything optimally in its 20+ year history.

@benjamingr
Copy link
Member

benjamingr commented Nov 12, 2021

@domenic to be clear the thing blocking DOMExceptions for AbortErrors in Node.js isn't the fact people don't like DOMExceptions (we already ship DOMExceptions anyway).

It's the fact DOMExceptions become a dependency of libraries (like readable-stream) that are explicitly designed to run in non-modern Node.js environments and porting DOMException there would be hard.

The current solution is a compromise that enabled us to adopt AbortSignals in all APIs while staying spec-compliant. Obviously we would be happier shipping less kinds of errors for the same thing :)

@ljharb

Why would it be a good thing for environments without a DOM to have or use a DOM exception?

It's a communications thing but IMO the DOMness of the exception is "this exception comes from a DOM API" rather than requiring a DOM tree.

@benjamingr
Copy link
Member

Oh, and regarding:

I saw claims like this a few times: I can state that is not intentional, and using .name is not the recommended way. That one example in the DOM spec is just an example.

@domenic I think we got that feedback/best-practice from @jakearchibald both in the AbortError meeting regarding it and back when cancellable fetch was discussed since it inter-ops between realms and is guaranteed.

If there is other preferred guidance we are happy to look at alternatives (though it would have been great a year ago).

(Also note I am happy to ping you more on these issues but I know you're busy and remember you don't like direct pings so I've been limiting the amount of pings to cases spec-compliance was on the line)

(And of course the thing that blocked using DOMExceptions wasn't their name anyway it was the difficulty vendoring)

@ljharb
Copy link
Member

ljharb commented Nov 12, 2021

@benjamingr "came from a DOM API" means we'd be expecting users to know the difference between specifications, something web browser representatives have repeatedly insisted they do not and should not understand.

I'm not saying the name of DOMException is the issue; as much as it is that AbortError inherits from it. Is it really too late for the web to fix that?

@domenic
Copy link
Contributor

domenic commented Nov 12, 2021

Yes, I agree developers should not have to know where the error came from. That's why I think Node.js should follow other ecosystems in using DOMException for abort errors instead of inventing its own one-off.

We have no plans to change how the web throws abort errors to use anything other than DOMException.

@DerekNonGeneric DerekNonGeneric added the confirmed-bug Issues with confirmed bugs. label Nov 14, 2021
@DerekNonGeneric DerekNonGeneric self-assigned this Nov 14, 2021
@Jamesernator
Copy link

Jamesernator commented Nov 18, 2021

I'm not saying the name of DOMException is the issue; as much as it is that AbortError inherits from it. Is it really too late for the web to fix that?

Actually AbortError doesn't inherit DOMException, rather there is just a single DOMException class such that every kind of DOM error is, just the .name/.code properties get set differently.

We have no plans to change how the web throws abort errors to use anything other than DOMException.

Do you think it would be viable to make AbortError an actual subclass of DOMException? This way people would be able to do err instanceof AbortError and new AbortError(message) as expected. We could even support existing uses of new DOMException("message", "AbortError") by having a custom [Symbol.hasInstance].

This would mean AbortError would be an actual global rather than the confusing and poorly named DOMException, but should not be an otherwise breaking change. (This could be done with other kinds of DOMException too, but I don't know if there would be as much demand as people wouldn't generally construct those themselves).

@domenic
Copy link
Contributor

domenic commented Nov 18, 2021

We have no plans to change how the web throws abort errors to use anything other than DOMException.

@ljharb
Copy link
Member

ljharb commented Nov 18, 2021

That's clear, but I think the request is to perhaps create those plans.

@benjamingr benjamingr removed the confirmed-bug Issues with confirmed bugs. label Nov 18, 2021
@benjamingr
Copy link
Member

@DerekNonGeneric this is not a confirmed bug - the fact this happens was a design decision that carefully considered the trade-offs. We can re-evaluate this further and see if we can stop vendoring readable-stream (or only vendor it in environments that have DOMExceptions) or we can explore DOM changes (though I'm not sure how we'd make a compelling case for browsers) or anything really.

Let's not "fix" this until there is consensus it should be fixed and how :)

@DerekNonGeneric
Copy link
Contributor

My only argument was being in favor of code portability, but open to finding what other ways we would be able to achieve that.

@benjamingr
Copy link
Member

@DerekNonGeneric I am not arguing for/against using DOMExceptions for AbortErrors right now - I am just asking not to change the behaviour without discussions. We started with DOMExceptions and moved away from them (see the discussion above :)), I suspect that if we refactor back people will not be happy.

In particular readable-stream would need to vendor DOMException inside readable-stream and would like to avoid that.

@Jamesernator
Copy link

That's clear, but I think the request is to perhaps create those plans.

Yes, there's plenty of improvements on the web for ergonomics of APIs all the time. I don't see why DOMException would not be eligible for such improvements.

We have no plans to change how the web throws abort errors to use anything other than DOMException.

And to be clear, I don't want to break existing code which is why my suggestion above made AbortError a subclass so that code that already uses DOMException still works as usual. However it would still offer the ergonomic benefits like new AbortError("aborted for reason") or err instanceof AbortError working as expected.

@DerekNonGeneric I am not arguing for/against using DOMExceptions for AbortErrors right now - I am just asking not to change the behaviour without discussions. We started with DOMExceptions and moved away from them (see the discussion above :)), I suspect that if we refactor back people will not be happy.

For this, something worth pointing out is that the spec for AbortController/AbortSignal has actually added quite a bit tighter integration with DOMException/AbortError recently. In particular there is a new property signal.reason that is set by default to an AbortError DOMException. i.e.:

const controller = new AbortController();
controller.abort();

console.log(controller.signal.reason); // DOMException: AbortError
``

This `.reason` property is intended to be [thrown from all APIs as part of its integration](https://github.com/whatwg/dom/issues/1030). i.e. If you're aborting from a signal it would be equivalent to this:

```js
if (signal.aborted) {
    throw signal.reason;
}

signal.addEventListener('abort', (event) => {
    reject(event.target.signal);
});

The .reason can be set to a custom value instead too though i.e.:

class MyCustomError extends Error { name="MyCustomError" }

const controller = new AbortController();
controller.abort(new MyCustomError());

console.log(signal.reason); // MyCustomError

I don't know if this throwing behaviour is something Node will adopt or not, but if it does this essentially removes control of what kind of error is thrown from the code utilizing an AbortSignal entirely instead deferring such decisions to whoever has the abort controller.

@kanongil
Copy link
Contributor Author

signal.reason seems like a very sensible API, and has already been merged in whatwg/dom#1027.

Using this in node would make the readable-stream issue moot, since the associated abort logic would no longer generate any error, just throw the provided signal.reason. This makes whether it is a DOMException, or a custom AbortError, up to the signal provider.

@benjamingr
Copy link
Member

@kanongil If I understand correctly signal.reason doesn't replace the error does it?

@Jamesernator to explain: in order for the DOM to consider not using DOMExceptions it would have to be specifically interesting to the browser stakeholders. So you would need to present a compelling argument on how this would benefit browsers rather than a non-browser-related Node.js compatibility issue.

@benjamingr
Copy link
Member

Hmm, .reason superceding the AbortError is actually pretty risky since it means consumers can raise arbitrary error subclasses "from the outside" and users can't catch cancellation with AbortErrors.

I think Node.js should still wrap errors with AbortError probably even if reason is passed.

@kanongil
Copy link
Contributor Author

@benjamingr Yes, it should replace the generated error, as specified here.

@benjamingr
Copy link
Member

@jasnell pointed out that .reason does not imply/suggest/guarantee that it is the exception thrown when a promise API is rejected.

So I think my current ask in this regard is really just "have a way to know when an error is cancellation" blessed by the platform. Node can ship this without WHATWG (with a static) but I think this is important for users elsewhere too so I am going to try and figure it out in the WHATWG/dom repo.

@jasnell
Copy link
Member

jasnell commented Nov 21, 2021

I agree that we should be using DOMException(AbortError) to signal cancelations (or DOMException(TimeoutError) when AbortSignal.timeout() is finished. Having a separate AbortError that is specific to Node.js is confusing. Where Node.js currently throws (or rejects) with an AbortError, we'll need to take those APIs through a deprecation cycle to transition to DOMException(AbortError), which is a bit irritating, but that's our own issue.

@benjamingr, I still don't really know what you mean by "know when an error is cancellation".

@benjamingr
Copy link
Member

@jasnell Node.js moved from DOMEXceptions for vendoring reasons (so that readable-stream would not need to vendor DOMExcepiton). If that concern has been resolved we can revert that decision.

@Jamesernator
Copy link

So I think my current ask in this regard is really just "have a way to know when an error is cancellation" blessed by the platform. Node can ship this without WHATWG (with a static) but I think this is important for users elsewhere too so I am going to try and figure it out in the WHATWG/dom repo.

What are the situations where you actually want to distinguish cancellation from other error kinds WHERE you don't control the AbortController?

@jasnell pointed out that .reason does not imply/suggest/guarantee that it is the exception thrown when a promise API is rejected.

I do agree that .reason not being guaranteed to be the thing thrown is weird, but that design falls out of the fact that AbortSignal was done in userland because TC39 never came to consensus on a design for cancellation to be distinct. Building on what JS provides, errors are the only mechanism by which things will bubble up the stack, so there's not really choices in this regard.

Also it's not really any different to the fact that an API can just outright ignore the AbortSignal and throw any error it wants at any time for any reason anyway, .reason is a bit nicer because it gives the person requesting the abort more control rather if the API accepting the AbortSignal is willing to accept a fairly simple implicit contract.

@jasnell Node.js moved from DOMEXceptions for vendoring reasons (so that readable-stream would not need to vendor DOMExcepiton). If that concern has been resolved we can revert that decision.

If readable-stream simply throws .reason then it's no longer constructing its own errors for aborts anyway, so it shouldn't need to depend on DOMException.

@Jamesernator
Copy link

Jamesernator commented Nov 22, 2021

So I think my current ask in this regard is really just "have a way to know when an error is cancellation" blessed by the platform. Node can ship this without WHATWG (with a static) but I think this is important for users elsewhere too so I am going to try and figure it out in the WHATWG/dom repo.

What are the situations where you actually want to distinguish cancellation from other error kinds WHERE you don't control the AbortController?

@jasnell pointed out that .reason does not imply/suggest/guarantee that it is the exception thrown when a promise API is rejected.

I do agree that .reason not being guaranteed to be the thing thrown is weird, but that design falls out of the fact that AbortSignal was done in userland because TC39 never came to consensus on a design for cancellation to be distinct. Building on what JS provides, errors are the only mechanism by which things will bubble up the stack, so there's not really choices in this regard.

Thinking about this for a minute, you actually can distinguish cancellations from other errors. This isn't something I'd recommend for general people to use (general users should just treat the errors by their kind, not how they were thrown), but if you really must you can do:

async function myCancelableOperation(abortSignal) {
    try {
        await someOtherOperation(abortSignal);
    } catch (error) {
        if (abortSignal.aborted && error === abortSignal.reason) {
            // The error was a cancellation
        } else {
            // Not a cancellation
        }
    }
}

@benjamingr
Copy link
Member

Thinking about this for a minute, you actually can distinguish cancellations from other errors. This isn't something I'd recommend for general people to use (general users should just treat the errors by their kind, not how they were thrown), but if you really must you can do:

This only works if you have a reference to the signal.

Building on what JS provides, errors are the only mechanism by which things will bubble up the stack, so there's not really choices in this regard.

The ask is not for true third-state cancellation only to be able to distinguish it as was possible until now. I realize that we don't have to throw signal.reason and can keep reason AbortErrors in our code happily while giving signal.reason as metadata which should be fine.

What are the situations where you actually want to distinguish cancellation from other error kinds WHERE you don't control the AbortController?

See examples in whatwg repo. Basically almost any time a framework is involved?

@tilgovi
Copy link
Contributor

tilgovi commented Jul 28, 2022

If, instead of wrapping reason with new AbortError(..., { cause: signal.reason }), Node.js followed fetch in throwing reason itself, wouldn't error === signal.reason work to detect cancellation?

@tilgovi
Copy link
Contributor

tilgovi commented Aug 8, 2022

@benjamingr I think this is a better place to respond to your comment about fetch1 than on the closed issue for that, but please direct me if there's a more constructive way I could engage with this issue.

I'd like to make the case that Node should switch to throwing reason in the APIs that use AbortSignal. I'm keeping an open mind to be convinced otherwise, though.

As a consumer of a signal that forwards it to another function, any code I write that looks like this

try {
  await forward({ signal });
} catch (err) {
  if (err.name === 'AbortError') { /* ... */ }
  throw err;
}

can just as easily be written like this

try {
  await forward({ signal });
} catch (err) {
  if (err === signal.reason) { /* ... */ }
  throw err;
}

I'd like to argue that the latter is more accurate. Instead of reacting to the fact that something was thrown that has a name property equal to AbortError, it's possible to know for sure that what happened was that the signal caused the operation to abort. Otherwise, it's possible some other thing was thrown that has that name, or some sub-operation of forward aborted for some other reason and forward didn't catch it, and so on.

Whether or not such a function should or should not propagate the reason by re-throwing it, and under what conditions, feels like it's not for anyone but the implementor to decide. I just argue that we should give them that power by propagating the reason in Node APIs.

Anyone creating an AbortController and passing its signal into Node APIs that accept it doesn't have to change their code. The check for err.name === 'AbortError' will continue to work. But for library authors that might consume and forward a signal, it should be possible to switch without affecting callers (unless they decide to throw something else).

The only place where this falls down is on v16, where AbortController.prototype.abort() without an argument doesn't set signal.reason. I hope we could bury that inconsistency or perhaps backport the default reason to v16.

Footnotes

  1. https://github.com/nodejs/node/issues/43874#issuecomment-1186865167

@benjamingr
Copy link
Member

I am currently both sick and on bereavement leave so I can't leave a proper full response but:

There are two major issues with this:

  • I think breaking the way everyone checks errors everywhere is a huge breaking change and I am ver hesitant for Node.js to do it and break all our error guarantees here (it would certainly be semver-major).
  • You really don't always have access to the AbortController/AbortSignal when doing these sort of checks - and even if you do there might be multiple signals (e.g. one tied to the process's life time, one to an http request's).

@tilgovi
Copy link
Contributor

tilgovi commented Aug 11, 2022

No rush on any fuller response, but I'll leave my reactions for when you return:

I think breaking the way everyone checks errors everywhere is a huge breaking change and I am ver hesitant for Node.js to do it and break all our error guarantees here (it would certainly be semver-major).

This is a proper concern, but I'm hoping it's mitigated by the fact that for any case where abort() is called without a reason argument the error handling could remain the same. I'd hope there isn't a lot of code in the wild passing reason yet, but I still sympathize with this concern. If it's done in a semver-major change, code will have to write if (error === signal.reason || error.cause === signal.reason) until Node.js 18 EOL. Unfortunate, but also understandable.

You really don't always have access to the AbortController/AbortSignal when doing these sort of checks - and even if you do there might be multiple signals (e.g. one tied to the process's life time, one to an http request's).

This is exactly the argument in favor of the change, I think. If one has multiple signals, one can tell them apart if a rejection surfaces an object that is one of the reasons of one of the signals. Otherwise, we're stuck guessing at which signal, if any, might be the cause. If one doesn't have the signal at all, checking properties of the error is still the best bet regardless, and properly re-throwing unexpected errors (if you catch them at all) is normal.

The use case I have in mind as I think about this is one where there is more than one concurrent, asynchronous call that has yet to resolve. One might want to abort them gracefully, e.g. in response to SIGTERM. But one could throw exceptions into them that cause them to reject with something other than an abort error, effectively causing them to exit less gracefully.

@benjamingr
Copy link
Member

I think breaking the way everyone checks errors everywhere is a huge breaking change and I am ver hesitant for Node.js to do it and break all our error guarantees here (it would certainly be semver-major).

This is a proper concern, but I'm hoping it's mitigated by the fact that for any case where abort() is called without a reason argument the error handling could remain the same.

It would have been nice if it was but unfortunately this changes the API surface consumers have to deal with from "This API can reject with an AbortError" to "This API can reject with anything" and code surrounding it would now have to deal with other types of errors.

Since virtually all of Node's APIs supports this this is a big breaking change in errors. I am honestly not sure semver-major is enough for such a big breaking change in error handling. Note that single error-code changes are semver-major and this is a lot more drastic.

Node has a lot less lee-way in these sort of breaking changes.

This is exactly the argument in favor of the change, I think. If one has multiple signals, one can tell them apart if a rejection surfaces an object that is one of the reasons of one of the signals. Otherwise, we're stuck guessing at which signal, if any, might be the cause.

We have mitigated this by setting the .cause on the error with the .reason of the signal so you can do this today without increasing the API surface.

That said like with exceptions you typically shouldn't have to care which signal caused the abort only that the abort happened to clean up and propagate the error. So while this use case is possible I am not sure it's one people should reach out to often.

The use case I have in mind as I think about this is one where there is more than one concurrent, asynchronous call that has yet to resolve. One might want to abort them gracefully, e.g. in response to SIGTERM. But one could throw exceptions into them that cause them to reject with something other than an abort error, effectively causing them to exit less gracefully.

I acknowledge that communicating extra information or metadata is a totally valid use case. If the whatwg design forced AbortError as a base class or .name to be AbortError or another reliable method to not break existing code but attach metadata to it I'd be game. I'm not sure with the design they went with we can do better than .cause

@tilgovi
Copy link
Contributor

tilgovi commented Jun 16, 2023

The situation where Node.js library functions don't honor reason when aborting, but attach it as the cause of an AbortError instead, is even more unfortunate now that newer versions of undici, and therefore fetch in Node.js, do honor the reason.

Is it still the case that this will never change, not even in a semver-major change?

@tilgovi
Copy link
Contributor

tilgovi commented Jun 16, 2023

I'm just running into this over and over again and finding that it really hurts usability to the point where I almost don't want to use signals at all. Here is a slightly modified snippet of real code I have in production:

const signal = AbortSignal.timeout(WORK_TIME_MS);
try {
  await performWork({ signal });
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    // work time expired, just return
  } else {
    // some unexpected error actually occurred
    throw error;
  }
}

In v16, this code works. In v18+, this broke because performWork calls signal.throwIfAborted() and also passes the signal to a readable stream. I have to know the ways that performWork might use the signal to know how this might reject. I have to choose between wrapping the stream to provide WHATWG semantics by extracting the cause or writing additional code in performWork so that it rejects with AbortError regardless of the signal's reason in order to match Node.js semantics. To follow Node.js semantics, I need to put boilerplate like this at the top of any function I write that takes a signal:

function performWork(options) {
  const controller = new AbortController();
  const signal = controller.signal;

  const incomingSignal = options.signal;
  if (incomingSignal) {
    const onAbort = () => {
      const error = new DOMException("The operation was aborted.", "AbortError");
      error.cause = incomingSignal.reason;
      controller.abort(error);
    }

    if (incomingSignal.aborted) {
      onAbort();
    } else {
      incomingSignal.addEventListener('abort', onAbort, { once: true });
      // ... hopefully remember to clean this up later ...
    }
  }

  // ... finally on to the real function body
}

This would be slightly easier if AbortError were exposed (#38361), if DOMException could be constructed with a cause, or if there were an errors module that made some of this easy (#14554).

I've run through the process of creating a ReadableStream that follows the WHATWG semantics and that's not friendly either. It's so much easier to write signal.throwIfAborted() and propagate the signal to downstream calls and never construct any additional, intermediate signals.

Following the WHATWG semantics is easy, but following the Node.js semantics is hard. As a result, it's tempting to write code, as I have, that rejects inconsistently depending on when the abort happens.

@benjamingr
Copy link
Member

I have to know the ways that performWork might use the signal to know how this might reject.

Correct, now imagine every Node.js API would increase its error API surface from "these few errors" "to anything anyone with access to the controller may decide to throw". Not great IMO.

As for your actual code can't you use AbortSignal.any?

Also

Following the WHATWG semantics is easy, but following the Node.js semantics is hard. As a result, it's tempting to write code, as I have, that rejects inconsistently depending on when the abort happens.

Your example shows zero Node.js code though? What you're complaining about is the API design itself isn't it?

@Jamesernator
Copy link

Jamesernator commented Jun 17, 2023

Correct, now imagine every Node.js API would increase its error API surface from "these few errors" "to anything anyone with access to the controller may decide to throw". Not great IMO.

The whole point of .reason/.throwOnAborted is that whoever owns the controller is able to signal whatever exception they want.

Personally I've always thought the AbortSignal API is pretty meh at best, but at least the web is consistent in it's usage. However Node having two different exception behaviours depending on whether the API is a web API or Node API just makes the whole thing incoherent as now one needs to deeply know how an API might throw on signalling.

This is particularly bad for user code, which might be passing signals to a mix of Node APIs or Web APIs, i.e. if we have something like:

async function loadAndWrite(url, file, { signal }={}) {
   const response = await fetch(url, { signal });
   await fs.promises.writeFile(file, new Uint8Array(await response.arrayBuffer()), { signal });
}

then this function, when an abort is signaled, can either throw the given error to .abort OR a Node AbortError depending on timing, this is simply incoherent for people calling such a function.

@benjamingr
Copy link
Member

However Node having two different exception behaviours depending on whether the API is a web API or Node API just makes the whole thing incoherent as now one needs to deeply know how an API might throw on signalling.

It's unfortunate however Node.js can't take all its APIs and change their exception surface from "a standard node error with a .code property" to "whatever the user can throw to you". It basically means every error handling code that may work with signals must expect anything to be thrown from whoever owns the controller. This effectively is a huge API surface change and not to the better.

Personally I've always thought the AbortSignal API is pretty meh at best, but at least the web is consistent in it's usage.

In an ideal world it's consistent. To be fair there are a few things I think are very problematic from the Node.js point of view but unfortunately I don't the capacity to work on improving them.

@Jamesernator
Copy link

Jamesernator commented Jun 18, 2023

It's unfortunate however Node.js can't take all its APIs and change their exception surface from "a standard node error with a .code property" to "whatever the user can throw to you"

Why not? The only way to observe this is if you pass a signal that throws a different error, in which case you are presumably expecting that signal's error to propogate. Again this is a major part of the the reason that .abort accepts a reason at all.

and not to the better.

I disagree, being able to signal different kinds of exceptions through is useful. Like AbortSignal itself already offers AbortSignal.timeout which throws a TimeoutError instead so that one can discrimate such cases.

It basically means every error handling code that may work with signals must expect anything to be thrown from whoever owns the controller.

Code that sees exceptions it doesn't recognize should just rethrow them and stop continuing work, this is basic code design that has been understood for decades at this point.

@benjamingr
Copy link
Member

Why not? The only way to observe this is if you pass a signal that throws a different error, in which case you are presumably expecting that signal's error to propogate.

Yes but you do not own the controller just the signal. So someone from the outside can change your API surface.

If you have error handling code that does something like:

function myCode({ signal }) {
  try {
    await someFunction({ signal });
  } catch (e) {
     const aborted = someCheck(e); // e.g. e.name === 'AbortError' || e.code === 'ECONNABORTED";
     if (!aborted) throw e;
     else handleCancellation(e);
     const retryable = someOtherCheck(e); // e.g. e.code === 'EAGAIN'
     await retryLogic(e);
     throw e;
  }
}

In this case - every error may become unrecoverable and unexpected from the signal. The controller isn't aware of all its consumers. Let's say my code does something like:

const ac = new AbortController();
startServer(someData, ac); // gets AC to abort on request
await myCode({ signal: ac.signal });

If startServer (potentially one library) changes the signal to throw a different error the completely unaware myCode (potentially another library) has to change its logic to deal with it. It basically increases the expected error surface of the method to anything. A lot of people writing Node.js rely on being able to know what errors are thrown from APIs so I am very unsure we can say "our APIs may reject/error with anything now if you use our cancellation primitive" and get away with it.

and not to the better.
I disagree, being able to signal different kinds of exceptions through is useful.

No argument regarding it being useful the disagreement is only about the core APIs rejection/error API surface. Something being useful doesn't mean it doesn't come with painful tradeoffs and I believe Node.js can't make this one for the reason above.

Code that sees exceptions it doesn't recognize should just rethrow them and stop continuing work, this is basic code design that has been understood for decades at this point.

You are telling me that people don't write great code - as the platform I'm not sure what to say other than "Hyrum's law". There are many things I wish we could discourage more in terms of the code people write (rejecting/throwing with strings anyone?).

Node servers crash when an exception happens, crashes can cause a DoS, cascading failures, financial damage etc. If some library I don't control using core APIs decides to abort a controller with a new type of cancellation/error - I get woken up at night (bad) or go down (even worse). I got woken up for forgetting to filter cancellation errors correctly from instrumentation back in 2014 times ^^

(Ironically enough (all parties were for it iirc), if we had third party cancellation this wouldn't be an issue ^^)

@benjamingr
Copy link
Member

benjamingr commented Jun 19, 2023

(Also @domenic as you 🚀 ed @Jamesernator comment, I want to say that while I disagree with James's point I do see it. I'm happy to change my mind, debate this and hear more arguments. Your inputs here are always valuable and respected. I hope the ±10 years we've interacted about these sort of issues are enough to convince you I am sincere in that and am always happy to hear you out)

@benjamingr
Copy link
Member

And let me ask for some data:

@mcollina @ronag from the readable-stream point of view is DOMException in Node.js old enough that in terms of support we can ship readable-stream relying on it being available?

From the API side, I'm wondering if there is some way to find out how common "I expect this list of errors" code is as well as find out how many people treat cancellation differently than exceptions in their error handling code. I'm happy to hear ideas on how to measure this and change my mind if I'm assuming Hyrum's law and being pessimistic. One thing we may be able to try is a flag to change the behavior and then to ask people to report issues with it. I'll also see if I can ask the Node.js using teams/groups in Microsoft to check.

@Jamesernator
Copy link

Jamesernator commented Jun 19, 2023

So someone from the outside can change your API surface.

Yes, but again again this is the whole point of being able to signal any error.

If you have error handling code that does something like:

function myCode({ signal }) {
  try {
    await someFunction({ signal });
  } catch (e) {
     const aborted = someCheck(e); // e.g. e.name === 'AbortError' || e.code === 'ECONNABORTED";
     if (!aborted) throw e;
     else handleCancellation(e);
     const retryable = someOtherCheck(e); // e.g. e.code === 'EAGAIN'
     await retryLogic(e);
     throw e;
  }
}

This is exactly the sort've fragile pattern I was talking about, you shouldn't be handling cancellation specifically, EVERY exception that can't be handled explicitly should result in the work being cancelled not simply those with name AbortError.

A better pattern is to precisely reverse this sort've pattern:

function myCode({ signal }) {
  try {
    await someFunction({ signal });
  // Note we don't handle cancellation specifically at all
  } catch (e) {
    // If it's not a retry, then we don't know to handle it, so we just cleanup and stop work
    if (e.code !== "EAGAIN") {
      await cancelWorkAndCleanup();
      throw e;
    }
    // Whatever to retry
    await retry(...);
  }
}

If startServer (potentially one library) changes the signal to throw a different error the completely unaware myCode (potentially another library) has to change its logic to deal with it.

If you're giving startServer full control of the AbortController then yes it can throw anything, this is just the nature of allowing .abort to take anything.

If you don't want this, pass a different AbortController and follow it instead:

// Yes, I don't like how verbose this either, the AbortController/AbortSignal API does leave
// a lot to be desired in the sugar department

const ac = new AbortController();
const serverController = new AbortController();
serverController.addEventListener("abort", () => ac.abort(new WhateverIWantToAbortWith()));
startServer(someData, serverController); // gets AC to abort on request
await myCode({ signal: ac.signal });

If some library I don't control using core APIs decides to abort a controller with a new type of cancellation/error - I get woken up at night (bad) or go down (even worse). I got woken up for forgetting to filter cancellation errors correctly from instrumentation back in 2014 times ^^

There are general problems with JS async error handling that leaves a lot to be desired, but generally speaking cancellation errors shouldn't be propagating to a point where instrumentation could even observe them.

I don't know what cancellation primitives were around in 2014, but for AbortController if you create an abort controller and pass the signal around, it should be expected that whatever you pass it to might raise an exception.

Like in the given example from before, given the code seems to be top level one should be prepared to handle the exception:

const ac = new AbortController();
startServer(someData, ac); // gets AC to abort on request

// We handle cancellation at this level because we are the ones who are prepared to handle cancellation
try {
    await myCode({ signal: ac.signal });
} catch (e) {
    if (e === ac.signal.reason) {
        // The code has been cancelled, so we don't need to throw an error
    }
}

@Jamesernator
Copy link

Jamesernator commented Jun 19, 2023

Something else I do think is worth pointing out in all of this, is that for the most part users shouldn't be needing to handle cancellation at all in intermediate functions because generally speaking user code will be delegating to Node code that actually acts as sinks of AbortSignals.

Like to demonstrate what I mean, if we write something that can be cancelled:

async function doSomething({ signal }) {
   // ...
}

then internally in most cases doSomething will be passing the signal to functions created by Node which act as sinks of AbortSignal, e.g. things like fetch or fs, etc, etc

async function doSomething({ signal }) {
   // ...
   await fetch(..., { signal });
   
   // ...
   await fsp.readFile(..., { signal });
   
   // ...
   await timerPromises.setTimeout(..., { signal });
}

here users don't need to handle cancellation at all, they just let the various Node APIs throw the error and it'll propagate up naturally.

Also, usually speaking when users are implementing abortable APIs directly, the web-like behaviour is basically encouraged thanks to the exposure of signal.reason and .throwIfAborted() (also mdn docs recommend it, so most users will fall into this path as well):

// Supposing userland implemented timerPromises.setTimeout themselves 
function setTimeout(delay, { signal }={}) {
    return new Promise((resolve, reject) => {
        const timeout = setTimeout(() => resolve(), delay);
        signal?.addEventListener("abort", () => {
            clearTimeout(timeout);
            // Note that the easiest available option here is to use signal.reason
            reject(signal.reason);
        });
    });
}

@benjamingr
Copy link
Member

benjamingr commented Jun 20, 2023

@Jamesernator you are giving me examples of how one can author code to avoid pitfalls this is potentially creating, my argument hasn't been that it isn't possible to write code that works after this change it's that a bunch of people won't and likely already don't. That was the whole bit about Hyrum's law and errors being part of the API surface (enough that error code changes in Node are semver major and we "broke the ecosystem" a few times in the past before (and after) that).

(Also it's depressing to me that we've given people an API where even experienced developers debating the semantics of these very APIs forget to remove the event listener on the signal, myself included sometimes)

@Jamesernator
Copy link

Jamesernator commented Jun 20, 2023

after this change it's that a bunch of people won't and likely already don't.

Well they certainly won't if Node decides to actively support behaviour that is divergent from what web browsers implement and the spec recommends.

Also .reason wasn't initially part of web implementations either, yet they managed to ship this change perfectly fine. I don't see why Node should be in anyway special here.

That was the whole bit about Hyrum's law and errors being part of the API surface (enough that error code changes in Node are semver major and we "broke the ecosystem" a few times in the past before (and after) that).

I'm not suggesting this shouldn't be a semver major change, though I am wary of the fact that the longer this behaviour is around the more entrenched it becomes.

And it's not like people in Node can even handle such errors in a consistent way anyway, as I pointed out with my earlier example. Ultimately if people rely on Node's error behaviour, and a library changes to use fetch or some other web API, then they will be hit with the web behaviour anyway.

forget to remove the event listener on the signal, myself included sometimes)

Yes, I usually use a helper that cleans up the listener. There are suggestions for this problem in the DOM spec, though they all have the dreaded needs implemeter interest tag.

(Also it's depressing to me that we've given people an API where even experienced developers debating the semantics of these very APIs

I don't want this to come across as an attack on anyone, but I do really think whatwg dropped the ball when designing this API. It's beyond me why the fairly obvious problems were not considered at the time, and still fail to have implementer interest to fix.

It also has always strongly bothered me that from the get go of AbortSignal, the spec had a special hook to specifically avoid the event behaviour which doesn't suffer any of the same problems.

@benjamingr
Copy link
Member

@Jamesernator

Also .reason wasn't initially part of web implementations either, yet they managed to ship this change perfectly fine. I don't see why Node should be in anyway special here.

Well, the web has a lot fewer APIs than Node that work with AbortSignal and (arguably) a lot more use cases where you'd need to abort ongoing actions.

As I mentioned I am happy to change my mind if my intuition of "this breaks things" is wrong and I'm not sure how to get the data

It also has always strongly bothered me that from the get go of AbortSignal, the spec had a special hook to specifically avoid the event behaviour which doesn't suffer any of the same problems.

There are actually two now whatwg/dom#1195 the recommendation is (?) to do AbortSignal.any([signal]).addEventListener. Honestly this sucks and I can complain but I don't have the time to fix this and the reason it hasn't been fixed/addressed isn't bad faith but funding - I'm confident that if someone spent a bunch of time improving this it will be improved.

As Symbol.dispose/Symbol.asyncDispose are stage 3 we may move to those APIs for resource management (streams, file handles, servers etc) and deprecate/remove AbortSignal support in those APIs (but not ones that signal the cancellation of an action, we'll keep using AbortSignal for those). For AbortSignal we may want to provide a helper that uses our internal (unfortunate) machinery we have for "non stoppable and weakly held event listeners" that user-land can't make atm spec wise otherwise (both weak and "unstoppable" events not being part of the spec, again somewhat for funding).

@mcollina
Copy link
Member

Currently readable-stream supports down to v12. However, the next release would likely cut down to v18, so this can land in a semver-major change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
errors Issues and PRs related to JavaScript errors originated in Node.js core.
Projects
None yet
Development

No branches or pull requests

10 participants