Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to add handlers for raised exceptions #688

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions spec/exception_handler_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,99 @@ describe "Kemal::ExceptionHandler" do
response.body.should eq "Something happened"
end

it "renders custom error for a crystal exception" do
error RuntimeError do
"A RuntimeError has occured"
end

get "/" do
raise RuntimeError.new
end

request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 500
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "A RuntimeError has occured"
end

it "renders custom error for a custom exception" do
error CustomExceptionType do
"A custom exception of CustomExceptionType has occurred"
end

get "/" do
raise CustomExceptionType.new
end

request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 500
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "A custom exception of CustomExceptionType has occurred"
end

it "renders custom error for a custom exception with a specific HTTP status code" do
error CustomExceptionType do |env|
env.response.status_code = 503
"A custom exception of CustomExceptionType has occurred"
end

get "/" do
raise CustomExceptionType.new
end

request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 503
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "A custom exception of CustomExceptionType has occurred"
end

it "renders custom error for a child of a custom exception" do
error CustomExceptionType do |env, error|
"A custom exception of #{error.class} has occurred"
end

get "/" do
raise ChildCustomExceptionType.new
end

request = HTTP::Request.new("GET", "/")
io = IO::Memory.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
Kemal::ExceptionHandler::INSTANCE.next = Kemal::RouteHandler::INSTANCE
Kemal::ExceptionHandler::INSTANCE.call(context)
response.close
io.rewind
response = HTTP::Client::Response.from_io(io, decompress: false)
response.status_code.should eq 500
response.headers["Content-Type"].should eq "text/html"
response.body.should eq "A custom exception of ChildCustomExceptionType has occurred"
end

it "overrides the content type for filters" do
before_get do |env|
env.response.content_type = "application/json"
Expand Down
6 changes: 6 additions & 0 deletions spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class AnotherContextStorageType
@name = "kemal-context"
end

class CustomExceptionType < Exception
end

class ChildCustomExceptionType < CustomExceptionType
end

add_context_storage_type(TestContextStorageType)
add_context_storage_type(AnotherContextStorageType)

Expand Down
6 changes: 5 additions & 1 deletion src/kemal/config.cr
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module Kemal
HANDLERS = [] of HTTP::Handler
CUSTOM_HANDLERS = [] of Tuple(Nil | Int32, HTTP::Handler)
FILTER_HANDLERS = [] of HTTP::Handler
ERROR_HANDLERS = {} of Int32 => HTTP::Server::Context, Exception -> String
ERROR_HANDLERS = {} of (Int32 | Exception.class) => HTTP::Server::Context, Exception -> String
Comment on lines 14 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: I'm wondering if it makes sense to mash up Int32 and Exception.class keys in a single hash.
Might be better to have two separate collections. They're used completely independent of each other. There's no need to expose both under the same name Kemal.error_handlers.
Maybe we could introduce a new name Kemal.exception_handlers for this feature?


{% if flag?(:without_openssl) %}
@ssl : Bool?
Expand Down Expand Up @@ -96,6 +96,10 @@ module Kemal
ERROR_HANDLERS[status_code] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s }
end

def add_error_handler(exception : Exception.class, &handler : HTTP::Server::Context, Exception -> _)
ERROR_HANDLERS[exception] = ->(context : HTTP::Server::Context, error : Exception) { handler.call(context, error).to_s }
end

def extra_options(&@extra_options : OptionParser ->)
end

Expand Down
5 changes: 5 additions & 0 deletions src/kemal/dsl.cr
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ def error(status_code : Int32, &block : HTTP::Server::Context, Exception -> _)
Kemal.config.add_error_handler status_code, &block
end

# Defines an error handler to be called when the given exception is raised
def error(exception : Exception.class, &block : HTTP::Server::Context, Exception -> _)
Kemal.config.add_error_handler exception, &block
end

