In a previous post I walked you through building and consuming a GraphQL API in Ruby. That was the happy path. In this post we’ll generate and handle some errors.
Errors in a GraphQL API
GraphQL APIs can fail in three ways.
- A client-side error, eg. a query parsing error based on well-known schema.
- A server-side error, eg. a failure to create a database record behind the scenes.
- A transport error, eg. a server failing to respond with a 200 status to a
POST
query to the API.
Client-Side Errors
Client-side errors include parser and validation errors that don’t require a roundtrip to the server. Parsing an invalid query with any client library based on graphql-ruby will raise a GraphQL::ParseError
, see @d42b6238 for an example.
Server-Side Errors
For server-side implementations the graphql-ruby doc on errors gives a few options.
Returning Errors Inside Resolve
You can return a GraphQL::ExecutionError
inside a resolve
function, see @027d75cf for an example.
resolve ->(_object, _inputs, _ctx) {
GraphQL::ExecutionError.new('This has not been implemented yet.')
}
The response contains both a nil
value in data
and an errors
field with an error at the executionError
path with a message.
Handling Predictable Errors
You can turn typical errors, such as ActiveRecord::RecordNotFound
, ActiveRecord::RecordInvalid
or catch-all StandardError
into a GraphQL::ExecutionError
using a generic rescue object, see @a0b8f58b for an example.
class Rescuable
attr_reader :resolve_func
def initialize(resolve_func)
@resolve_func = resolve_func
end
def call(obj, args, ctx)
resolve_func.call(obj, args, ctx)
rescue ActiveRecord::RecordNotFound => e
nil
rescue ActiveRecord::RecordInvalid => e
error_messages = e.record.errors.full_messages.join("\n")
GraphQL::ExecutionError.new "Validation failed: #{error_messages}."
rescue StandardError => e
GraphQL::ExecutionError.new e.message
end
end
This is used with resolve
as follows.
field :findInvoiceById, InvoiceType do
argument :id, !types.Int
resolve Rescuable.new ->(_object, args, _ctx) {
Invoice.where(id: args[:id]).first
}
end
But for most APIs a catch-all at schema level is much easier than having to wrap each resolve
call into resolve Rescuable.new
, which can be done with the graphql-errors gem. See @9e65eb47.
GraphQL::Errors.configure(Schema) do
rescue_from ActiveRecord::RecordNotFound do
nil
end
rescue_from ActiveRecord::RecordInvalid do |e|
error_messages = e.record.errors.full_messages.join("\n")
GraphQL::ExecutionError.new "Validation failed: #{error_messages}."
end
rescue_from StandardError do |e|
GraphQL::ExecutionError.new e.message
end
end
Transport Errors
Transport errors are straightforward and depend on the GraphQL client and HTTP library used. For example Graphlient’s FaradayAdapter
uses Faraday::Response::RaiseError
, which will turn any non-200 HTTP status code into an exception, see adapters/http/faraday_adapter.rb#24.
Client-Side Errors or Exceptions?
This is often a matter of preference or strong opinions.
I believe that any unexpected behavior outside of a happy path should raise an exception unless it can be explicitly avoided ahead of time. I also prefer to deal with two scenarios rather than three: it worked and it didn’t work vs. it worked, it didn’t work in some predictable way and it didn’t work in some unpredictable way. Practically, this means I want to see a StandardError
when something failed, vs. having to check for an .errors
field in my business logic.
The Github graphql-client library does not raise exceptions except when otherwise for all types of errors and parses server-side errors in potentially problematic ways (see graphql-client#132), leaving you having to both handle exceptions and check for .data.errors
and .errors
.
The graphlient library built on top of graphql-client attempts to make sense out of the many scenarios and always raises exceptions of Graphlient::Errors::Error
variety for server-side problems. It’s simpler to use and requires less if
statements.