redux-saga: Inconsistent error handling
TL;DR
- Can’t catch exceptions fired by
put
. - Unhandled exceptions are not always being caught by a custom global handler (if specified).
- If a custom global handler is specified, the default console logging (by the saga middleware) is not being suppressed.
- Handling exceptions of a spawned task is not considered as handling by the saga middleware.
Related issues:
- #250 Handling uncaught exceptions
- #374 Question: handling uncaught exceptions - spawn vs fork
- #441 Weird (incorrect?) error handling behaviors
- #626 Contradiction in docs whether
put
is blocking
The problem
We have a shopping cart on our website, and the ability to store it locally and restore afterwards at some point.
During a restore we can encounter some errors, e.g.:
- storage is not available
- restored data is broken (and can’t be rendered)
We want to be able to catch and handle any of these cases (here and actually in other sagas in general):
- at least report the error to a backend like NewRelic
- recover from the error, don’t break the whole app (don’t abort the root saga)
Initial code
Here is our code (slightly simplified, but very close to reality).
function fetchCart(restaurantId) {
// Fetches the cart and returns a Promise, which returns cart items.
}
function reportError(e) {
// Logs errors, sends them to some backend like NewRelic, etc.
}
const sagaMiddleware = createSagaMiddleware({
onError: reportError
});
function* restoreCartHandler(action) {
const { restaurantId } = action.payload;
try {
const cartItems = yield call(fetchCart, restaurantId);
yield put(cartRestored(cartItems));
} catch (e) {
// If something goes wrong, we clear the cart and allow user to start from the beginning.
reportError(e);
yield put(clearCart(restaurantId));
}
}
function* restoreCartSaga(context) {
yield takeEvery(RESTORE_REQUESTED, restoreCartHandler);
}
If the data is broken and put
throws (e.g. from a reducer or from a component’s render method),
we can’t catch it in restoreCartHandler
. put
is async (non-blocking), and it always returns
ASAP without exceptions. Exceptions bubble up to the redux-saga middleware and
are finally console.logged as uncaught at restoreCartHandler
.
Still, we can catch other exceptions: e.g. syntactic and from the fetchCart
(e.g. storage
is not available).
If the data is broken, this is what I see in the browser console:
So here are two problems:
- We can’t catch exceptions triggered by put and handle the case in the
restoreCartHandler
. - Moreover, this unhandled exception is not being caught by the global handler.
Solution 1: wrapped fork
and spawn
Based on #250 and
#374 I tried to implement my own
takeEvery
which could catch exceptions from put
effects and report them.
function* forkSafely(saga, ...args) {
const task = yield fork(saga, ...args);
task.done.catch(e => {
console.log('Exception caught inside forkSafely', e);
});
return task;
}
function* spawnSafely(saga, ...args) {
const task = yield spawn(saga, ...args);
task.done.catch(e => {
console.log('Exception caught inside spawnSafely', e);
});
return task;
}
function forkWrapped(saga, ...args) {
return call(forkSafely, saga, ...args);
}
function spawnWrapped(saga, ...args) {
return call(spawnSafely, saga, ...args);
}
export function* takeEverySafely(pattern, saga, ...args) {
while (true) {
const action = yield take(pattern);
yield forkWrapped(saga, ...args.concat(action));
// Or, instead:
// yield spawnWrapped(saga, ...args.concat(action));
}
}
export function* restoreCartSaga(context) {
yield takeEverySafely(RESTORE_REQUESTED, restoreCartHandler, context);
}
The result using wrapped fork
:
Neither Exception caught inside forkSafely
, nor reporting the error from the global handler.
The result using wrapped spawn
is the same.
So, in both cases (fork and spawn):
- We can’t catch an exception triggered by put and handle it in the handler attached to the
task.done.catch
- Again, this unhandled exception is not being caught by the global handler.
Modified case: exception not in put
It was interesting to check what is the error handling behaviour if exceptions are fired
somewhere else in the handler, not by the put
.
function* restoreCartHandler(context, action) {
// Let's just throw here and leave it unhandled
throw Error('Sorry man');
}
const sagaMiddleware = createSagaMiddleware({
onError: e => {
// And let's modify this one just for clarity on screen shots
console.log('Global saga error handler', e);
}
});
I was expecting that fork
and spawn
return a task in that case, and task.done.catch
will work ok.
Let’s see the result of using fork
:
task.done.catch
handler is not being called, noException caught inside forkSafely
.- The exception bubbles up to the root saga, the root saga is being aborted.
- The global handler is being called, but the default console logging is not being suppressed.
Let’s see the result of using spawn
:
task.done.catch
handler is being called, but after all the other handlers.- The exception doesn’t bubble up to the root saga.
- Despite of the fact that the exception is handled by the handler attached to the spawned task:
- the task is still considered uncaught (
uncaught at restoreCartHandler
) - the global handler is still being called
- the task is still considered uncaught (
There were big hopes to the “spawn-and-catch” approach, but currently it doesn’t seem to be very usable due to its implementation. “Fork-and-catch” doesn’t work at all.
Solution 2: put().done.catch
The idea was to check whether I can asynchronously catch and handle errors of the put
itself,
since it seemed to behave similar to fork
and spawn
.
function* restoreCartHandler(action) {
const { restaurantId } = action.payload;
try {
const cartItems = yield call(fetchCart, restaurantId);
const task = yield put(cartRestored(cartItems));
task.done.catch(e => {
console.log('Exception caught inside put', e);
});
} catch (e) {
// If something goes wrong, we clear the cart and allow user to start from the beginning.
reportError(e);
yield put(clearCart(restaurantId));
}
}
function* restoreCartSaga(context) {
yield takeEvery(RESTORE_REQUESTED, restoreCartHandler);
}
The result is:
put
returnsundefined
, which leads to an exception inside the try block of therestoreCartHandler
- this exception is being caught and reported
put
is being executed as usual, with the “uncaught at…” result- so the original exception is not being caught, even by the global handler
Solution 3: put.sync
We used put
everywhere in the codebase as we believed that it’s blocking.
Since it happens that it was not true either from the beginning, or from some version
(we’ve upgraded from 0.10
to 0.12
), I decided to check put.sync
.
function* restoreCartHandler(action) {
const { restaurantId } = action.payload;
try {
const cartItems = yield call(fetchCart, restaurantId);
yield put.sync(cartRestored(cartItems));
} catch (e) {
// If something goes wrong, we clear the cart and allow user to start from the beginning.
reportError(e);
yield put(clearCart(restaurantId));
}
}
function* restoreCartSaga(context) {
yield takeEvery(RESTORE_REQUESTED, restoreCartHandler);
}
The result is finally as expected:
put.sync
throws exception inside the try block.- It is being caught, reported and handled properly.
- The exception doesn’t bubble up to our global handler, and is not being logged by the middleware.
To sum up
Seems that the only viable solution for us - to use put.sync
for the time being.
Still, I suppose error handling could have been more consistent:
- if
put
is non-blocking, it should return a task - exceptions fired by
put
should be able to be caught - what’s caught locally (both sync and async way), if there’s no re-throw:
- should be treated as handled
- should not bubble up to the root
- if a custom global handler is specified, no default logging should be done
This is what basically seems right from our perspective. Would be great to hear your opinion.
About this issue
- Original URL
- State: closed
- Created 8 years ago
- Reactions: 20
- Comments: 27 (17 by maintainers)
Commits related to this issue
- Do not swallow errors when executing put effect (closes #632) — committed to redux-saga/redux-saga by Andarist 7 years ago
- Update docs (#37) * Added unpkg field in package.json * Fixed test There needs to be `true` passed to the generator's next function, otherwise the while loop in the main generator will be termi... — committed to neighborhood999/redux-saga by neighborhood999 7 years ago
- Update docs (#38) * add test case * pass option.middleware to proc and hot replace * recursively call runEffect if middleware returns something different * fix typo * effect middleware AP... — committed to neighborhood999/redux-saga by neighborhood999 7 years ago
- Update docs (#39) * add test case * pass option.middleware to proc and hot replace * recursively call runEffect if middleware returns something different * fix typo * effect middleware AP... — committed to neighborhood999/redux-saga by neighborhood999 7 years ago
I’ve decided to restore old behaviour - we shouldn’t swallow errors by default. Gonna do it in upcoming v1 release. I’m really sorry that u had to wait for so long for this, but I guess I had to mature to this decision.
any action point for that? is it official recommendation to use
put.resolve
if we want to handle such errors in saga?@Andarist Yeah! but that was the other me 😃.
More seriously, in #567 the idea was that ‘put is non blocking so we shouldn’t bubble errors’. But then, as @banderror noted we should return a task in this case, otherwise there is no way to catch errors from it. But again if we return a task, then errors will fail the entire tree just as fork/spawn do actually.
I think we shouldn’t treat put as a non blocking effect (ie I think I was wrong on #567). In the actual code, put will wait (even sync) for the return value from dispatch, in fact seems like put.sync is a misleading name, because all the difference vs put alone is that the former resolve promise responses. maybe put.resolve could be a better name
My comment was about its general behaviour, not the error handling itself. I strongly believe that
put
was always resolved (giving back running context to your generator) once it finally managed todispatch
your action. By finally I mean that it could not be immediate due to internal scheduling, but for sure within the same synchronous call stack.You are right though that silent error swallowing was added, because at the moment it felt weird that error in your reducer or component can crash your saga. However after this got into the library the questions were raised about this behaviour and we are not 100% sure which behaviour is desired and which one should we keep. We will for sure settle our minds on it before geting v1 to you, which im pushing forward the best I can due to limited time I can spend on the code now.
In addition - there are not really that many blocking effects in the library, most are non-blocking. More about this can be found here:
It waits, it just swallows the error for now, which
put.resolve
does not do. I dont if I can explain it better than the code itself, let me show you:The only non-obvious thing here might be
cb
- its just a function which propagates the result properly through the code, so it can be injected back to your generator.cb(result)
- means this is a proper result and it will get injected byit.next(result)
latercb(error, true)
- second param being true means this is an error, so it will later inject it later withit.throw(error)
If you have any further questions, please ask, I’ll try to explain things to the best of my capabilities 😃
put.resolve()
means your stack trace will be meaningful.PUT is a sneaky little guy. You’re giving a chance for the rest of the redux environment to react (lulz). This means anything from connected components to reducers to selectors can bring down the house.
With
put.resolve
the inner stack trace shows you exactly who caused it. Withput
, you have to wait until next tick and you go hunting for answers.My vote (wait, are we even voting?) is for
put.resolve
to be the defaultput
behaviour. Breaking changes be damned. I thought it always worked like that. 😃What I’m doing now is:
I know the naming is probably going to cause ya’ll ulcers, but dispatching is exactly how I think of the behaviour. Synchronous dispatching.
They have been updated - https://github.com/redux-saga/redux-saga/pull/1922/files . Didn’t have time to do a release with those changes though. I’ll try to do it this week.
They always bubble in v1-beta. The behaviour described in the docs is referring to v0.16
+1 halp
For now I would say yes. It for sure needs to be established before reaching 1.0, which I would like to reach eventually in a ‘near’ future. But for now it seems reasonable to stick to
put.resolve
here.Error boundaries are a quite a topic, regarding the fact that even React itself is struggling with it and potentially Fiber might be the answer. I need to dig into what they do there and whats the reasoning behind the thing they do. It could have been helpful for us here.
I think it might be more important for sagas to keep their tree intact in such a case that
put
throws under the hood.However it potentially might lead to the inconsistent state. Do you have any example in mind when it matters? And when failing would be really a better option for us to make by default?