Error Handling

Bill Heaton edited this page Jan 20, 2016 · 17 revisions

Problem

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.

Solution

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.

Handing an error Event with a Route action

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>

Handling Failure Using Route error Substates

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

Discussion

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.

Links

You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.