'Error Handling in a GraphQL API built with Scala and Sangria' post illustration

Error Handling in a GraphQL API built with Scala and Sangria

avatar

If execution of a GraphQL query in your Scala application goes wrong for whatever reasons, Sangria will respond to the client with the message Internal server error, which is hardly helpful.

Default error handling in Scala applications powered by Sangria isn't really practical. But Sangria does provide us with the functionality to handle errors in a way that makes sense. In this article, we look at a few examples of managing exceptions when GraphQL queries can't be parsed or properly executed.

We take a look at these aspects of managing errors with Sangria:

  • Handling errors before a query is executed
  • Handling errors when a query is executed
  • Creating custom error messages
  • Managing violations of GraphQL queries
  • Handling errors from custom QueryReducer

You may want to familiarize yourself with the official error format in GraphQL documentation. Also, check out our Scala application with properly implemented GraphQL error handling in this repository.

Our app uses a typical Scala technology stack — Play Framework, SBT, and Guice. You can download and run the app and then run a few GraphQL mutations and queries to see what errors are produced (we show a few examples further in the article as well). Also, have a look at the application structure below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scala-graphql-api
├── app                    # The Scala application source code
│   ├── controllers        # Contains AppController
│   ├── graphql            # GraphQL schemas and resolvers
│   │   ├── resolvers      # Resolver methods to execute GraphQL queries
│   │   ├── schemas        # Concrete schemas, in particular, PostSchema
│   │   └── GraphQL.scala  # Defines global GraphQL-related objects
│   ├── models             # The application models
│   │   ├── errors         # A folder that contains models of various error classes
│   │   └── Post.scala     # The Post entity model
│   ├── modules            # The modules such as PostModule and DBModule
│   ├── repositories       # Contains the trait PostRepository with its implementation
│   ├── validators         # Contains the trait PostValidator with its implementation
│   └── views              # HTML layouts (a graphiql layout)
├── conf
├── project
├── public
├── test
├── .gitignore
├── .travis.yml
├── build.sbt
└── README.md

As shown in the application structure, we'll be working with the Post entity, which has the following shape:

1
case class Post(id: Option[Long] = None, title: String, content: String)

With the obvious stuff out of the way, you can step into the realm of Sangria and GraphQL error handling.

Defining HTTP status codes in GraphQL applications

Before we discuss how to manage GraphQL errors with Sangria, we want to clarify an essential aspect of how GraphQL-based applications work with HTTP status codes.

We’re able to set HTTP status codes for errors thrown during the GraphQL query validation before the query is even executed. Basically, it means that for a concrete validation error we can specify a concrete HTTP status code to be returned to the client.

However, errors thrown during query execution (read: inside the GraphQL resolver methods) are always returned by the GraphQL server as an HTTP response with the status code 200, unlike in a RESTful API.

In REST, each request is handled separately and so we can specify the status code for each request also separately. But in a GraphQL API, we can't use the same approach because several queries and mutations can be sent in a single client request. Some queries or mutations may result in error; others may not. This is why we have to always return the HTTP status code 200. You'll see an example of this in the section Handling errors when executing GraphQL queries.

Handling errors before executing a GraphQL query

Upon receiving a GraphQL query from the client, Sangria analyzes the request and validates it. Sangria defines several types of errors that can happen before the query is executed:

  • QueryReducingError, a wrapper for errors thrown from QueryReducer (which we discuss later).
  • QueryAnalysisError, a wrapper for errors with the GraphQL query or variables. These errors are made by the client application.
  • ErrorWithResolver, a basic trait for handling QueryAnalysisError and QueryReducingError or any other unexpected errors.

To clarify how they are connected, have a look at the inheritance chain:

1
QueryReducingError <= QueryAnalysisError <= ErrorWithResolver

And the very first code sample that demonstrates the use of Sangria functionality, in particular, the listed error types, is this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def executeQuery(query: String, variables: Option[JsObject] = None, operation: Option[String] = None): Future[Result] = QueryParser.parse(query) match {
 case Success(queryAst: Document) => Executor.execute(
   schema = graphQL.Schema,
   queryAst = queryAst,
   variables = variables.getOrElse(Json.obj()),
 ).map(Ok(_)).recover {
   case error: QueryAnalysisError => BadRequest(error.resolveError)
   case error: ErrorWithResolver => InternalServerError(error.resolveError)
 }
 case Failure(ex) => Future(
   BadRequest(
     JsObject(
       Seq(
         "error" -> JsString("Unable to parse the query.")
       )
     )
   )
 )
}

