Error Handling in Spring for GraphQL

Ion Pascari
Published: August 31, 2022

The Problem

Recently, I wrote some GraphQL endpoints and got a bit blocked when I came to the error handling mechanism. Usually, when writing REST endpoints, you either go for a particular @ExceptionHandler for your controller or you go for the @ControllerAdvice to handle exceptions globally for multiple controllers. Apparently, that is not the case for GraphQL. There is a completely different approach for handling errors.

First, the most important thing that I should mention is that I am using:

implementation("org.springframework.boot:spring-boot-starter-graphql")

And not:

implementation("com.graphql-java-kickstart:graphql-spring-boot-starter:14.0.0")

These are two completely different things, and this should be kept in mind during development and research on different websites. 

So what is the problem? Whenever you run a GraphQL query/mutation and your service/facade is throwing an exception — let’s say a NotFoundException — by default, you’re getting this output for the result:

{
  "errors": [
    {
      "message": "INTERNAL_ERROR for 2ce1d7be-86f2-da5d-bdba-aac45f4a534f",
      "locations": [
        {
          "line": 1,
          "column": 13
        }
      ],
      "path": [
        "deleteCourseById"
      ],
      "extensions": {
        "classification": "INTERNAL_ERROR"
      }
    }
  ],
  "data": {
    "deleteCourseById": null
  }
}

Meh, that is not intuitive at all! We miss the exception message, right? This needs to be fixed. I want to be able to provide the exception message, and in certain scenarios, be able to override the exception message for some exceptions and display it.

My biggest mistake was to google it straightaway instead of going through the documentation first. That led me to a journey of trial and errors as I’ve never seen before, and all of that is because most of the research ecosystem is filled with QA and tutorials for the com.graphql-java-kickstart:graphql-spring-boot-starter library or io.leangen.graphql library, and very little is to be found about Spring for GraphQL. There are lots of valid answers about the error handling either by implementing the GraphQLError or by implementing a custom GraphQLErrorHandler or by enabling some kind of property and so on, but none of them work in Spring for GraphQL, as it is a completely different library.

Epiphany

After trying everything out, let’s see what the documentation states about exception resolution:

  DataFetcherExceptionResolver is an asynchronous contract. For most implementations, it would be sufficient to extend DataFetcherExceptionResolverAdapter and override one of its resolveToSingleError or resolveToMultipleErrors methods that resolve exceptions synchronously.

Wow, how simple is that? Lesson learned. Always check documentation first!

In order to demonstrate the error handling in Spring for GraphQL, let’s configure a mini project about courses and instructors. For this purpose I used Kotlin, but the solution would work in Java as well. For the sake of conciseness lots of classes won’t be shown here, but you can go ahead and take a look at the full source code on GitHub. Here are the DTOs being used:

data class CourseRequest(
    @get:NotBlank(message = "must not be blank") val name: String,
    @get:NotBlank(message = "must not be blank") val category: String,
    val instructor: InstructorRequest
)
data class CourseResponse(
    val id: Int?,
    val name: String,
    val category: String,
    val createdAt: String,
    val updatedAt: String,
    val instructor: InstructorResponse
)
data class InstructorRequest(
    @get:NotBlank(message = "must not be blank") val name: String,
)
data class InstructorResponse(
    val id: Int?,
    val name: String?,
)

And here is their representation in the schema.graphqls:

type CourseResponse {
    id: ID
    name: String
    category: String
    instructor: InstructorResponse
}
input CourseRequest{
    name: String
    category: String
    instructor: InstructorRequest
}
type InstructorResponse {
    id: ID
    name: String
}
input InstructorRequest {
    name: String
}

Now we have our controller:

@Controller
class CourseGraphQLController(val courseFacade: CourseFacade) {
    @QueryMapping
    fun getCourseById(@Argument id: Int): CourseResponse = courseFacade.findById(id)
    @QueryMapping
    fun getAllCourses(): List<CourseResponse> = courseFacade.findAll()
    @SchemaMapping(typeName = "CourseResponse", field = "instructor")
    fun getInstructor(course: CourseResponse): InstructorResponse = course.instructor
    @MutationMapping
    fun deleteCourseById(@Argument id: Int) = courseFacade.deleteById(id)
    @MutationMapping
    fun createCourse(@Valid @Argument request: CourseRequest): CourseResponse = courseFacade.save(request)
}

Just for the sake of mentioning, Spring for GraphQL is merely providing support for GraphQL Java in more opinionated way — an annotation-based approach. So instead of implementing GraphQLQueryResolver/GraphQLMutationResolver, we use @QueryMapping and @MutationMapping alongside with @Argument to resolve the method arguments. Also there is @SchemaMapping (@QueryMapping/@MutationMapping’s parent) which allows a method to act as the DataFetcher for a field from the schema mapping.  

Okay, here is the schema mapping for the queries/mutations:

type Query {
    getAllCourses: [CourseResponse]!
    getCourseById(id: Int): CourseResponse
}
type Mutation {
    deleteCourseById(id: Int): Boolean
    createCourse(request: CourseRequest): CourseResponse
}

In order to get a little context about the errors, here is my generic NotFoundException thrown from the service:

