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

.throw()ing an async iterator #1284

Open
bojavou opened this issue Jun 11, 2023 · 4 comments
Open

.throw()ing an async iterator #1284

bojavou opened this issue Jun 11, 2023 · 4 comments

Comments

@bojavou
Copy link

bojavou commented Jun 11, 2023

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.

@saschanaz
Copy link
Member

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

@MattiasBuelens
Copy link
Collaborator

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().

@bojavou
Copy link
Author

bojavou commented Jun 20, 2023

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.

@bojavou
Copy link
Author

bojavou commented Jun 20, 2023

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
  }
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

3 participants