-
Notifications
You must be signed in to change notification settings - Fork 3k
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
feat(onUnhandledError): configuration point added for unhandled errors #5681
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,168 @@ | ||
/** @prettier */ | ||
|
||
import { config } from '../src/internal/config'; | ||
import { expect } from 'chai'; | ||
import { Observable } from 'rxjs'; | ||
|
||
describe('config', () => { | ||
it('should have a Promise property that defaults to nothing', () => { | ||
expect(config).to.have.property('Promise'); | ||
expect(config.Promise).to.be.undefined; | ||
}); | ||
|
||
describe('onUnhandledError', () => { | ||
afterEach(() => { | ||
config.onUnhandledError = null; | ||
}); | ||
|
||
it('should default to null', () => { | ||
expect(config.onUnhandledError).to.be.null; | ||
}); | ||
|
||
it('should call asynchronously if an error is emitted and not handled by the consumer observer', (done) => { | ||
let called = false; | ||
const results: any[] = []; | ||
|
||
config.onUnhandledError = err => { | ||
called = true; | ||
expect(err).to.equal('bad'); | ||
done() | ||
}; | ||
|
||
const source = new Observable<number>(subscriber => { | ||
subscriber.next(1); | ||
subscriber.error('bad'); | ||
}); | ||
|
||
source.subscribe({ | ||
next: value => results.push(value), | ||
}); | ||
expect(called).to.be.false; | ||
expect(results).to.deep.equal([1]); | ||
}); | ||
|
||
it('should call asynchronously if an error is emitted and not handled by the consumer next callback', (done) => { | ||
let called = false; | ||
const results: any[] = []; | ||
|
||
config.onUnhandledError = err => { | ||
called = true; | ||
expect(err).to.equal('bad'); | ||
done() | ||
}; | ||
|
||
const source = new Observable<number>(subscriber => { | ||
subscriber.next(1); | ||
subscriber.error('bad'); | ||
}); | ||
|
||
source.subscribe(value => results.push(value)); | ||
expect(called).to.be.false; | ||
expect(results).to.deep.equal([1]); | ||
}); | ||
|
||
it('should call asynchronously if an error is emitted and not handled by the consumer in the empty case', (done) => { | ||
let called = false; | ||
config.onUnhandledError = err => { | ||
called = true; | ||
expect(err).to.equal('bad'); | ||
done() | ||
}; | ||
|
||
const source = new Observable(subscriber => { | ||
subscriber.error('bad'); | ||
}); | ||
|
||
source.subscribe(); | ||
expect(called).to.be.false; | ||
}); | ||
|
||
it('should call asynchronously if a subscription setup errors after the subscription is closed by an error', (done) => { | ||
let called = false; | ||
config.onUnhandledError = err => { | ||
called = true; | ||
expect(err).to.equal('bad'); | ||
done() | ||
}; | ||
|
||
const source = new Observable(subscriber => { | ||
subscriber.error('handled'); | ||
throw 'bad'; | ||
}); | ||
|
||
let syncSentError: any; | ||
source.subscribe({ | ||
error: err => { | ||
syncSentError = err; | ||
} | ||
}); | ||
|
||
expect(syncSentError).to.equal('handled'); | ||
expect(called).to.be.false; | ||
}); | ||
|
||
it('should call asynchronously if a subscription setup errors after the subscription is closed by a completion', (done) => { | ||
let called = false; | ||
let completed = false; | ||
|
||
config.onUnhandledError = err => { | ||
called = true; | ||
expect(err).to.equal('bad'); | ||
done() | ||
}; | ||
|
||
const source = new Observable(subscriber => { | ||
subscriber.complete(); | ||
throw 'bad'; | ||
}); | ||
|
||
source.subscribe({ | ||
error: () => { | ||
throw 'should not be called'; | ||
}, | ||
complete: () => { | ||
completed = true; | ||
} | ||
}); | ||
|
||
expect(completed).to.be.true; | ||
expect(called).to.be.false; | ||
}); | ||
|
||
/** | ||
* Thie test is added so people know this behavior is _intentional_. It's part of the contract of observables | ||
* and, while I'm not sure I like it, it might start surfacing untold numbers of errors, and break | ||
* node applications if we suddenly changed this to start throwing errors on other jobs for instances | ||
* where users accidentally called `subscriber.error` twice. Likewise, would we report an error | ||
* for two calls of `complete`? This is really something a build-time tool like a linter should | ||
* capture. Not a run time error reporting event. | ||
*/ | ||
Comment on lines
+132
to
+139
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
AFAICT, the contract stipulates how the communication between the observable and the observer is to occur. It says nothing about how errors ought to be treated when then communication channel between the observable and the observer has been closed. IMO, this behaviour really ought to be configurable. Intentionally swallowing errors when a reporting channel is known to be closed is less than ideal. If a config option were there, I'd definitely be enabling it.
FWIW, for all but the most trivial scenarios, this is beyond the capabilities of static analysis. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have some ideas about how this could be addressed. I'll create an issue or a PR after I've given it more thought. In case it's unclear, I think the changes in this PR are all good, but I think we need to do more here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The only important thing is that we don't break anyone. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure. The change that I'm contemplating is independent of the |
||
it('should not be called if two errors are sent to the subscriber', (done) => { | ||
let called = false; | ||
config.onUnhandledError = () => { | ||
called = true; | ||
}; | ||
|
||
const source = new Observable(subscriber => { | ||
subscriber.error('handled'); | ||
subscriber.error('swallowed'); | ||
}); | ||
|
||
let syncSentError: any; | ||
source.subscribe({ | ||
error: err => { | ||
syncSentError = err; | ||
} | ||
}); | ||
|
||
expect(syncSentError).to.equal('handled'); | ||
// This timeout would be scheduled _after_ any error timeout that might be scheduled | ||
// (But we're not scheduling that), so this is just an artificial delay to make sure the | ||
// behavior sticks. | ||
setTimeout(() => { | ||
expect(called).to.be.false; | ||
done(); | ||
}); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import { Observer } from './types'; | ||
import { config } from './config'; | ||
import { reportUnhandledError } from './util/reportUnhandledError'; | ||
import { noop } from './util/noop'; | ||
|
||
/** | ||
* The observer used as a stub for subscriptions where the user did not | ||
* pass any arguments to `subscribe`. Comes with the default error handling | ||
* behavior. | ||
*/ | ||
export const EMPTY_OBSERVER: Readonly<Observer<any>> = { | ||
closed: true, | ||
next: noop, | ||
error(err: any): void { | ||
if (config.useDeprecatedSynchronousErrorHandling) { | ||
throw err; | ||
} else { | ||
reportUnhandledError(err); | ||
} | ||
}, | ||
complete: noop | ||
}; |
This file was deleted.
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
/** @prettier */ | ||
import { config } from '../config'; | ||
|
||
/** | ||
* Handles an error on another job either with the user-configured {@link onUnhandledError}, | ||
* or by throwing it on that new job so it can be picked up by `window.onerror`, `process.on('error')`, etc. | ||
* | ||
* This should be called whenever there is an error that is out-of-band with the subscription | ||
* or when an error hits a terminal boundary of the subscription and no error handler was provided. | ||
* | ||
* @param err the error to report | ||
*/ | ||
export function reportUnhandledError(err: any) { | ||
setTimeout(() => { | ||
const { onUnhandledError } = config; | ||
if (onUnhandledError) { | ||
// Execute the user-configured error handler. | ||
onUnhandledError(err); | ||
} else { | ||
// Throw so it is picked up by the runtime's uncaught error mechanism. | ||
throw err; | ||
} | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick: the description is weird; the consumer's
next
callback cannot handle the error