The Itinera bookRust edition

Part 2: Getting started, Chapter 17

Giving up a step early

What you want to do

Some failures can't be mended by trying again: Ned's parcel is heavier than any carrier of the shop collects, and when the bank's network fails during a charge, the rescue step, which mends only an expired card, can't help either. You want the shop to give such a step up at once, before the retry or the rescue the workflow would otherwise run.

The code

The shipping first learns a rule of the shop's business: the shop ships no parcel over 80 kg, the most its backup carrier collects. It fails such an order with the reason over-parcel-limit, before it books anything:

Rust, from the program ch17_giving_up_a_step_early
/// Ships an order through the carrier, with its receipt in the parcel, gives back its tracking
/// number, and says when the parcel has left. A parcel the carrier won't collect may be taken on
/// another attempt. A gift card has nothing to ship, and the shop ships no parcel over 80 kg.
#[derive(Step)]
struct Ship {
    #[input]
    order: String,
    #[input]
    weight: i64,
    #[input]
    receipt: String,
    #[input]
    gift_card: bool,
}

#[step]
impl Ship {
    fn run(
        self,
        #[service] carrier: &dyn Carrier,
        #[contributor] mut contributor: Contributor<'_>,
        #[reporter] mut reporter: StepReporter<'_>,
    ) -> Result<Outcome, Error> {
        if self.gift_card {
            return Self::skip_because("nothing-to-ship");
        }
        if self.weight > 80 {
            return Self::fail("over-parcel-limit");
        }
        let tracking = carrier.book(&self.order, self.weight)?;
        contributor.contribute("tracking", tracking);
        if !carrier.collects(self.weight) {
            return Self::fail("too-heavy");
        }
        reporter.info(format!("the parcel leaves with receipt {}", self.receipt))?;
        Self::succeed()
    }
}

The trait Carrier it asks for is chapter 12's.

Then the shop gets two new step policies. The first, heavy_parcels, defines the hook on step retry. It asks for the reason the shipping's attempt failed with, and gives the shipping up when that reason is over-parcel-limit, since the limit is the same on every attempt.

Rust, from the program ch17_giving_up_a_step_early
/// Gives up the shipping of a parcel over the shop's limit, rather than try it again: the limit
/// is the same on every attempt, whichever carrier comes.
#[derive(Default)]
struct HeavyParcels;

#[step_policy(name = "heavy_parcels")]
impl HeavyParcels {
    #[on_step_retry]
    fn before_retrying(
        &self,
        #[reason] reason: Option<Reason>,
    ) -> Result<Option<FailWorkflow>, Error> {
        if reason.as_ref().map(Reason::code) == Some("over-parcel-limit") {
            return Self::fail_workflow("no-carrier-can-take-it");
        }
        Self::carry_on()
    }
}

The second, outage, defines the hook on step abnormal termination. It asks for nothing, and gives up the step it is attached to whenever an error escapes it.

Rust, from the program ch17_giving_up_a_step_early
/// Gives up a step that ends in an error at once: the bank's network is down, and neither a retry
/// nor a rescue can mend that.
#[derive(Default)]
struct Outage;

#[step_policy(name = "outage")]
impl Outage {
    #[on_step_abnormal_termination]
    fn ended_in_error(&self) -> Result<Option<FailWorkflow>, Error> {
        Self::fail_workflow("payment-network-down")
    }
}

The workflow attaches outage to the charge, beside the audit, and heavy_parcels to the shipping:

Rust, from the program ch17_giving_up_a_step_early
/// The shop's workflow: it charges, repairing a charge that fails and auditing one that succeeds,
/// then ships, and shipping may be tried once more.
#[workflow(name = "orders", reporters = [Log, Desk])]
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit, Outage])]
#[with_step(Ship, name = "ship", retry_budget = 1, policies = [HeavyParcels])]
struct Orders;

The charge and the audit are chapter 15's, the rescue step RenewCard is chapter 14's and the desk is chapter 11's.

The log is chapter 16's, and now also says which policy gave a step up, and why:

Rust, from the program ch17_giving_up_a_step_early
EventBody::StepGivenUp {
    step,
    cause: GiveUpCause::FailWorkflow {
        decided_by, reason, ..
    },
    ..
} => Some(format!(
    "{}: FailWorkflow from {}, {}",
    step.step,
    decided_by.policy,
    reason.code()
)),

The rest is chapter 16's shop. It runs the same five orders, and Cat's again, whose 120 is more than the bank's network can take, as in chapter 12. It prints:

