`.throw()`ing an async iterator
bojavou opened this issue · 5 comments
The AsyncIterator
spec defines a throw()
method.
Invoking this method notifies the AsyncIterator object that the caller has detected an error condition. The argument may be used to identify the error condition and typically will be an exception object.
This method seems to be ignored by streams. The Streams spec defines a response to return()
but doesn't mention throw()
.
Implementations have left it out. web-streams-polyfill
and the Node.js implementation both define return()
but not throw()
. Browsers seem not to support iteration yet.
I feel like I should be receiving this signal in my streams. If the consumer has an error, they should be able to tell me so I can respond to it.
One use case: throw()
is meaningful in a generator function. Suppose I have a series of transforms wrapping a generator. I'd like a throw()
at the end to propagate all the way back so it can be thrown in the generator code. This hits different logic than a return()
.
async function * latestNews () {
const connection = open()
try {
yield * connection.readAll()
} catch (error) {
connection.reportError(error)
throw error
} finally {
connection.close()
}
}
const stream = new GeneratorStream(latestNews())
.pipeThrough(new FilterToInterestingStream())
.pipeThrough(new RenderMarkdownStream())
.pipeThrough(new RenderLinkPreviewsStream())
const iterator = stream[Symbol.asyncIterator]()
// next() next() next()
await iterator.throw(new Error('We crashed, shut it down!'))
The error might even be handled so generation can continue. This isn't possible with a return()
.
async function * latestNews () {
const connection = open()
while (connection.active) {
try {
yield await connection.readNext()
} catch (error) {
connection.reportError(error)
if (error.recoverable) {
connection.resetState()
continue
} else {
connection.close()
throw error
}
}
}
}
throw()
is also potentially meaningful in any custom iterators a stream might be wrapping.
const r = new ReadableStream({
pull(c) {
c.enqueue("hello");
},
cancel(arg) {
console.log("cancel", arg);
}
})
for await (const chunk of r) {
throw new Error("errored!");
}
This indeed doesn't seem to pass the error object to the cancel callback, arg
is undefined in this case.
I guess a blocking issue is that Web IDL does not define the hook for throw
: https://webidl.spec.whatwg.org/#idl-async-iterable
We could add a similar hook for throw(). So far there has been no need, but if you are creating an API that needs such capabilities, please file an issue.
Should we have an issue? @MattiasBuelens
for..of
doesn't call throw()
either.
let it = {
[Symbol.iterator]: () => it,
next() {
return { done: false, value: "a" };
},
throw(e) {
console.log("it.throw() called");
return { done: true };
},
return() {
console.log("it.return() called");
return { done: true };
}
};
for (const elem of it) {
throw new Error("boom!");
}
The above snippet logs:
it.return() called
Uncaught Error: boom!
So even if we added such a hook, it wouldn't do anything in your example. AFAIK the only built-in construct that interacts with throw()
is yield*
.
I don't see how we could ever make throw()
work. With generators, throw()
can still cause the generator to resume normally, as in the following example:
function* gen() {
while (true) {
try {
yield;
} catch {
continue;
}
}
}
But with for..of
, throwing inside the loop body must propagate upwards. We cannot "resume iteration" afterwards. So we only have return()
.
In my iteration utils I have an abort routine that throw()
s first then return()
s if it doesn't take. This way I can deliver the error if the iterator wants it, but still ensure it closes no matter what happens.
function abort (iterator, error) {
try {
const result = iterator.throw(error)
if (result.done) return
} catch {
return
}
iterator.return(error)
}
But this would change the semantics of iterator use right at the core of the language. That would be a big change.
I was surprised to see loops don't deliver the error, but then I realized just what you said. It may actually not close the iterator, so you really need the return()
in there somewhere.
There's that additional complication that throw()
could actually throw a completely new error. I just drop that one, which is kind of awkward.
I guess this would require a whole new error channel in streams. One where the error could be caught and dismissed.
const generator = readUsers()
const stream = new ReadableStream({
async catch (error, controller) {
// generator throws in typical case, erroring the stream
const result = await generator.throw(error)
// generator catch block returned, close the stream
if (result.done) await controller.close()
// generator handled the error and resumed, keep the stream open
else return
}
})