-
Notifications
You must be signed in to change notification settings - Fork 19
Error Handling
Handing data persistence operations between your Ember (client) application and API server requires fault tolerance. The app needs to notify users of relevant error responses and how to recover from various API server error responses.
Handling JSON API error responses for HTTP status codes 400, 404, 422, 500, 502, 302, etc. should be first class in the application code.
Ember routes have an error
substate to that is called in response to errors in the
route hooks for fetching data (beforeModel
, model
, afterModel
).
Since actions in Ember routes bubble up, the "Application" route is the top most route which can catch errors and can be utilized to handle server errors and notify users.
Depending oh how you would like to handler an error you can choose either an error
substate or an action to handle the error
event. Using both an action and a
substate will not work.
In this scenario error
substates will not be used, instead the error
action
of an Ember.Route
will be used to respond to the error.
Here is an ApplicationRoute
prototype (class):
/**
@class ApplicationRoute
@extends Ember.Route
*/
export default Ember.Route.extend({
actions: {
error(resp, transition) {
if (!resp || resp && !resp.errors) {
Ember.Logger.error(resp);
return;
}
this.onResourceError(resp);
let codes = resp.errors.reduce(function (errorCodes, error) {
if (errorCodes.indexOf(error.code) === -1) {
errorCodes.push(error.code);
}
return errorCodes;
}, []);
if (codes.indexOf(404) !== -1) {
Ember.Logger.error('404: ' + transition.intent.url);
this.transitionTo('/not-found');
}
}
},
onResourceError(resp) {
this.controllerFor('application').setProperties({
'errors': resp.errors,
'errorMessage': resp.message
});
}
});
Specific routes may also listen for the resourceError
event which is triggered
when catching an error in the updateResource()
method of an adapter. In the
example below you could use this.send
to send the error to that route's error
action handler or via the bubbling behavior to the application route's error action;
or just throw resp;
to re-throw the error.
/**
@class PostRoute
@extends Ember.Route
*/
export default Ember.Route.extend({
initEvents: Ember.on('init', function() {
let service = this.get('store.posts');
if (service) {
service.on('resourceError', this, function (resp) { this.send('error', resp); });
}
})
});
If an error is caught it can be re-thrown, if you want to use either the route's
error
action or the route's error
sub-state (they only seem to work
independently) you can define a route-name-error
route to handle the route's
error stub-state and use the setupController(controller, error)
hook of the
error substate which receives the error as the model. (So, you can setup the
controller for the template to display those errors.)
It can get complicated fast when trying to use a combination of route actions and error substates. When you are not using a substate you need to define a route that you can use instead, e.g. 'not-found'.
Other routes in the application for a resource may have nested structures such as:
Router.map(function() {
this.route('posts', { path: '/posts' }, function () {
this.route('list', { path: '/' });
this.route('detail', { path: '/:post_id' });
this.route('edit', { path: '/:post_id/edit' });
this.route('new');
});
this.route('not-found', { path: '/*path' });
});
The Ember.Route
prototype for PostsRoute
, PostsListRoute
, PostsDetailRoute
,
PostsEditRoute
, and PostsNewRoute
to NOT need to define their own error
action,
instead the ApplicationRoute
can handle errors. This does support throwing errors
by your application code as well.
You can handle the error
action wherever you choose, at the top route a general
purpose handler is ideal for notifications that can be presented in the application
template.
Below is the ApplicationController
prototype that manages the errors
and
errorMessage
properties and also formats server error responses for an
Unprocessable Entity 422 response.
import Ember from 'ember';
const { computed, Controller } = Ember;
/**
@class ApplicationController
@extends Ember.Controller
*/
export default Controller.extend({
/**
@property {Array} errors
*/
errors: null,
/**
@property {String} errorMessage
*/
errorMessage: null,
actions: {
/**
@method [actions.dismissErrorMessage]
*/
dismissErrorMessage() {
this.setProperties({
'errorMessage': null,
'errors': null
});
}
},
/**
@property {String} unprocessableEntities - computed from errors, error code: 100
*/
unprocessableEntities: computed('errors', function() {
let errors = this.get('errors');
let fields;
if (errors && errors.length > 0) {
// See https://github.com/cerebris/jsonapi-resources#error-codes
errors = errors.filterBy('code', 100);
fields = errors.map(function(error) {
let paths = error.source.pointer.split('/');
let attr = paths[paths.length - 1].split('_');
attr = attr.map(function(str) {
return Ember.String.capitalize(str);
});
return attr.join(' ');
});
}
return (!fields) ? '' : 'Invalid fields: ' + fields.join(', ') + '.';
})
});
And in the application template include a condition for displaying the errors:
{{#if errorMessage}}
<button class="error-message u-full-width" {{action 'dismissErrorMessage'}}>
<div class='u-pull-right'>X</div>
{{errorMessage}} - {{unprocessableEntities}}
</button>
{{/if}}
For the 404 Not Found template…
<h1>Page Not Found</h1>
<p>
Please try another Url, there is nothing here :|
</p>
In this scenario error
actions will not be primarily used, instead the error
substate of an Ember.Route
will be used to respond to the errors. The error
action will be used secondarily by sending an error
action, send('error')
,
after a failed update.
The ember-jsonapi-resources addon has a example "test" app, see jr-test which
includes example code for using error
substates to handle various server
responses, i.e. 500, 400, 404, 422.
At the top level, where all error
events bubble to, is the ApplicationRoute
.
An application_error
substate can be added using a combination of both an
application-error
route and application-error
template. If you only display
the error.message
and have no need to use a condition to set a title on the
template, then you could use the template alone (without an error route substate).
The application error template below handles any error thrown by a route that is
not handled by a child route's error
substate.
The app/router.js file will not need any error routes added, they are built-in.
Since the model
is passed to the setupController(controller, error)
hook,
the error.message
property can be rendered to notify the user of the error.
app/templates/application-error.hbs
<h1>Oops, the app is borked…</h1>
<p>{{model.message}}</p>
Alternatively, when a substate is not used to display an error notification,
your application template can display any messages that you set on the
application controller; e.g. errorMessage
and errorDetails
.
app/templates/application.hbs
{{#if errorMessage}}
<button class="error-message" {{action 'dismissErrorMessage'}}>
{{errorMessage}} {{errorDetails}}
</button>
{{/if}}
{{outlet}}
If you want to vary the error notification text of the error substate template,
use the setupController
hook to set a title
property for the template.
In a route (below the application), e.g. the PostRoute
, an error substate can
be used to branch the display of the title to differentiate between a client
and server error like so:
app/templates/post-error.hbs
<h1>{{title}}</h1>
<p>{{model.message}}</p>
The title
attribute of the controller is used in the above template. It is
customized depending on the error code.
app/routes/post-error.js
import Ember from 'ember';
export default Ember.Route.extend({
setupController(controller, error) {
let title = 'Oops, this post is borked…';
let code = error.code || error.get('code');
if (code) {
if (code >= 500) {
title = 'Oops, there was a server error…';
} else if (code === 404) {
title = "Opps, can't find this one…";
}
controller.set('title', title);
}
this._super(controller, error);
}
});
Since the application template may be used for errors that you do not need to
transition to an error
substate, the user will need a way to dismiss. An
action dismissErrorMessage
can be used to clear application error properties.
app/routes/application.js
import Ember from 'ember';
export default Ember.Route.extend({
errorMessage: null,
errorDetails: null,
actions: {
dismissErrorMessage() {
this.controllerFor('application').setProperties({
'errorMessage': null,
'errorDetails': null
});
}
}
});
The application_error
substate will be used to display any 500 errors, or
a 404 error. However, in the case of a specific client error like 400 or 422
that your application should handle without making a transition, conditions will
need to be added to branch the behavior between using error
substates and the
application error
template.
If you use any nested routes, for example admin/edit and admin/create, you can define specific error substates at that level in the route structure, (below the application's default error handing).
app/templates/admin/edit-error.hbs
<h1>{{title}}</h1>
<p>{{model.message}}</p>
Notice the template above is the same as the 'post-error.hbs' template. The post
error
template handles the display of non-admin errors; the "/admin/" directory
is used for editing and creating resources.
The route hierarchy used in this example is below. When using the error
substates
you do not need to add any routes to the router.js file. Error substates are
built into the Ember.js Router.
This is the jr-test route.js file:
app/router.js
import Ember from 'ember';
import config from './config/environment';
const Router = Ember.Router.extend({
location: config.locationType
});
Router.map(function() {
this.route('index', { path: '/' });
this.route('post', { path: '/:post_id' }, function () {
this.route('detail', { path: '/' });
this.route('comments');
});
this.route('admin', function () {
this.route('index');
this.route('create');
this.route('edit', { path: ':edit_id' });
});
});
export default Router;
To assit with testing the error
substates - I set the PostController
of the
backend (API) to respond with an error. This is the repo for the API application:
blog-api using a branch 'ember-jsonapi-resources-testing'. See the commented
code I used to send error responses.
I used the setupController
hook to set a relevant title
for the notification
and used a console warning for a condition that should not be handled by a
transition. In an effort to build a solution for the desired user experience,
it can be helpful to log the error conditions.
app/routes/admin/edit-error.js
import Ember from 'ember';
export default Ember.Route.extend({
setupController(controller, error) {
let title = 'Oops, this post is borked…';
let code = error.code || error.get('code');
if (code) {
if (code >= 500) {
title = 'Oops, there was a server error…';
} else if (code === 404) {
title = "Opps, can't find this one…";
} else if (code === 422) {
Ember.Logger.warn('Not expecting to handle 422 in an error substate');
}
controller.set('title', title);
}
this._super(controller, error);
}
});
The post form component sends an update
action to persist changes via the Post
resource endpoint.
app/templates/admin/edit.hbs
<p><strong>Edit a Blog Post</strong></p>
{{form-post post=model isNew=model.isNew on-edit=(action "update")}}
The action is triggered after the user exists the field, this prevents a flood of updates from every keystroke - caused from binding a model property to an input.
app/components/form-post.js
import Ember from 'ember';
import BufferedProxy from 'ember-buffered-proxy/proxy';
export default Ember.Component.extend({
tagName: 'form',
resource: Ember.computed('post', function() {
return BufferedProxy.create({ content: this.get('post') });
}).readOnly(),
isNew: null,
isEditing: true,
focusOut() {
if (!this.get('isNew')) {
this.get('resource').applyChanges();
this.set('isEditing', false);
let action = this.get('on-edit');
if (typeof action === 'function') {
action(this.get('post'), function callback() {
this.set('isEditing', true);
}.bind(this));
}
}
}
/* … */
});
The admin.edit
route responds to the actions send by the form component. After
the API request is made successfully, the callback
function, sent with the action,
is called. Or, an error may be caught by the failed promise.
In the case of an error, the changes to the model are rolled back and the error
response is handled by the route. It depends on the error code whether or not
a transition will be made to an error
substate. When an error is not thrown by
a route's model hook, then a transition needs to be made explicitly via catch
.
In the example below, it may be a bad user experience to transition away from the admin form the user is editing - due to a client error (such as "Bad Request" 400, or an "Unprocessable Entity" 422). Instead, error properties are set on the application controller which results in a dismissible error notification.
app/routes/admin/edit.js
import Ember from 'ember';
import ApplicationErrorsMixin from 'jr-test/mixins/application-errors';
export default Ember.Route.extend(ApplicationErrorsMixin, {
model(params) {
return this.store.find('posts', params.edit_id);
},
setupController(controller, model) {
this._super(controller, model);
controller.set('isEditing', true);
},
actions: {
update(model, callback) {
return this.store.updateResource('posts', model)
.finally(function() {
if (typeof callback === 'function') {
callback();
}
})
.catch(function(error) {
model.rollback();
this.send('error', error);
}.bind(this));
},
error(error) {
if (error.code === 422 || error.code === 400) {
this.handleApplicationError(error);
} else {
this.intermediateTransitionTo('admin.edit_error', error);
}
}
}
});
So that both the admin/edit and admin/create routes can use the same behavior,
the method handleApplicationError
is defined in a mixin.
This mixin is used to parse the error responses and format error details.
app/mixins/application-errors.js
import Ember from 'ember';
export default Ember.Mixin.create({
handleApplicationError(error) {
let details = this.handleUnprocessableEntities(error);
details = details || this.handleBadRequest(error);
this.controllerFor('application').setProperties({
'errorMessage': error.message,
'errorDetails': details || undefined
});
},
handleBadRequest(error) {
if (error.code !== 400 || !error.errors.length) { return; }
// See https://github.com/cerebris/jsonapi-resources#error-codes
let errors = error.errors.filterBy('code', 105);
errors = errors.mapBy('detail');
return (!errors) ? '' : errors.join(' ');
},
handleUnprocessableEntities(error) {
if (error.code !== 422 || !error.errors.length) { return; }
// See https://github.com/cerebris/jsonapi-resources#error-codes
let errors = error.errors.filterBy('code', 100);
let fields = errors.map(function(error) {
let paths = error.source.pointer.split('/');
let attr = paths[paths.length - 1].split('_');
attr = attr.map(function(str) {
return Ember.String.capitalize(str);
});
return attr.join(' ');
});
return (!fields) ? '' : 'Invalid fields: ' + fields.join(', ') + '.';
}
});
For the admin/create route no error
substate template was needed. For handling
500 errors the parent application error
substate will be used. And, for
handling 400 or 422 responses it makes sense to display the errors "in-context",
without making a transition to a substate. The only reason the admin.edit_error
substate uses the admin/edit/error.hbs
template is to handle a 404 and a 500
response. That is not the case with the admin.create
route; the parent substate,
application_error
will work just fine.
Notice the naming convention used with ember-cli, the substates use a .
period
and _
for the substate name and the templates use /
and -
. So, to transition
to the application error substate use application_error
like so:
this.intermediateTransitionTo('application_error', err)
, or to a nested substate:
this.intermediateTransitionTo('admin.edit_error', err)
.
The ApplicationRoute
error
action handler can be used to catch and handle
various errors and delegate the notification of the errors to the
ApplicationController
and accompanying HTMLBars application template. Or, an
applicaton_error
substate with an applicaton-error
template may handle
route error
events.
Also, you may combine both error
substates and error
action handling strategies
in a creative way my sending the error
event when the error occurs as the result
of another action; instead of by a model hook method, e.g. model
, beforeModel
,
afterModel
, etc.
I favor the using error
substates as the primary strategy for fault tolerance
in an ember application. I also like the fact that the error
action may be
utilized creatively as a secondary strategy.
In the [Ember JSONAPI Resources] addon a ErrorMixin defines error types for:
-
ServerError
- handles 50x -
ClientError
- handles 40x -
FetchError
(default failure) - handles 30x
The error objects thrown by a resource's service
includes the error code.
You can use the code
to determine how to present the error notification, use
the error type, or use the error.name
property. The error.code
the most specific.
Based on the error.code
the route error
action can transition to a custom route
to handle that specific error type. In the first example, a transition is made to
a /not-found
route, that could have used an intermediateTransitionTo
to keep
the URL unchanged.
However, there is a catch when using a route's error
action, by doing so the route
error
substates are not used. The second solution does not use use the
route error
action. Instead, it uses route error
substates with error
templates.
Using the error
substates allows the use of the route-name_error
state and
associated route-name-error
template. The setupController
hook of the *_error
route receives objects: controller
and error
(as it's model
). Using the route
error
substate to define properties for the error template is a good way to
customize the display of the error notifications, depending on the error code.
When the error is not thrown by one of the route model hooks, perhaps by a custom
action
, you can decide how to handle the error. If the error is recoverable
perhaps set properties on the application controller for display by the application
template. Or, if the error is not recoverable perhaps fire the error
event on
the route, e.g. this.send('error', resp);
(One caveat - the current state of using an acceptance test for an error substate is that the test may fail. Any error may cause your test adapter to fail the test. See 12791.)
The ember-jsonapi-resources addon uses custom error objects to make fault tolerance first class in your ember application.