# All the helper methods available are:
# - before_all, before_get, before_post, before_put, before_patch, before_delete, before_options
# - after_all, after_get, after_post, after_put, after_patch, after_delete, after_options
Expand Down
32 changes: 32 additions & 0 deletions src/kemal/exception_handler.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,44 @@ module Kemal
rescue ex : Kemal::Exceptions::CustomException
call_exception_with_status_code(context, ex, context.response.status_code)
rescue ex : Exception
# Use error handler defined for the current exception if it exists
return call_exception_with_exception(context, ex, 500) if Kemal.config.error_handlers.has_key?(ex.class)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line is added above the log function in order for the specs to pass.

A logger is needed for #log to be able to successfully run which the tests in exception_handler_spec.cr doesn't appear to have defined.

This shouldn't be a problem in the real world case as a logger would always exist afaik.

Please let me know if this should be rectified in some other way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach won't work with inheritance.

Copy link
Author

@syeopite syeopite Sep 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by inheritance in this case?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling .has_key? will check for the exception class only.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose it should be something like Kemal.config.error_handlers.any?{ |key, _| ex.class <= key } in order to match subclasses of key as well.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does identify whether the exception inherits from a known exception but shouldn't this also take into account the order of inheritance? For example

class GrandParentException < Exception
end

class ParentException < GrandParentException
end

class ChildException < ParentException
end

error Exception do
  "Generic" 
end

error GrandParentException do
  "Grandparent exception"
end

error ParentException do
  "Parent exception"
end

get "/" do
  raise ChildException.new()
end

I'll expect that the raised error in the above logic be caught by the handler for ParentException.

Is there any way of identify the order of inheritance at runtime?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, you could sort classes by their inheritance relationship.

I don't think that would be a good idea though. Just following the order of declaration is probably best. rescue works the same way: the first matching branch wins, even if a later type would be a closer fit.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. In that case I think this should achieve the desired behavior

Comment on lines +14 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: This special case with has_key? seems unnecessary because this is already covered with ex.class <= key.
It should be safe to remove this partial duplication.

Suggested change
# Use error handler defined for the current exception if it exists
return call_exception_with_exception(context, ex, 500) if Kemal.config.error_handlers.has_key?(ex.class)


# Use error handler for an ancestor of the current exception if it exists
Kemal.config.error_handlers.each_key do |key|
if key.is_a? Exception.class && ex.class <= key
return call_exception_with_exception(context, ex, 500, override_handler_used: key)
end
end

log("Exception: #{ex.inspect_with_backtrace}")
# Else use generic 500 handler if defined
return call_exception_with_status_code(context, ex, 500) if Kemal.config.error_handlers.has_key?(500)
verbosity = Kemal.config.env == "production" ? false : true
render_500(context, ex, verbosity)
end

# Calls the defined error handler for the given exception if it exists
#
# By default it tries to use the handler that is defined for the given exception. However, another
# handler can be used via the `override_handler_used` parameter.
private def call_exception_with_exception(context : HTTP::Server::Context, exception : Exception, status_code : Int32 = 500, override_handler_used : (Exception.class)? = nil)
return if context.response.closed?

if !override_handler_used
handler_to_use = exception.class
else
handler_to_use = override_handler_used
end

if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(handler_to_use)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance: The combination of h.has_key?(key) and later h[key] is inefficient because it requires two hash lookups for the same key. Instead, you could directly query the value with h[key]? and use that as a condition. A nil return value would indicate the key does not exist.
Even better, you could already retrieve the handler while matching the exception (use each instead of each_key and you get the value for free) and pass it directly to this method. That removes two unnecessary hash lookups.

context.response.content_type = "text/html" unless context.response.headers.has_key?("Content-Type")
context.response.status_code = status_code
context.response.print Kemal.config.error_handlers[handler_to_use].call(context, exception)
context
end
end

private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32)
return if context.response.closed?
if !Kemal.config.error_handlers.empty? && Kemal.config.error_handlers.has_key?(status_code)
Expand Down