In this example, if a GraphQL query isn't valid (an error was made on the client), we return the 400 status code (BadRequest). And if some other error is thrown before the query is executed, we return the 500 status code (InternalServerError).

The key aspect of GraphQL error handling in this case is the use of built-in methods from Sangria. Notice how we passed error.resolveError into the BadRequest() and InternalServerError(). The method resolveError() is exposed by the error trait ErrorWithResolver and renders the exception in the format compliant with GraphQL.

As you can see, managing exceptions before executing GraphQL queries it's quite simple with Sangria.

If only errors were made in the incoming GraphQL queries, we'd finish our article right now. But errors can also be produced when the queries are executed, and in the next section we explain how to handle them.

Handling errors when executing GraphQL queries

What happens after a GraphQL query has been successfully validated? Naturally, it gets executed by a respective resolve function, and various errors can be produced at this stage.

Consider a situation when the client application tries to befool our Scala server by sending a post with a title that was already used. We don't want to store two or more posts with the same title so we need Sangria to decline the transaction and return an error.

This is what a basic mutation (defined in app/graphql/schema/PostSchema.scala in our app) to add a new post looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
val Mutations: List[Field[Unit, Unit]] = List(
 Field(
   name = "addPost",
   fieldType = PostType,
   arguments = List(
     Argument("title", StringType),
     Argument("content", StringType)
   ),
   resolve = sangriaContext =>
     postResolver.addPost(
       sangriaContext.args.arg[String]("title"),
       sangriaContext.args.arg[String]("content")
     )
 )
)

resolve() runs the addPost() method defined in the PostResolver class located in app/graphql/resolvers. Here's the addPost() implementation:

1
def addPost(title: String, content: String): Future[Post] = postRepository.create(Post(title = title, content = content))

As you can see, we can't but make everything complicated. PostResolver.addPost() doesn't really do anything other than invoking the method create() implemented in the PostRepositoryImpl class, located in app/repositories next to the PostRepository trait it extends.

Here's the method create() from PostRepositoryImpl (and we still don't see how it's implemented):

1
2
3
override def create(post: Post): Future[Post] = db.run {
  Actions.create(post)
}

It calls Actions.create() (the Actions object is also defined in PostRepositoryImpl). Finally, we reached the actual method that does something useful.

1
2
3
4
5
6
7
8
9
10
def create(post: Post): DBIO[Post] = for {
 maybePost <- if (post.id.isEmpty) DBIO.successful(None) else find(post.id.get)
 _ <- maybePost.fold(DBIO.successful()) {
     _ => DBIO.failed(AlreadyExists(s"Post with id = ${post.id} already exists."))
   }
 postWithSameTitle <- postQuery.filter(_.title === post.title).result
 id <- if (postWithSameTitle.lengthCompare(1) < 0) postQuery returning postQuery.map(_.id) += post else {
     DBIO.failed(AlreadyExists(s"Post with title = '${post.title}' already exists."))
   }
 } yield post.copy(id = Some(id))

In the code snippet above, we filter posts by title and if the application doesn't find a post with the title sent by the client, then a new post is added to the database. However, if a post with the same title already exists, we return an error of type AlreadyExists, which is a case class located in app/models/errors.

Here's the implementation of AlreadyExists:

1
2
3
case class AlreadyExists(msg: String) extends Exception with UserFacingError {
  override def getMessage(): String = msg
}

This class demonstrates one of the key aspects of error handling. AlreadyExists implements the trait UserFacingError provided by Sangria so that our Scala application returns a meaningful message to the client.

The error Post with title = '{title}' already exists. will be sent in the field errors in the created GraphQL response (remember that the status code will still be 200 as we discussed in the section Defining HTTP status codes in GraphQL applications).

Error handling with the UserFacingError trait in a Scala GraphQL application

To show you how exactly the UserFacingError trait is useful, try to remove the with UserFacingError part in app/models/errors/AlreadyExists.scala. Sangria will return a boring message Internal server error instead of our custom message.

Error handling without the UserFacingError trait in a Scala GraphQL application

Advanced error handling with Sangria

Sangria gives us a way to implement our own, more advanced mechanism for handling errors than the use of the UserFacingError trait.

