Skip to content

Interruptions - Signals

Halt work intentionally with success!, skip!, fail!, or throw!. Each signals a clear intent and can carry a reason and metadata.

Internally these methods throw a CMDx::Signal that Runtime catches around work, breaking out of the current call stack the moment they fire — nothing after them runs.

Note

success! is the third halt method; it produces a complete/success result with a custom reason and metadata. See Annotating a Successful Result.

Skipping

Use skip! when the task doesn't need to run. It's a controlled no-op, not an error.

Important

Skipped tasks are considered "ok" outcomes (result.ok? #=> true). execute! does not raise on a skip — only on a failure.

class ProcessInventory < CMDx::Task
  def work
    # Without a reason
    skip! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name)

    # With a reason
    skip!("Warehouse closed") unless Time.now.hour.between?(8, 18)

    inventory = Inventory.find(context.inventory_id)

    if inventory.already_counted?
      skip!("Inventory already counted today")
    else
      inventory.count!
    end
  end
end

result = ProcessInventory.execute(inventory_id: 456)

# Executed
result.status #=> "skipped"

# Without a reason
result.reason #=> nil

# With a reason
result.reason #=> "Warehouse closed"

Failing

Use fail! when the task can't complete successfully. It signals controlled, intentional failure:

class ProcessRefund < CMDx::Task
  def work
    # Without a reason
    fail! if Array(ENV["DISABLED_TASKS"]).include?(self.class.name)

    refund = Refund.find(context.refund_id)

    # With a reason
    if refund.expired?
      fail!("Refund period has expired")
    elsif !refund.amount.positive?
      fail!("Refund amount must be positive")
    else
      refund.process!
    end
  end
end

result = ProcessRefund.execute(refund_id: 789)

# Executed
result.status #=> "failed"

# Without a reason
result.reason #=> nil

# With a reason
result.reason #=> "Refund period has expired"

Note

result.reason is exactly what you passed (or nil). The localized cmdx.reasons.unspecified fallback only appears on Fault#message when execute! raises with no reason.

Metadata Enrichment

Enrich halt calls with metadata for better debugging and error handling. Keyword args passed to success! / skip! / fail! / throw! are merged into Task#metadata first, then the resulting hash is attached to the thrown Signal — so middlewares that pre-populated task.metadata (e.g. a request id) show up on the same result without the caller having to forward them.

class ProcessRenewal < CMDx::Task
  def work
    license = License.find(context.license_id)

    if license.already_renewed?
      # Without metadata
      skip!("License already renewed")
    end

    unless license.renewal_eligible?
      # With metadata
      fail!(
        "License not eligible for renewal",
        error_code: "LICENSE.NOT_ELIGIBLE",
        retry_after: Time.current + 30.days
      )
    end

    process_renewal
  end
end

result = ProcessRenewal.execute(license_id: 567)

# Without metadata
result.metadata #=> {}

# With metadata
result.metadata #=> {
                #     error_code: "LICENSE.NOT_ELIGIBLE",
                #     retry_after: <Time 30 days from now>
                #   }

Short-Circuit Behavior

Halt methods always throw — they never return. The first one to fire ends work immediately, so any subsequent halt calls are unreachable:

class ProcessOrder < CMDx::Task
  def work
    fail!("Out of stock") if out_of_stock?
    fail!("Insufficient funds") if insufficient_funds?
    # If both conditions are true, only the first fail! ever runs.
  end
end

Important

Halt methods only work inside work (and anything it calls). Throwing from rollback, callbacks, or middlewares raises UncaughtThrowError; on a frozen task (post-teardown) they raise FrozenError.

State Transitions

Halt methods trigger specific state and status transitions:

Method State Status Outcome
success! complete success ok? = true, ko? = false
skip! interrupted skipped ok? = true, ko? = true
fail! interrupted failed ok? = false, ko? = true
throw!(failed) interrupted failed (mirrors upstream) ok? = false, ko? = true
result = ProcessRenewal.execute(license_id: 567)

# State information
result.state        #=> "interrupted"
result.status       #=> "skipped" or "failed"
result.interrupted? #=> true
result.complete?    #=> false

# Outcome categorization
result.ok?          #=> true for skipped, false for failed
result.ko?          #=> true for both skipped and failed

Execution Behavior

execute always returns a Result, regardless of whether work finished normally or halted via a signal. execute! only raises on failed?skip! and success! return normally. See Basics - Execution for the full entry-point contract and Interruptions - Faults for the rescued exception hierarchy.

Rethrowing a Peer Failure

Use throw! to halt the current task by echoing another task's failed result. It's a no-op when the other result isn't failed?:

class ReportMonthlyMetrics < CMDx::Task
  def work
    result = BuildReport.execute(context)
    throw!(result) # echoes the peer's state/status/reason; the upstream
                   # result is exposed via `origin`. Metadata on this task's
                   # result is this task's `metadata` (merged with any kwargs
                   # passed to throw!), not a copy of the peer's metadata.

    # ...happy path continues here when result isn't failed
  end
end

The resulting Result carries the upstream failure in result.origin; result.thrown_failure? is true. See Result - Chain Analysis.

Note

throw! accepts a Result or a raw CMDx::Signal. Non-failed inputs are a no-op (caller continues past the throw!). Use it to forward another task's halt state without unwrapping.

Best Practices

Prefer specific reasons — they become result.reason, Fault#message, and end up in logs and telemetry:

# Best: Specific reason + structured metadata
fail!("File format not supported by processor", code: "FORMAT_UNSUPPORTED")

# Good: Clear reason
skip!("Document processing paused for compliance review")

# Avoid: nil reason (Fault#message falls back to the localized cmdx.reasons.unspecified)
skip!
fail!

Manual Errors

Accumulate structured errors on task.errors during work; if any are present when work returns, Runtime throws a failed signal whose reason is the joined messages — no explicit fail! required.

class ProcessRenewal < CMDx::Task
  def work
    document = Document.find(context.document_id)
    errors.add(:document, "is not renewable") if document.nonrenewable?
    document.renew! if errors.empty?
  end
end

See Outcomes - Errors for the full errors API.