jessica dussault

Retrying jobs in Rails 8

Date
13 March, 2026
Category
code
Tags
Ruby on Rails

I am working on a project right now where we wait until we confirm a customer's payment has succeeded, then send a request to a 3rd party to start the corresponding service up. We're using Rails' ActiveJob with SolidQueue to manage this request.

A coworker had already written a job to activate the 3rd party service several times and logged the failures. This was my starting point:

class ActivateRecordJob < ApplicationJob
  queue_as :default

  class ActivationError < StandardError; end

  retry_on ActivationError, wait: :polynomially_longer, attempts: 5
  discard_on ActiveJob::DeserializationError

  def perform(record)
    if ActivateInteractor.call(record)
      record.update(status: :published)
    else
      Rails.logger.error("Activation failed for record #{record.id}, attempt #{executions}")
      raise ActivationError
    end
  end
end

When the job is picked up off the queue, the following happens:

  1. It calls the activation interactor which returns true / false for success / failure
  2. If it succeeds, the job is done, hooray
  3. If it fails, it raises ActivationError which then prompts it to retry. It will retry 4 times before giving up

after_discard

If the activation failed, I wanted to add some code that would refund the customer, since the thing they paid for was unavailable.

It took me a little bit to figure out because I'm not terribly familiar with ActiveJob. Most of the examples use the discard_on hook in concert with the after_discard hook and I wasn't sure if that was required to consider a job "discarded." However, after a little trial and error, I discovered that if the retries fail enough times, the job is considered discarded. If the job passes, it is NOT discarded. That's all that I needed!

I had to make my record variable in the example above an instance variable so that the value would be available in the after_discard hook, which then looked like this:

after_discard do |job, exception|
  @record.update(status: :canceled)
  CancelInteractor.call(@record)
end

Testing

As always, testing remains my Achilles heel. Fortunately, it ended up not being too difficult to figure out how to write unit tests that would confirm that the job was ultimately running the code in after_discard on failure.

Success

describe "when activation succeeds" do
  it "publishes the record" do
    ThirdParty::Service.stub(:activate, true, [record.service_id]) do
      ActivateRecordJob.perform_now(record)
    end

    record.reload
    assert record.published?
  end
end

Failure

describe "when activation fails repeatedly" do
  it "cancels the record" do
    ActivateInteractor.stub(:call, false) do
      CancelInteractor.stub(:call, true) do
        perform_enqueued_jobs do
          assert_raises ActivateRecordJob::ActivationError do
            ActivateRecordJob.perform_later(record)
          end
        end
      end
    end

    record.reload
    assert record.canceled?
  end
end