Consider a typical development problem: before your application saves a new post, you may want it to validate the post title length or characters. What ingredients do we need to validate the title? It depends on a concrete implementation. Our validation solution consists of the following elements:

  • The InvalidTitle error model
  • The PostValidator trait with a defined validation method
  • The PostValidatorImpl class that implements the validation method

We first define a model for validation errors. Our case class can be called InvalidTitle (in our application, this class is stored in the app/models/errors folder):

1
case class InvalidTitle(msg: String) extends Exception(msg)

Pay attention that this time our class doesn't implement the trait UserFacingError as did AlreadyExists. We'll use another mechanism provided by Sangria for more advanced error handling.

Let's now create a wrapper method that accepts two parameters: the title and the callback function. We invoke the callback function if the title is valid and return an error otherwise.

This method is implemented in PostValidatorImpl class that you can find in app/validators:

1
2
3
4
5
6
7
class PostValidatorImpl extends PostValidator {
  val titleRegex = "[a-zA-Z0-9- ]{3, 100}"
  
  override def withTitleValidation[T](title: String)(callback: => Future[T]): Future[T] = {
    if (title.matches(titleRegex)) callback else Future.failed(InvalidTitle("The post's title is invalid."))
  }
}

Now we need to go back to the method addPost() in app/graphql/resolvers/PostResolver.scala and just wrap the invoked method postRepository.create() with withTitleValidation().

1
2
3
4
5
def addPost(title: String, content: String): Future[Post] = {
  withTitleValidation(title) {
    postRepository.create(Post(title = title, content = content))
  }
}

But that's not all we must do to handle the error with withTitleValidation() in our Scala app. We need to create an instance of Sangria's class ExceptionHandler and then pass it to the Executor as an optional parameter to handle the errors such as InvalidTitle.

In our application, exceptionHandler is created in app/graphql/GraphQL.scala, but it's recommended that you move it to another place in your project.

1
2
3
4
5
val exceptionHandler = ExceptionHandler(
  onException = {
    case (resultMarshaller, error: InvalidTitle) => HandledException(error.getMessage)
  }
)

