Not sure if I need to care about this, but it seems that with nested Observables (like with zen-observable), it's hard to manage how errors are handled, you know what I mean? How do you organize the error handlers so that errors get handled:
- In the "correct order" as you would conceptually expect.
- Handlers are not executed when they don't need to be (i.e. when some other handler has already handled it).
Take for example, this code, expanded upon with logging in this codesandbox:
import Observable from 'zen-observable'
let controller = new AbortController
function observeRequest(): Observable<{ type: 'req', count: number }> {
return new Observable(observer => {
let count = 0
const interval = setInterval(() => {
count++
observer.next({ type: 'req', count })
if (count > 20) {
clearInterval(interval)
} else if (count === 10) {
controller.abort('Aborted req')
}
}, 10)
controller.signal?.addEventListener('abort', (e: Event) => {
clearInterval(interval)
console.log('Caught in req', controller.signal.reason)
observer.error(new Error(controller.signal.reason))
})
return () => clearInterval(interval)
})
}
function observeInterval(): Observable<{ type: 'int' | 'req', count: number }> {
return new Observable(observer => {
let count = 0
const interval = setInterval(() => {
count++
observer.next({ type: 'int', count })
observeRequest().forEach(data => {
observer.next(data)
}).catch(e => {
console.log('Caught in req sub', controller.signal.reason)
observer.error(e)
})
if (count > 200) {
clearInterval(interval)
} else if (count === 150) {
controller.abort('Aborted int')
}
}, 1000)
controller.signal?.addEventListener('abort', (e: Event) => {
clearInterval(interval)
console.log('Caught in int', controller.signal.reason)
observer.error(new Error(controller.signal.reason))
})
return () => clearInterval(interval)
})
}
async function observerMany() {
try {
await observeInterval().forEach(({ type, count }) => {
console.log(type, count, (new Date).toISOString())
})
} catch (e) {
console.log(e.message)
}
return true
}
observerMany().then(done => {
console.log(done)
})
Here's what you see:
That is incorrect in some ways. It says:
Caught in int: Aborted req (should be 3rd)
Caught in req: Aborted req (should be 1st)
Caught in req sub: Aborted req (should be 2nd)
error: Aborted req (should be 4th)
Instead, it should say more like:
Caught in req: Aborted req (should be 1st)
Caught in req sub: Aborted req (should be 2nd)
Caught in int: Aborted req (should be 3rd)
error: Aborted req (should be 4th)
...since it was thrown in the nested req observable, nested inside int.
I can obviously figure out a way to order the event handlers, but it is tedious, brittle, and not nicely encapsulated if I just brute force a solution like that. So I would like to know if there is a best-practice way of dealing with this to make it process in the conceptually correct way.
If you have a complex tree of nested functions, where you pass in the signal from the top-level call to this function-call tree, what is the preferred way of managing the signal.addEventListener('abort') and observer.error(error) calls? What I have "works", but the order is unexpected (though if you read the code carefully it is expected based on the code order). I was wondering though if there was a straightforward way of adding/removing the signal abort event handlers in such a way as to give rise to the "correct order" you would conceptually expect (as seen in 1st, 2nd, 3rd, 4th in bold in the logs).
For example, what if the error was thrown in the outer scope instead of the inner scope, like in this codesandbox? You see:
You'll notice:
- In the 1st set of logs, it said Aborted req. (req is inner scope)
- In the 2nd set of logs, it said Aborted int. (int is outer scope)
So ultimately it seems correct, yet the order of events is wonky and hard to trace. In the 2nd set of logs (Aborted int), controller.abort() is called from within the outer scope, while the inner scope has an event listener for it. The inner scope (req) thus:
- Processes the error (unnecessarily).
- Calls "Caught in req" twice for some reason, not sure yet.
What is the proper way of handling this so it is easy to reason about, and doesn't do unnecessary work?
The second example should show more like:
Caught in int. Aborted int.
error: Aborted int.
Instead of this:
Caught in int. Aborted int.
Caught in req. Aborted int.
Caught in req sub. Aborted int.
error: Aborted int.
Those req handlers are unnecessary, right? Or am I misinterpreting this? Not 100% sure how this should work, but all I know now is it's confusing and as my function-call tree gets more complex, it's going to be hard to reason about the output of the logs. (I don't have a logging system like this in my production code, but about 5 layers deep of AbortSignal handling within observables, currently).
Basically, what I'm thinking is:
- If I handle the error in the outer scope, then the inner scope should not handle it at all. I can't seem to find a nice way to do that yet.
- If I handle the error in the inner scope, it should bubble up to the outer scopes. Like traditional errors.
- There should be no duplicate errors.
Note: In this setInterval code the events are interwoven slightly. In my real code, there is more sequence to the events (make HTTP request, handle progress event, hand load/error event, process the result in steps, etc..). So perhaps it will be easier in the production case when the events are less interwoven. But still I run into the problem like this:
top()
async function top() {
signal.addEventListener('abort', ...)
await topStartWork() // do work here....
// remove signal handlers so nested one can handle it temporarily.
signal.removeEventListener('abort', ...)
await middle() // call into nested middle-level function
// ...add it back
signal.addEventListener('abort', ...)
// several other `middle`-level functions called here too, etc..
// but for simplicity's sake, just showing 1.
await topEndWork()
signal.removeEventListener('abort', ...)
}
async function middle() {
signal.addEventListener('abort', ...)
await middleStartWork() // do work here....
// remove signal handlers so nested one can handle it temporarily, like above.
signal.removeEventListener('abort', ...)
await bottom() // call into nested bottom-level function
signal.addEventListener('abort', ...)
// more bottom calls...
await middleEndWork()
signal.removeEventListener('abort', ...)
}
async function bottom() {
signal.addEventListener('abort', ...)
await bottomWork() // do work here....
signal.removeEventListener('abort', ...)
}
Having to wrap/unwrap the signal handler for every nested function call makes the output feel more correct, but at the same time it is very verbose. So I'm not sure what the best approach is yet.