Errors and Status Codes
Suppose a client asks for a product that does not exist:
{
product(id: 99) {
name
priceCents
}
}
With REST, GET /products/99 gets 404 Not Found. The status code says what kind of error occurred. In GraphQL, errors encountered while resolving fields typically come back with 200 OK. The details appear in the response body, under the errors field.
Here is an example response to the query above:
{
"data": {
"product": null
},
"errors": [
{
"message": "Product 99 not found.",
"path": ["product"],
"extensions": {
"code": "NOT_FOUND"
}
}
]
}
The response has two top-level fields. data is still there, and product is null, which the schema allows because the return type in product(id: ID!): Product has no !. errors is a list of what went wrong. Each error has a message for a person, a path that says which field failed, and a code in extensions that a program can act on. The codes are things like NOT_FOUND, CART_NOT_OPEN, and INSUFFICIENT_STOCK. We define these codes ourselves.
The response above reaches the client with the HTTP status 200 OK. The server ran the query, but the product lookup failed. Here, 200 does not mean that every field was resolved successfully. The client must check the response body for errors.
Modern GraphQL over HTTP Specification
The “200 OK with errors” behavior I described here is one of the most distinctive traits of GraphQL: partial or full application failures typically return a 200 OK status code.
The draft GraphQL over HTTP specification states that if a request cannot be executed (e.g., because of an authorization or validation failure before any resolvers run, or because of a server error), the server returns a 4xx or 5xx code instead of 200.
Data and errors together
One query can partly succeed. Suppose cart 74 has three items, and the merchant deleted one, product 44, after it went in the cart. With REST, the client reads the cart and then each product, and gets 200, 200, and 404 across three requests. With GraphQL there is one request, and it asks for everything:
{
cart(id: 74) {
totalCents
items {
productId
quantity
product {
name
priceCents
}
}
}
}
Here is the response:
{
"data": {
"cart": {
"totalCents": 4800,
"items": [
{
"productId": "42",
"quantity": 1,
"product": { "name": "Blue mug", "priceCents": 1800 }
},
{
"productId": "43",
"quantity": 2,
"product": { "name": "White mug", "priceCents": 1500 }
},
{ "productId": "44", "quantity": 1, "product": null }
]
}
},
"errors": [
{
"message": "Product 44 is no longer available.",
"path": ["cart", "items", 2, "product"],
"extensions": { "code": "PRODUCT_DELETED" }
}
]
}
data holds what the API could produce, in the shape of the query. The path of the error says which field failed: the product of the item at position 2, counting from zero, of the cart’s items.
The third item’s product is null, so the client shows two items and a notice for the third. The client still has the productId of the deleted item, so it can pass that productId to removeCartItem to take the item out of the cart.
Aside
Now I can explain my design decision to make CartItem.product nullable in the previous chapter.
Suppose CartItem.product were declared Product!, like the other fields of CartItem. Then a deleted product could not be null.
When a non-null field fails, the null moves up to the nearest ancestor that is allowed to be null. The item is a non-null entry in Cart.items, and Cart.items is itself non-null, so the null moves past both. The first nullable ancestor is Query.cart, so the whole cart would come back as null.
So one deleted product would leave the client with no cart to display.
Making CartItem.product nullable keeps the failure inside that one field. The cost is that the client has to check for null on a product, which is a small cost.