Add a finishing stroke: pass the created exceptionHandler to the Executor (it's located in app/controllers/AppController.scala):

1
2
3
4
5
6
Executor.execute(
  schema = graphQL.Schema,
  queryAst = queryAst,
  variables = variables.getOrElse(Json.obj()),
  exceptionHandler = graphQL.exceptionHandler
)

With this implementation, our Scala and GraphQL server will return a meaningful error instead of Internal Server Error (provided that its type is InvalidTitle). Now, if you try to send a mutation with a title that has a forbidden character, you'll see a message similar to this:

Advanced validation error handling with Sangria in a Scala application

The error message "Post's title is invalid." is a bit vague, though. We can improve it by adding more fields to HandleException to return more data to the client.

1
2
3
4
5
6
7
8
val exceptionHandler = ExceptionHandler(
  onException = {
    case (resultMarshaller, error: InvalidTitle) => HandleException(
      error.getMessage,
      Map("validation_rule" -> resultMarshaller.fromString("Allowed characters: a-z, A-Z, 0-9, and -. The length must be 3 up to 100 characters."))
    )
  }
)

Now the response will have an additional field validation_rule which will contain a more advanced description of the error inside the field extensions:

Advanced error message with Sangria in a Scala application

We can also simplify the error format by reducing the level of nesting. Using sane words, we just want to add the field validation_rule on the same level as the message and get rid of extensions. To do that, pass additional boolean arguments addFieldsInError and addFieldsInExtensions with respective values in HandledException:

1
2
3
4
5
6
7
8
9
10
11
12
val exceptionHandler = ExceptionHandler(
 onException = {
   case (resultMarshaller, error: InvalidTitle) => HandledException(
     error.getMessage,
     Map(
       "validation_rule" ->resultMarshaller.fromString("Allowed characters: a-z, A-Z, 0-9, and -. The length must be 3 up to 100 characters.")
     ),
     addFieldsInError = true,
     addFieldsInExtensions = false
   )
 }
)

And this is what the response will look like after the last change:

Advanced error message with Sangria in a Scala application

Sangria also allows us to pass several errors into HandledException using the multiple() method to produce several errors. You can add the code below to the file app/graphql/GraphQL.scala in our Scala application.

1
2
3
4
5
6
HandledException.multiple(
  Vector(
    ("Error #1", Map("errorCode" -> resultMarshaller.fromString("OOPS!!!")), Nil),
    ("Error #2", Map.empty[String, resultMarshaller.Node], Nil),
  )
)

And here's the result:

Multiple error messages with Sangria in a Scala application

With the last change, you can get several errors in the errors array.

Violations handling in Sangria and GraphQL application

Sangria allows us to catch violations such as the validation errors of the incoming query. If the client sends a request with an incorrect mutation name, say addPos instead of addPost registered in the schema, Sangria will handle this error as a standard query analysis error:

We can override this behavior to get more control over how the error is handled. For example, we can extend our exception hanlder defined in app/graphql/GraphQL.scala by adding the onViolation function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val exceptionHandler = ExceptionHandler(
 onException = {
   case (resultMarshaller, error: InvalidTitle) => HandledException(
     error.getMessage,
     Map(
       "validation_rule" ->resultMarshaller.fromString("Allowed characters: a-z, A-Z, 0-9, and -. The length must be 3 up to 100 characters.")
     ),
     addFieldsInError = true,
     addFieldsInExtensions = false
   )
 },
 onViolation = {
   case (resultMarshaller, violation: UndefinedFieldViolation) =>
     HandledException("Field is missing!",
       Map(
         "fieldName"  resultMarshaller.fromString(violation.fieldName),
         "errorCode"  resultMarshaller.fromString("FIELD_MISSING"))
     )
 }
)

With this implementation, another result will be returned to the client:

The error Field Missing in a GraphQL query implemented with Sangria

Handling errors from Sangria's QueryReducer

A GraphQL schema can have circular dependencies to let the client application send infinitely deep queries. But allowing this is a very bad idea because we can easily overload the server. Thankfully, Sangria provides a way to protect our GraphQL server against malicious queries using QueryReducer.

QueryReducer is an object that enables us to configure the parameters for protection against malicious queries. We can pass a list of QueryReducer objects to Executor so that Sangria will analyze the incoming GraphQL query and only then execute it if everything's fine.

Sangria provides two mechanisms to protect against malicious queries. We can limit the query complexity and the query depth. In simple terms, the query complexity means how great the GraphQL query is, and the query depth defines the maximal nesting level in a GraphQL query. You can find more information about these mechanisms in the official Sangria documentation.

To use both these mechanisms in our Scala application, we defined two constants in GraphQL.scala. One constant is called maxQueryDepth and is set to 15. The other constant is maxQueryComplexity and it's set to 1000. Now we need to add a list of QueryReducer objects in Executor and pass the constants in them:

1
2
3
4
5
6
7
8
9
10
Executor.execute(
 schema = graphQL.Schema,
 queryAst = queryAst,
 variables = variables.getOrElse(Json.obj()),
 exceptionHandler = graphQL.exceptionHandler,
 queryReducers = List(
   QueryReducer.rejectMaxDepth[Unit](graphQL.maxQueryDepth),
   QueryReducer.rejectComplexQueries[Unit](graphQL.maxQueryComplexity, (_, _) => TooComplexQueryError)
 )
)

Then, we handle these two errors in the onException function in ExceptionHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val exceptionHandler = ExceptionHandler(
    onException = {
      case (resultMarshaller, error: InvalidTitle) => HandledException(
        error.getMessage,
        Map(
          "validation_rule" -> resultMarshaller.fromString("Allowed characters: a-z, A-Z, 0-9, and -. The length must be 3 up to 100 characters.")
        ),
        addFieldsInError = true,
        addFieldsInExtensions = false
      )
      case (_, error: TooComplexQueryError) => HandledException(error.getMessage)
      case (_, error: MaxQueryDepthReachedError) => HandledException(error.getMessage)
    },
    // ...

Note that QueryReducer.rejectMaxDepth provides a standard error message. QueryReducer.rejectComplexQueries(), however, requires an additional error class. We created a respective model TooComplexQueryError for this kind of errors under app/models/errors:

1
case class TooComplexQueryError(msg: String = "Query is too expensive.") extends Exception(msg)

Now, if we, for example, send a query with depth exceeding what we defined, the client will receive the following error message:

Query depth error with GraphQL and Sangria in a Scala application

For this example, we actually set maxQueryDepth to just 1 for the sake of simplicity. In your real application, you should set depth to, say, 15, just like we did in GraphQL.scala in our Scala app.


That's all the basics you need to know about error handling in Scala and GraphQL API using Sangria.

If you're looking for a developer or considering starting a new project,
we are always ready to help!