Is it possible to retrieve a Oban.PerformError from a failed job?

I’m trying to create a system where I will expose to end users the option to run tasks in the background. I want these users to be able to manage their tasks transparently.

This means that I want to use Oban to do that but I don’t want to expose anything related to oban to the end user.

So, for example, if the task finishes successfully, I can just use recorded: true and expose the recorded value to the end user instead of the full job structure.

This works fine for jobs that finished correctly, but not for jobs that failed to run, in these cases, what I tried to do was have an struct defined in a module, instantiate it in the job and return as {:error, struct}.

The issue with this is that Oban will convert this into an Oban.PerformError, and then store just the message value in the errors array of the job table, something like this:

errors: [
    %{
      "at" => "2026-02-04T23:18:39.876954Z",
      "attempt" => 1,
      "error" => "** (Oban.PerformError) Core.Workers.ApiCaller failed with {:error, %ErrorReply{http_code: 1, error: \"blobs\"}}"
    },
    %{
      "at" => "2026-02-04T23:18:58.044722Z",
      "attempt" => 2,
      "error" => "** (Oban.PerformError) Core.Workers.ApiCaller failed with {:error, %ErrorReply{http_code: 1, error: \"blobs\"}}"
    },
 ...
]

I don’t want to show this to the end user since it “exposes” oban, I just want the struct I created as the output.

Is there some way for me to achieve that without having to manage these states in a separated, custom, table?

You can define your own exception at the worker level, or externally if you want to share them between workers. Then you’ll raise that exception instead of letting it fall through to the generic Oban.PerformError exception. That way you can completely control the message:

defmodule MyApp.Worker do
  use Oban.Pro.Worker

  defmodule Error do
    defexception [:message]

    @impl true
    def exception(value) do
      message = "did not get what was expected, got: #{inspect(value)}"

      %__MODULE__{message: message}
    end
  end

  @impl true
  def process(job) do
    with {:error, reason} <- do_something(job.args) do
      raise Error, reason
    end
  end
end

Since you’re in control of the message, you’ll have a predictable format for the error value, and you can show that or parse it out.

4 Likes

Thanks, that works.

I’m not super happy with it since I will still need to extract the data I want from it using a regex which seems fragile, but at least I’m able to do that.

Here is the full solution:

  defmodule Error do
    defexception [:error]

    @impl true
    def exception(error) do
      %__MODULE__{error: error}
    end

    def message(%{error: error}) do
      error |> :erlang.term_to_binary() |> Base.encode64(padding: false)
    end

    def blame(exception, _stacktrace) do
      {exception, []}
    end
  end

Then, when I retrieve the job, I will get something like this in the errors key:

  errors: [
    %{
      "at" => "2026-02-05T16:09:52.916947Z",
      "attempt" => 1,
      "error" => "** (Core.Workers.ApiCaller.Error) g3QAAAADdwVlcnJvcm0AAAAFYmxvYnN3Cl9fc3RydWN0X193EUVsaXhpci5FcnJvclJlcGx5dwlodHRwX2NvZGVhAQ"
    },
    %{
      "at" => "2026-02-05T16:10:10.055109Z",
      "attempt" => 2,
      "error" => "** (Core.Workers.ApiCaller.Error) g3QAAAADdwVlcnJvcm0AAAAFYmxvYnN3Cl9fc3RydWN0X193EUVsaXhpci5FcnJvclJlcGx5dwlodHRwX2NvZGVhAQ"
    },
    %{
      "at" => "2026-02-05T16:10:29.159186Z",
      "attempt" => 3,
      "error" => "** (Core.Workers.ApiCaller.Error) g3QAAAADdwVlcnJvcm0AAAAFYmxvYnN3Cl9fc3RydWN0X193EUVsaXhpci5FcnJvclJlcGx5dwlodHRwX2NvZGVhAQ"
    }
  ]

From this list, I can retrieve the data I want with:

  def handle_error(%{errors: errors} = job) do
    Enum.map(errors, fn %{"error" => error} ->
      %{"base_64" => base_64} = Regex.named_captures(~r/(?<base_64>[A-Za-z0-9+\/=]+)$/, error)

      base_64 |> Base.decode64!(padding: false) |> :erlang.binary_to_term()
    end)
  end

Which will return me the desired structs:

iex(86)> Core.Workers.ApiCaller.handle_error(j)
[
  %ErrorReply{http_code: 1, error: "blobs"},
  %ErrorReply{http_code: 1, error: "blobs"},
  %ErrorReply{http_code: 1, error: "blobs"}
]
1 Like

Going back to this, @sorentwo is there some other way to achieve the same but without having to raise?

For example, I want to use the before_process/1 callback to check if the user has enough credits to run the task.

My current implementation is something like this:

  def before_process(job) do
    %{tenant_id: tenant_id} = job.args

    case Payments.try_to_use_credits(1, tenant: tenant_id) do
      {:ok, _} ->
        {:ok, job}

      {:error, :not_enough_credits} ->
        raise ErrorContainer, Error.new!(type: :not_enough_credits)

      {:error, :max_retries_exceeded} ->
        raise ErrorContainer, Error.new!(type: :failed_to_fetch_credits)

      {:error, %Ash.Error.Invalid{} = error} ->
        raise error
    end
  end

This kinda works but it is far from ideal. The first issue is that this will generate an error log in my logger (when I have this code inside the perform function, it doesn’t), so this will quickly pollute my logs with useless information.

The second issue is that this only works if I want to actually fail the job, but in this case, the correct approach is to cancel the job instead.

But, if I change this code to do something like {:cancel, Error.new!(type: :not_enough_credits)} instead of a raise, then I will get back to having Oban.PerformError errors (like this `** (Oban.PerformError) Core.Workers.ApiCaller failed with {:cancel, %Core.Workers.ApiCaller.Error{type: :not_enough_credits}}`) which are hard to parse/extract the information.

It would be super great if I could replace Oban.PerformError exception with a custom one per job or queue. Or maybe an easier solution would be to allow the user to customize the exception/1 and message/1 functions from Oban.PerformError, something like:

  use Oban.Pro.Worker,
    error: [exception_fun: &exception/1, message_fun: &message/1]

  use Oban.Pro.Worker,
    error_exception: MyCustomException