class NotFoundException(clazz: KClass<*>, property: String, propertyValue: String) :
    RuntimeException("${clazz.java.simpleName} with $property equal to [$propertyValue] could not be found!")

So by running the following GraphQL query:

query { getCourseById(id: -999) {
    id
    name
    instructor {
        id
    }
}}

I was expecting to get something like “Course with id equal to [-999] could not be found!” But that was not the case, as we’ve seen at the beginning. 

Solution

Okay, enough talk; time to fix this. Here is the required subclass, according to the documentation:

@Component
class GraphQLExceptionHandler : DataFetcherExceptionResolverAdapter() {
    companion object {
        private val log: Logger = LoggerFactory.getLogger(this::class.java)
    }
    override fun resolveToSingleError(e: Throwable, env: DataFetchingEnvironment): GraphQLError? {
        return when (e) {
            is NotFoundException -> toGraphQLError(e)
            else -> super.resolveToSingleError(e, env)
        }
    }
    private fun toGraphQLError(e: Throwable): GraphQLError? {
        log.warn("Exception while handling request: ${e.message}", e)
        return GraphqlErrorBuilder.newError().message(e.message).errorType(ErrorType.DataFetchingException).build()
    }
}

So we extended the DataFetcherExceptionResolverAdapter and overrode the resolveToSingleError method to treat our exception the correct way. Basically, it is a translation of the NotFoundException to GraphQLError.  Now, if we run our query again:

{
  "errors": [
    {
      "message": "Course with id equal to [-999] could not be found!",
      "locations": [],
      "extensions": {
        "classification": "DataFetchingException"
      }
    }
  ],
  "data": {
    "getCourseById": null
  }
}

Beautiful, isn’t it?

But wait; there is more. This here is a custom exception. What about some built-in exceptions like the ConstraintViolationException, which is thrown when the @Valid is invalidated? As you’ve seen my CourseRequest’s name is annotated with @NotBlank:

data class CourseRequest(
    @get:NotBlank(message = "must not be blank") val name: String,
    @get:NotBlank(message = "must not be blank") val category: String,
    val instructor: InstructorRequest
)

What happens when I try to create a Course with an empty name, like this?

mutation { createCourse(
    request: {
        name: "",
        category: "DEVELOPMENT",
        instructor: {
            name: "Thomas William"
        }
    }) {
    id
    name
}}

Oh God, no… Again, that INTERNAL_ERROR message… 

But no worries — with our GraphQLExceptionHandler in place, it is a matter of adding a new exception to be handled. Also, just for safety, I’ll add the Exception there too, as the times comes new specializations can be added, but by default for untreated exception the exception message always will be shown. So here is our new implementation:

@Component
class GraphQLExceptionHandler : DataFetcherExceptionResolverAdapter() {
    companion object {
        private val log: Logger = LoggerFactory.getLogger(this::class.java)
    }
    override fun resolveToSingleError(e: Throwable, env: DataFetchingEnvironment): GraphQLError? {
        return when (e) {
            is NotFoundException -> toGraphQLError(e)
            is ConstraintViolationException -> handleConstraintViolationException(e)
            is Exception -> toGraphQLError(e)
            else -> super.resolveToSingleError(e, env)
        }
    }
    private fun toGraphQLError(e: Throwable): GraphQLError? {
        log.warn("Exception while handling request: ${e.message}", e)
        return GraphqlErrorBuilder.newError().message(e.message).errorType(ErrorType.DataFetchingException).build()
    }
    private fun handleConstraintViolationException(e: ConstraintViolationException): GraphQLError? {
        val errorMessages = mutableSetOf<String>()
        e.constraintViolations.forEach { errorMessages.add("Field '${it.propertyPath}' ${it.message}, but value was [${it.invalidValue}]") }
        val message = errorMessages.joinToString("\n")
        log.warn("Exception while handling request: $message", e)
        return GraphqlErrorBuilder.newError().message(message).errorType(ErrorType.DataFetchingException).build()
    }
}

As you can see, the NotFoundException/Exception will be simply translated to GraphQLError (yes, at the moment, the logic’s the same and NotFoundException may be removed, but I prefer to keep them separated for future possible changes). ConstraintViolationException is treated separately by constructing a sensible message.

Now, if we run our mutation again, voila!

{
  "errors": [
    {
      "message": "Field 'createCourse.request.name' must not be blank, but value was []",
      "locations": [],
      "extensions": {
        "classification": "DataFetchingException"
      }
    }
  ],
  "data": {
    "createCourse": null
  }

Conclusion

In this article, we discussed error handling in Spring for GraphQL and we looked at the implementation of ErrorHandler that is capable of handling both the custom exception and the built-in exceptions. And we learned an important lesson: Always check the documentation first!

That’s all folks; hope that you liked it. In case you missed it, here is the full project

P.S. Here is an unrelated tip for the Kotlin users who are still trying to implement the GraphQLError and extend the RuntimeException and getting the “Accidental override: The following declarations have the same JVM signature (getMessage()Ljava/lang/String;)”. The dirty workaround is to have it implemented in Java and have a single Java class in a 100% Kotlin project. The elegant workaround is to extend the newly created GraphqlErrorException specifically created for Kotlin users, as per the opened GitHub issue

Source: dzone.com