Infrastructure High severity

Rescuing Exception

Catching the top-level Exception class (or omitting the class entirely in a bare rescue =>), which swallows operating-system signals like SignalException, SystemExit, and Interrupt, preventing graceful shutdown of Puma workers and Sidekiq processes during deploys.

Before / After

Problematic Pattern
class ReportWorker
def call
  generate_report
rescue Exception => e # too broad
  Rails.logger.error(e.message)
  ErrorTracker.notify(e)
  # Swallows SIGTERM, Interrupt, NoMemoryError
  # Sidekiq cannot cleanly stop.
  # Deploys time out trying to kill workers.
end
end
Target Architecture
class ReportWorker
def call
  generate_report
rescue StandardError => e
  # Only catches application-level errors.
  # SIGTERM, Interrupt propagate as expected.
  Rails.logger.error(e.message)
  ErrorTracker.notify(e)
  raise
end
end

# Even better: catch specific expected errors.
rescue Faraday::TimeoutError, ActiveRecord::RecordInvalid => e
# handle the specific failure modes you know
end

Why this hurts

Ruby’s exception hierarchy has Exception at the top with two main descendants: StandardError for application bugs and runtime issues, and a family of system-level exceptions for process control: SignalException (SIGTERM, SIGINT, SIGHUP), SystemExit (from exit, abort), Interrupt (Ctrl-C at a terminal), and resource-level errors like NoMemoryError. Catching Exception catches all of them, including the signals that container orchestrators use to request graceful shutdown.

When Kubernetes wants to terminate a pod, it sends SIGTERM and waits for the process to exit cleanly within the grace period (typically 30 seconds). A rescue Exception in a long-running loop catches SIGTERM, logs it as an error, and resumes the loop. The pod stays up, the grace period expires, and Kubernetes sends SIGKILL, which cannot be caught and kills the process instantly. Any in-flight Sidekiq job loses its state because Sidekiq::Shutdown handlers never ran. Puma’s request drain logic never executes. Systemd reports a failed deployment.

NoMemoryError being swallowed is subtler but worse. When the process runs out of memory, Ruby raises NoMemoryError and the rescue Exception catches it. The error handler tries to log the event or notify Sentry, which requires allocating strings and HTTP requests: all of which would themselves raise NoMemoryError if memory is genuinely exhausted. The process enters a cascade of failed allocations and undefined behavior. The kernel’s OOM killer eventually fires, but the process wasted seconds of CPU on the recovery attempt and corrupted in-memory state in the meantime.

SystemExit from a script that calls exit or abort also gets caught, so a script meant to halt continues executing. Shutdown hooks registered via at_exit still fire, but the code that was supposed to stop the process keeps running on the path following the rescue block. This is especially problematic in migration scripts and data backfills where a deliberate abort (because preconditions failed) quietly resumes.

Get Expert Help

Inheriting a legacy Rails codebase with this problem? Request a Technical Debt Audit.