Documented Alternative Error Handling (#634)

* Added alternative error handling

* Fixed book tests and some sentences

* Apply suggestions from code review

Co-Authored-By: Christian Legnitto <LegNeato@users.noreply.github.com>

* Fixed book examples

Co-authored-by: Christian Legnitto <LegNeato@users.noreply.github.com>
This commit is contained in:
Jonas Meurer 2020-04-28 18:41:19 +02:00 committed by GitHub
parent 79c265f027
commit 845331033e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -1,5 +1,14 @@
# Error handling
Error handling in GraphQL can be done in multiple ways. In the
following two different error handling models are discussed: field
results and GraphQL schema backed errors. Each approach has its
advantages. Choosing the right error handling method depends on the
requirements of the application--investigating both approaches is
beneficial.
## Field Results
Rust
[provides](https://doc.rust-lang.org/book/second-edition/ch09-00-error-handling.html)
two ways of dealing with errors: `Result<T, E>` for recoverable errors and
@ -115,7 +124,7 @@ following would be returned:
}
```
## Structured errors
### Structured errors
Sometimes it is desirable to return additional structured error information
to clients. This can be accomplished by implementing [`IntoFieldError`](https://docs.rs/juniper/latest/juniper/trait.IntoFieldError.html):
@ -169,3 +178,279 @@ The specified structured error information is included in the [`extensions`](htt
]
}
```
## Errors Backed by GraphQL's Schema
Rust's model of errors can be adapted for GraphQL. Rust's panic is
similar to a `FieldError`--the whole query is aborted and nothing can
be extracted (except for error related information).
Not all errors require this strict handling. Recoverable or partial errors can be put
into the GraphQL schema so the client can intelligently handle them.
To implement this approach, all errors must be partitioned into two error classes:
* Critical errors that cannot be fixed by the user (e.g. a database error).
* Recoverable errors that can be fixed by the user (e.g. invalid input data).
Critical errors are returned from resolvers as `FieldErrors` (from the previous section). Non-critical errors are part of the GraphQL schema and can be handled gracefully by clients. Similar to Rust, GraphQL allows similar error models with unions (see Unions).
### Example Input Validation (simple)
In this example, basic input validation is implemented with GraphQL
types. Strings are used to identify the problematic field name. Errors
for a particular field are also returned as a string. In this example
the string contains a server-side localized error message. However, it is also
possible to return a unique string identifier and have the client present a localized string to the user.
```rust
#[derive(juniper::GraphQLObject)]
pub struct Item {
name: String,
quantity: i32,
}
#[derive(juniper::GraphQLObject)]
pub struct ValidationError {
field: String,
message: String,
}
#[derive(juniper::GraphQLObject)]
pub struct ValidationErrors {
errors: Vec<ValidationError>,
}
#[derive(juniper::GraphQLUnion)]
pub enum GraphQLResult {
Ok(Item),
Err(ValidationErrors),
}
pub struct Mutation;
#[juniper::graphql_object]
impl Mutation {
fn addItem(&self, name: String, quantity: i32) -> GraphQLResult {
let mut errors = Vec::new();
if !(10 <= name.len() && name.len() <= 100) {
errors.push(ValidationError {
field: "name".to_string(),
message: "between 10 and 100".to_string()
});
}
if !(1 <= quantity && quantity <= 10) {
errors.push(ValidationError {
field: "quantity".to_string(),
message: "between 1 and 10".to_string()
});
}
if errors.is_empty() {
GraphQLResult::Ok(Item { name, quantity })
} else {
GraphQLResult::Err(ValidationErrors { errors })
}
}
}
# fn main() {}
```
Each function may have a different return type and depending on the input
parameters a new result type is required. For example, adding a user
requires a new result type which contains the variant `Ok(User)`
instead of `Ok(Item)`.
The client can send a mutation request and handle the
resulting errors as shown in the following example:
```graphql
{
mutation {
addItem(name: "", quantity: 0) {
... on Item {
name
}
... on ValidationErrors {
errors {
field
message
}
}
}
}
}
```
A useful side effect of this approach is to have partially successful
queries or mutations. If one resolver fails, the results of the
successful resolvers are not discarded.
### Example Input Validation (complex)
Instead of using strings to propagate errors, it is possible to use
GraphQL's type system to describe the errors more precisely.
For each fallible input variable a field in a GraphQL object is created. The
field is set if the validation for that particular field fails. You will likely want some kind of code generation to reduce repetition as the number of types required is significantly larger than
before. Each resolver function has a custom `ValidationResult` which
contains only fields provided by the function.
```rust
#[derive(juniper::GraphQLObject)]
pub struct Item {
name: String,
quantity: i32,
}
#[derive(juniper::GraphQLObject)]
pub struct ValidationError {
name: Option<String>,
quantity: Option<String>,
}
#[derive(juniper::GraphQLUnion)]
pub enum GraphQLResult {
Ok(Item),
Err(ValidationError),
}
pub struct Mutation;
#[juniper::graphql_object]
impl Mutation {
fn addItem(&self, name: String, quantity: i32) -> GraphQLResult {
let mut error = ValidationError {
name: None,
quantity: None,
};
if !(10 <= name.len() && name.len() <= 100) {
error.name = Some("between 10 and 100".to_string());
}
if !(1 <= quantity && quantity <= 10) {
error.quantity = Some("between 1 and 10".to_string());
}
if error.name.is_none() && error.quantity.is_none() {
GraphQLResult::Ok(Item { name, quantity })
} else {
GraphQLResult::Err(error)
}
}
}
# fn main() {}
```
```graphql
{
mutation {
addItem {
... on Item {
name
}
... on ValidationErrorsItem {
name
quantity
}
}
}
}
```
Expected errors are handled directly inside the query. Additionally, all
non-critical errors are known in advance by both the server and the client.
### Example Input Validation (complex with critical error)
Our examples so far have only included non-critical errors. Providing
errors inside the GraphQL schema still allows you to return unexpected critical
errors when they occur.
In the following example, a theoretical database could fail
and would generate errors. Since it is not common for the database to
fail, the corresponding error is returned as a critical error:
```rust
# #[macro_use] extern crate juniper;
#[derive(juniper::GraphQLObject)]
pub struct Item {
name: String,
quantity: i32,
}
#[derive(juniper::GraphQLObject)]
pub struct ValidationErrorItem {
name: Option<String>,
quantity: Option<String>,
}
#[derive(juniper::GraphQLUnion)]
pub enum GraphQLResult {
Ok(Item),
Err(ValidationErrorItem),
}
pub enum ApiError {
Database,
}
impl juniper::IntoFieldError for ApiError {
fn into_field_error(self) -> juniper::FieldError {
match self {
ApiError::Database => juniper::FieldError::new(
"Internal database error",
graphql_value!({
"type": "DATABASE"
}),
),
}
}
}
pub struct Mutation;
#[juniper::graphql_object]
impl Mutation {
fn addItem(&self, name: String, quantity: i32) -> Result<GraphQLResult, ApiError> {
let mut error = ValidationErrorItem {
name: None,
quantity: None,
};
if !(10 <= name.len() && name.len() <= 100) {
error.name = Some("between 10 and 100".to_string());
}
if !(1 <= quantity && quantity <= 10) {
error.quantity = Some("between 1 and 10".to_string());
}
if error.name.is_none() && error.quantity.is_none() {
Ok(GraphQLResult::Ok(Item { name, quantity }))
} else {
Ok(GraphQLResult::Err(error))
}
}
}
# fn main() {}
```
## Additional Material
The [Shopify API](https://shopify.dev/docs/admin-api/graphql/reference)
implements a similar approach. Their API is a good reference to
explore this approach in a real world application.
# Comparison
The first approach discussed above--where every error is a critical error defined by `FieldResult` --is easier to implement. However, the client does not know what errors may occur and must instead infer what happened from the error string. This is brittle and could change over time due to either the client or server changing. Therefore, extensive integration testing between the client and server is required to maintain the implicit contract between the two.
Encoding non-critical errors in the GraphQL schema makes the contract between the client and the server explicit. This allows the client to understand and handle these errors correctly and the server to know when changes are potentially breaking clients. However, encoding this error information into the GraphQL schema requires additional code and up-front definition of non-critical errors.