Skip to content

Add configurable custom exception handlers for unhandled errors #389

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

Open
wants to merge 1 commit into
base: main
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
71 changes: 68 additions & 3 deletions lib/async/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def initialize(message = "Cannot create child task within a task that has finish
super
end
end

# @deprecated With no replacement.
def self.yield
Fiber.scheduler.transfer
Expand All @@ -73,7 +73,33 @@ def self.run(scheduler, *arguments, **options, &block)
task.run(*arguments)
end
end


@@unhandled_exception_handler = nil

# Set the global handler for unhandled exceptions in tasks.
#
# This configured handler is deprioritized below the instance-level handler set by `Async::Task#unhandled_exception_handler`.
#
# This allows you to customize how Async deals with unhandled exceptions,
# such as logging them to a different destination, adding custom metadata,
# or sending them to an error monitoring service.
#
# @example Setting a custom exception handler
# Async::Task.unhandled_exception_handler { |task, exception|
# MyLogger.error("Async task failed: #{exception.message}",
# task_id: task.object_id,
# backtrace: exception.backtrace)
# }
#
# @example Resetting to default behavior
# Async::Task.unhandled_exception_handler(nil)
#
# @param [Proc, nil] block The handler proc to be called with task and exception
# @return [Proc, nil] The current handler
def self.unhandled_exception_handler(guard = nil, &block)
@@unhandled_exception_handler = block || guard
end

# Create a new task.
# @parameter reactor [Reactor] the reactor this task will run within.
# @parameter parent [Task] the parent task.
Expand Down Expand Up @@ -125,6 +151,35 @@ def annotation
super
end
end

# Set the task instance-level handler for unhandled exceptions in tasks.
#
# This configured handler is prioritized over the global handler set by `Async::Task.unhandled_exception_handler`.
#
# This allows you to customize how Async deals with unhandled exceptions,
# such as logging them to a different destination, adding custom metadata,
# or sending them to an error monitoring service.
#
# @example Setting a custom exception handler
# Async do |task|
# task.unhandled_exception_handler { |task, exception|
# MyLogger.error("Async task failed: #{exception.message}",
# task_id: task.object_id,
# backtrace: exception.backtrace)
# }
# end
#
# @example Resetting to default behavior
# Async do |task|
# ...
# task.unhandled_exception_handler(nil)
# end
#
# @param [Proc, nil] block The handler proc to be called with task and exception
# @return [Proc, nil] The current handler
def unhandled_exception_handler(guard = nil, &block)
@unhandled_exception_handler = block || guard
end

# @returns [String] A description of the task and it's current status.
def to_s
Expand Down Expand Up @@ -201,7 +256,17 @@ def run(*arguments)
rescue => error
# I'm not completely happy with this overhead, but the alternative is to not log anything which makes debugging extremely difficult. Maybe we can introduce a debug wrapper which adds extra logging.
if @finished.nil?
warn(self, "Task may have ended with unhandled exception.", exception: error)
begin
if @unhandled_exception_handler
@unhandled_exception_handler.call(self, error)
elsif @@unhandled_exception_handler
@@unhandled_exception_handler.call(self, error)
else
warn(self, "Task may have ended with unhandled exception.", exception: error)
end
rescue => e
warn(self, "Exception occurred in unhandled exception handler.", exception: e)
end
end

raise
Expand Down
118 changes: 117 additions & 1 deletion test/async/task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
require "async/clock"
require "async/queue"

require "console"

require "sus/fixtures/console"

require "sus/fixtures/time/quantum"
Expand Down Expand Up @@ -935,7 +937,121 @@ def sleep_forever
message: be == "Task may have ended with unhandled exception."
)
end


it "does class-level customized error handling if a task fails without being waited on" do
failed_task = nil

# set a class-level customized error handler
Async::Task.unhandled_exception_handler { |task, error|
Console.warn(task, "class-level customized error handler", exception: error)
}

reactor.async do |task|
task.async do |task|
failed_task = task
raise "boom"
end
end

reactor.run

expect_console.to have_logged(
severity: be == :warn,
subject: be_equal(failed_task),
message: be == "class-level customized error handler"
)

# reset the class-level customized error handler
Async::Task.unhandled_exception_handler(nil)

reactor.async do |task|
task.async do |task|
failed_task = task
raise "boom"
end
end
reactor.run

expect_console.to have_logged(
severity: be == :warn,
subject: be_equal(failed_task),
message: be == "Task may have ended with unhandled exception."
)
end

it "does task instance-level customized error handling if a task fails without being waited on" do
failed_task = nil

# set a class-level customized error handler, but this should be ignored due to the priority
Async::Task.unhandled_exception_handler { |task, error|
Console.warn(task, "class-level customized error handler", exception: error)
}

reactor.async do |task|
task.async do |task|
# set a task instance-level customized error handler; this should be respected
task.unhandled_exception_handler { |task, error|
Console.warn(task, "instance-level customized error handler", exception: error)
}
failed_task = task
raise "boom"
end
end

reactor.run

expect_console.to have_logged(
severity: be == :warn,
subject: be_equal(failed_task),
message: be == "instance-level customized error handler"
)

# reset the customized error handlers
Async::Task.unhandled_exception_handler(nil)

reactor.async do |task|
task.async do |task|
# set a task instance-level customized error handler; this should be respected
task.unhandled_exception_handler { |task, error|
Console.warn(task, "instance-level customized error handler", exception: error)
}
task.unhandled_exception_handler(nil) # reset
failed_task = task
raise "boom"
end
end

reactor.run

expect_console.to have_logged(
severity: be == :warn,
subject: be_equal(failed_task),
message: be == "Task may have ended with unhandled exception."
)
end

it "catches the exception from the customized unhandled exception handler" do
failed_task = nil

reactor.async do |task|
task.async do |task|
task.unhandled_exception_handler { |task, error|
raise "exception in unhandled exception handler"
}
failed_task = task
raise "boom"
end
end

reactor.run

expect_console.to have_logged(
severity: be == :warn,
subject: be_equal(failed_task),
message: be == "Exception occurred in unhandled exception handler."
)
end

it "does not log a warning if a task fails and is waited on" do
failed_task = nil

Expand Down