What ch17_giving_up_a_step_early prints, recorded in ch17_giving_up_a_step_early.txt
Ann's order:
log orders A-1 #1 journey_started card, gift_card, order, price, weight
log orders A-1 #2 attempt_started charge, attempt 1
log orders A-1 #3 service_provided charge gets payments from bank, a ch17_giving_up_a_step_early::Bank
the bank charges 40 for order A-1
log orders A-1 #4 step_succeeded charge
log orders A-1 #5 contribution_committed receipt
log orders A-1 #6 hook_called audit: on step success of charge
log orders A-1 #7 contribution_committed audit
log orders A-1 #8 attempt_started ship, attempt 1
log orders A-1 #9 service_provided ship gets carrier from carriers, a ch17_giving_up_a_step_early::MainCarrier
the main carrier books order A-1
log orders A-1 #10 step_info ship: the parcel leaves with receipt R-A-1
log orders A-1 #11 step_succeeded ship
log orders A-1 #12 contribution_committed tracking
log orders A-1 #13 journey_succeeded
the journey succeeded, with receipt R-A-1 and tracking T-A-1
audit: charge took 40, receipt R-A-1
Gil's order:
log orders A-7 #1 journey_started card, gift_card, order, price, weight
log orders A-7 #2 attempt_started charge, attempt 1
log orders A-7 #3 service_provided charge gets payments from bank, a ch17_giving_up_a_step_early::Bank
the bank charges 45 for order A-7
log orders A-7 #4 step_succeeded charge
log orders A-7 #5 contribution_committed receipt
log orders A-7 #6 hook_called audit: on step success of charge
log orders A-7 #7 contribution_committed audit
log orders A-7 #8 attempt_started ship, attempt 1
log orders A-7 #9 service_provided ship gets carrier from carriers, a ch17_giving_up_a_step_early::MainCarrier
the main carrier books order A-7
log orders A-7 #10 step_failed ship: too-heavy
log orders A-7 #11 hook_called heavy_parcels: on step retry of ship
log orders A-7 #12 step_retrying ship
log orders A-7 #13 attempt_started ship, attempt 2
log orders A-7 #14 service_provided ship gets carrier from carriers, a ch17_giving_up_a_step_early::BackupCarrier
the backup carrier books order A-7
log orders A-7 #15 step_info ship: the parcel leaves with receipt R-A-7
log orders A-7 #16 step_succeeded ship
log orders A-7 #17 contribution_committed tracking
log orders A-7 #18 journey_succeeded
the journey succeeded, with receipt R-A-7 and tracking B-A-7
audit: charge took 45, receipt R-A-7
Jo's order:
log orders A-8 #1 journey_started card, gift_card, order, price, weight
log orders A-8 #2 attempt_started charge, attempt 1
log orders A-8 #3 service_provided charge gets payments from bank, a ch17_giving_up_a_step_early::Bank
the bank declines card jo-2024: it has expired
log orders A-8 #4 step_failed charge: card-expired
log orders A-8 #5 step_rescuing charge: failure, rescued by charge_rescue
log orders A-8 #6 attempt_started charge_rescue, attempt 1
the bank says card jo-2024 was replaced by jo-2029
log orders A-8 #7 step_succeeded charge_rescue
log orders A-8 #8 contribution_committed card
log orders A-8 #9 data_overwritten card
log orders A-8 #10 step_rescued charge by charge_rescue
log orders A-8 #11 attempt_started charge, attempt 2
the bank charges 30 for order A-8
log orders A-8 #12 step_succeeded charge
log orders A-8 #13 contribution_committed receipt
log orders A-8 #14 hook_called audit: on step success of charge
log orders A-8 #15 contribution_committed audit
log orders A-8 #16 attempt_started ship, attempt 1
log orders A-8 #17 service_provided ship gets carrier from carriers, a ch17_giving_up_a_step_early::MainCarrier
the main carrier books order A-8
log orders A-8 #18 step_info ship: the parcel leaves with receipt R-A-8
log orders A-8 #19 step_succeeded ship
log orders A-8 #20 contribution_committed tracking
log orders A-8 #21 journey_succeeded
the journey succeeded, with receipt R-A-8 and tracking T-A-8
audit: charge took 30, receipt R-A-8
Kim's order:
log orders A-9 #1 journey_started card, gift_card, order, price, weight
log orders A-9 #2 attempt_started charge, attempt 1
log orders A-9 #3 service_provided charge gets payments from bank, a ch17_giving_up_a_step_early::Bank
the bank charges 25 for order A-9
log orders A-9 #4 step_succeeded charge
log orders A-9 #5 contribution_committed receipt
log orders A-9 #6 contribution_committed code
log orders A-9 #7 hook_called audit: on step success of charge
log orders A-9 #8 contribution_committed audit
log orders A-9 #9 attempt_started ship, attempt 1
log orders A-9 #10 service_provided ship gets carrier from carriers, a ch17_giving_up_a_step_early::MainCarrier
log orders A-9 #11 step_skipped ship: nothing-to-ship
log orders A-9 #12 contributions_discarded ship
log orders A-9 #13 journey_succeeded
the journey succeeded, with receipt R-A-9 and tracking none
audit: charge took 25, receipt R-A-9
Ned's order:
log orders A-12 #1 journey_started card, gift_card, order, price, weight
log orders A-12 #2 attempt_started charge, attempt 1
log orders A-12 #3 service_provided charge gets payments from bank, a ch17_giving_up_a_step_early::Bank
the bank charges 50 for order A-12
log orders A-12 #4 step_succeeded charge
log orders A-12 #5 contribution_committed receipt
log orders A-12 #6 hook_called audit: on step success of charge
log orders A-12 #7 contribution_committed audit
log orders A-12 #8 attempt_started ship, attempt 1
log orders A-12 #9 service_provided ship gets carrier from carriers, a ch17_giving_up_a_step_early::MainCarrier
log orders A-12 #10 step_failed ship: over-parcel-limit
log orders A-12 #11 hook_called heavy_parcels: on step retry of ship
log orders A-12 #12 step_given_up ship: FailWorkflow from heavy_parcels, no-carrier-can-take-it
log orders A-12 #13 journey_failed ship
desk: order A-12 failed at ship
the journey failed, with receipt R-A-12 and tracking none
audit: charge took 50, receipt R-A-12
Cat's order:
log orders A-3 #1 journey_started card, gift_card, order, price, weight
log orders A-3 #2 attempt_started charge, attempt 1
log orders A-3 #3 service_provided charge gets payments from bank, a ch17_giving_up_a_step_early::Bank
the bank's network fails while charging order A-3
log orders A-3 #4 step_abnormal_termination charge: the payment network is down
log orders A-3 #5 hook_called outage: on step abnormal termination of charge
log orders A-3 #6 step_given_up charge: FailWorkflow from outage, payment-network-down
log orders A-3 #7 journey_failed charge
desk: order A-3 failed at charge
the journey failed, with receipt none and tracking none
audit: none

