So far we’ve looked at working with APIs that return success responses. In this chapter, we will look at how Ember Data handles errors out of the box and the different adapter and serializer methods we can override to handle custom error responses. Let’s dive in!
Validation Errors
Responses with an HTTP status code of 422 (Unprocessable Entity) are considered invalid errors, or in other words,
validation errors. Validation error responses follow the JSON:API specification regardless of which serializer is used. A
JSON:API error response looks like the following:
{
"errors": [
{
"id": "{unique identifier for this particular occurrence}",
"links": {
"about": "{link that leads to further details about this problem}"
},
"status": "{HTTP status code}",
"code": "{application-specific error code}",
"title": "{summary of the problem}",
"detail": "{explanation specific to this occurrence of the problem}",
"source": {
"pointer": "{a JSON Pointer to the associated entity in the request document}",
"parameter": "{a string indicating which URI query parameter caused the error}"
},
"meta": {}
}
]
}
The response must contain a root key errors that is an array of error objects. Each error object can have any of the properties listed earlier. To find out more about each property of an error object, visit the JSON:API error documentation.1 JSON:API states that an error object may have those properties, but Ember Data only requires a subset of them.
Let’s say we want to create a new
contact record and handle the scenario when there is an error validating the
name attribute. The error response needs two properties:
detail and
source
:
{
"errors": [
{
"detail": "Name must be at least 2 characters.",
"source": {
"pointer": "data/attributes/name"
}
}
]
}
The value of source.pointer in an error object is a JSON Pointer2 to a specific attribute, which in this case is the name attribute. A JSON Pointer is a string using a syntax that is similar to a file path, where it identifies a path to a specific value in a JSON document.
When the adapter sees a 422 response status code, a rejected promise is returned with an instance of
InvalidError to signal that the record failed server-side validation. The
InvalidError instance
is passed
errors from the response payload. These validation errors can then be retrieved from the record with the
errors property. For example, we can render the record’s validation errors for the name attribute as follows:
{{#each model.errors.name as |error|}}
<div class="error">
{{error.message}}
</div>
{{/each}}
We can also access these errors and the instance of
InvalidError
in our catch block:
try {
await contact.save();
} catch (invalidError) {
console.log(invalidError); // instance of InvalidError
console.log(contact.errors); // instance of Errors
console.log(contact.get('errors.name')); // array of error objects for the name attribute
console.log(contact.isValid); // false
}
Controlling the Invalid Status Code
Your first question might be “What if my API returns a status code other than 422?” If we look at the
RESTAdapter source code, we can see the creation of
InvalidError only happens if the status is invalid via the
isInvalid() method
:
isInvalid(status, headers, payload) {
return status === 422;
},
handleResponse(status, headers, payload, requestData) {
if (this.isSuccess(status, headers, payload)) {
return payload;
} else if (this.isInvalid(status, headers, payload)) {
return new InvalidError(payload.errors);
}
// ...
return new AdapterError(errors, detailedMessage);
}
We can override the public
isInvalid() method in our adapter to account for a status code that isn’t 422. For example, maybe our API responds with 400 instead of 422:
app/adapters/application.js
import RESTAdapter from '@ember-data/adapter/rest';
export default class ApplicationAdapter extends RESTAdapter {
namespace = 'api';
isInvalid(status) {
return status === 400;
}
}
Controlling Error Response Payloads
Your second question might be “What if my error response payload doesn’t follow JSON:API?”
Let’s say our error response looks like the following instead:
{
"errors": {
"name": "Name must be at least 2 characters."
}
}
If the payload contains an errors property that isn’t an array, you will get the following error message in the console:
The reason this error message mentions AdapterError instead of InvalidError is because InvalidError extends from the AdapterError class.
Let’s look at the implementation of
handleResponse()
again:
handleResponse(status, headers, payload, requestData) {
if (this.isSuccess(status, headers, payload)) {
return payload;
} else if (this.isInvalid(status, headers, payload)) {
return new InvalidError(payload.errors);
}
// ...
return new AdapterError(errors, detailedMessage);
}
As you can see, the
errors property of the payload is passed directly to
InvalidError, and if it isn’t an array, the preceding error message will be thrown. To handle a custom error payload that either doesn’t have
errors as an array or doesn’t have an
errors property at all, we can override
handleResponse()
in the adapter:
app/adapters/application.js
import RESTAdapter from 'ember-data/adapters/rest';
export default class ApplicationAdapter extends RESTAdapter {
namespace = 'api';
handleResponse(status, headers, payload, requestData) {
if (this.isInvalid(status)) {
payload.errors = Object.keys(payload.errors).map((attribute) => {
return {
detail: payload.errors[attribute],
source: {
pointer: 'data/attributes/${attribute}'
}
};
});
}
return super.handleResponse(status, headers, payload, requestData);
}
}
In the preceding code, we are manipulating our custom error payload to be JSON:API compliant if the status is invalid. Then, the original handleResponse() is called so that the InvalidError object is created.
Now your third question might be “What if the payload does have an
errors property that is an array, but it isn’t JSON:API compliant?” Let’s say our error
payload looked like this instead:
{
"errors": [
{
"attribute": "name",
"messages": {
"size": "Name must be at least 2 characters.",
"alpha": "Name must be entirely alphabetic characters."
}
}
]
}
Each of the validation rules and corresponding messages are stored under
errors/messages. To handle this error payload, we can override
handleResponse() as before, or we can override the
extractErrors() method on the serializer. The
extractErrors() method is used to extract model errors when a call to
save() on the model fails with an
InvalidError. The
extractErrors() method
receives the payload as one of its arguments and expects the return value to look like the following:
{
name: [
'Name must be at least 2 characters.',
'Name must be entirely alphabetic characters.'
];
}
Here is an implementation of overriding
extractErrors()
to normalize the
errors array in the response:
app/serializers/contact.js
import ApplicationSerializer from './application';
export default class ContactSerializer extends ApplicationSerializer {
extractErrors(store, typeClass, payload, id) {
let extractedErrors = {};
payload.errors.forEach((error) => {
extractedErrors[error.attribute] = Object.keys(error.messages).map(
(rule) => {
return error.messages[rule];
}
);
});
return extractedErrors;
}
}
So your fourth question might be “Why bother using extractErrors() if I could just use handleResponse() for all cases?” The answer to that goes back to the question “what class is responsible for formatting request and response data?” That’s the serializer. Generally, if I can handle normalizing error responses in the serializer, I prefer that. If normalization isn’t possible in the serializer, then I will handle it in the adapter.
Other Error Types
We learned about the
InvalidError class
that gets instantiated when there is a 422 response. Ember Data also supports a few other error types for common
HTTP status codes (Table
9-1).
Table 9-1Common HTTP Status Codes
Error Class | HTTP Status Code |
---|
InvalidError | 422 |
UnauthorizedError | 401 |
ForbiddenError | 403 |
NotFoundError | 404 |
ConflictError | 409 |
ServerError | 500 |
An UnauthorizedError
will get thrown if the HTTP status code is 401, which indicates that authorization is required and has either failed or not been provided. A ForbiddenError
will get thrown if the HTTP status code is 403, which signals that the authenticated user doesn't have the necessary permissions for the request. A NotFoundError
will get thrown if the HTTP status code is 404, which indicates that the server can't find a resource. A ConflictError
will get thrown if the HTTP status code is 409, which indicates that there is a request conflict with the state of the server. For example, a 409 status could be returned if a user was trying to update a resource with stale data. Lastly, a ServerError
will get thrown if the HTTP status code is 500, which indicates that there was an error on the server.
All of these error classes also extend from the AdapterError
class.
Let’s say our API returns a 500 status code. When this happens, the following will hold true:
import AdapterError, {
ServerError,
TimeoutError
} from '@ember-data/adapter/error';
try {
await contact.save();
} catch (serverError) {
console.log(serverError instanceof AdapterError); // true
console.log(serverError instanceof ServerError); // true
console.log(serverError instanceof TimeoutError); // false
}
As we learned with InvalidError
, the expected error payload needs to have an errors property as an array. This is true for any subclass of AdapterError
.
Let’s say our API responds with a 500 status code and the following payload:
{
"errors": [
{
"status": "500",
"title": "There was an error on the server.",
"detail": "Oh snap! Something went wrong.",
"foo": "bar"
},
{
"bar": "baz"
}
]
}
With this payload, the
errors property on our
ServerError
instance will be this:
[
{
status: '500',
title: 'There was an error on the server.',
detail: 'Oh snap! Something went wrong.',
foo: 'bar'
},
{
bar: 'baz'
}
];
If an
errors key isn’t present in the response, the adapter will create the following for
errors on our
ServerError
instance:
[
{
status: '500',
title: 'The backend responded with an error', // this message is created by Ember Data
detail: '[object Object]' // A string representation of the payload
}
];
If the API returns a string as a payload, such as “FAILED”,
detail will be that string:
[
{
status: '500',
title: 'The backend responded with an error', // this message is created by Ember Data
detail: 'FAILED'
}
];
Personally, I haven’t found the need to normalize error payloads that weren’t validation errors (
InvalidError), but if you need to, you can override
handleResponse()
in the adapter like we did earlier. For example, let’s say the API returned a 500 with the following response:
{
"status": "500",
"title": "There was an error on the server.",
"detail": "Oh snap! Something went wrong.",
"foo": "bar"
}
In this case,
errors on our
ServerError
instance would contain the following:
[
{
status: '500',
title: 'The backend responded with an error', // this message is created by Ember Data
detail: '[object Object]' // A string representation of the payload
}
];
We could normalize this error response in
handleResponse()
as follows:
import RESTAdapter from '@ember-data/adapter/rest';
export default class ApplicationAdapter extends RESTAdapter {
handleResponse(status, headers, payload, requestData) {
if (status === 500) {
let normalizedPayload = {
errors: [payload]
};
return super.handleResponse(
status,
headers,
normalizedPayload,
requestData
);
}
return super.handleResponse(status, headers, payload, requestData);
}
}
Here we are taking the raw payload and putting it in an
errors array in an object. With this change,
errors on our
ServerError
instance would contain the following:
[
{
status: '500',
title: 'There was an error on the server.',
detail: 'Oh snap! Something went wrong.',
foo: 'bar'
}
];
Summary
Handling errors is an important aspect when working with APIs, and Ember Data is flexible enough to handle any error response. In this chapter, we looked at the default error handling in Ember Data and learned about validation errors and other adapter errors. Next up, we’ll learn about different ways of testing our Ember Data customizations.