How it works

  1. A hook can end the journey. A hook's Option holds a lifecycle when it returns Some: a decision that ends the journey there. FailWorkflow fails it, with a reason, a code as a step's failure has. Self::fail_workflow(...) returns it, and Self::carry_on() still returns None, leaving what happens next unchanged.
  2. on step retry runs before a retry. When a step that failed still has a retry to spend, the executor calls it first. Gil's parcel of 60 kg is within the limit, but the main carrier won't collect it, so the shipping fails with too-heavy, the hook carries on, and the backup carrier takes the parcel on attempt 2. Ned's parcel of 90 kg is over the limit, so the shipping fails with over-parcel-limit, and the hook fails the journey: the shipping is given up with the cause FailWorkflow, decided by heavy_parcels, and never meets the backup carrier. The retry budget says how many retries a step may have; a policy can still decide that none of them can help.
  3. The step says what happened, and the policy decides what to do about it. The limit is a rule of the business, so it lives in the shipping, which reports it as a reason. The hook only branches on it, asking for it with #[reason], as an Option<Reason>, because an attempt that ended in an error reported no reason.
  4. on step abnormal termination runs first after an error. It is called as soon as an error ends an attempt, before the executor decides between a retry, a rescue and giving up. Cat's charge ended in the bank's error, so outage failed the journey with the reason payment-network-down, and the charge was given up at once. Its rescue step never ran.
  5. A step given up by a hook says who decided. step_given_up carries the cause FailWorkflow, the policy that returned it, and its reason, then journey_failed follows, naming the step. Neither step decided to give up: the shipping only reported that the parcel was over the limit, and the charge only let the bank's error escape.
  6. Each hook may return only some lifecycles, and its type says which. on step retry and on step abnormal termination return Option<FailWorkflow>, since failing the journey is the only thing either may decide.

So no order spends a retry or a rescue that can't help it. But none of the shop's journeys tells the customer how it went: the lines that say whether a journey succeeded come from main, after each journey has ended, and no customer ever reads them.

Outcomes

We have shown how a step policy gives a step up early, when no retry or rescue can help it.

The usual way With Itinera
Deciding that a retry is pointless usually means more conditions inside the retry loop, beside the call it retries. A policy's on step retry hook decides it from the reason the step reported, attached to the step on the workflow's line.
What happens after an unexpected error is often decided wherever the error is caught. on step abnormal termination decides it for the step, before any retry or rescue, and the event says which policy did.