The Itinera bookRust edition

Part 2: Getting started, Chapter 13

A carrier for each attempt

What you want to do

When the main carrier won't collect a parcel, the shop has a backup carrier that will collect heavier ones. You want each attempt of the shipping to get its own carrier, the main one first and the backup one when the shipping is tried again, without the shipping knowing that there are two.

The code

The two carriers are faked, like the main one before: each books a parcel, prints what it does, and collects a parcel only up to its weight limit, 20 kg for the main carrier and 80 kg for the backup. Their error, BookingFailed, is chapter 12's.

Rust, from the program ch13_a_carrier_for_each_attempt
/// The shop's main carrier, faked: its booking system fails on a parcel over 100 kg, and it
/// collects a parcel only up to 20 kg.
struct MainCarrier;

impl Carrier for MainCarrier {
    fn book(&self, order: &str, weight: i64) -> Result<String, BookingFailed> {
        if weight > 100 {
            println!("the main carrier's booking system fails on order {order}");
            return Err(BookingFailed);
        }
        println!("the main carrier books order {order}");
        Ok(format!("T-{order}"))
    }

    fn collects(&self, weight: i64) -> bool {
        weight <= 20
    }
}

/// The shop's backup carrier, faked: its booking system fails on a parcel over 100 kg, and it
/// collects a parcel only up to 80 kg.
struct BackupCarrier;

impl Carrier for BackupCarrier {
    fn book(&self, order: &str, weight: i64) -> Result<String, BookingFailed> {
        if weight > 100 {
            println!("the backup carrier's booking system fails on order {order}");
            return Err(BookingFailed);
        }
        println!("the backup carrier books order {order}");
        Ok(format!("B-{order}"))
    }

    fn collects(&self, weight: i64) -> bool {
        weight <= 80
    }
}

The workflow's provider for carrier is now marked per_attempt, and asks for the attempt's number with a parameter marked #[attempt]. It makes the main carrier for attempt 1, and the backup carrier for any attempt after it.

Rust, from the program ch13_a_carrier_for_each_attempt
/// A carrier for every attempt of a step: the main carrier first, the backup carrier after.
#[provider(key = "carrier", per_attempt)]
fn carriers(&self, #[attempt] attempt: NonZeroU32) -> Result<Provided<dyn Carrier>, Error> {
    if attempt == NonZeroU32::MIN {
        return Ok(provide!(MainCarrier));
    }
    Ok(provide!(BackupCarrier))
}

Everything else is chapter 12's shop, unchanged, including the shipping, which still asks for a &dyn Carrier and fails when the parcel won't be collected, and both steps' retry budgets of 1. It runs Ann's, Gil's and Jo's orders again, and prints:

What ch13_a_carrier_for_each_attempt prints, recorded in ch13_a_carrier_for_each_attempt.txt
Ann's order:
log orders A-1 #1 journey_started 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 ch13_a_carrier_for_each_attempt::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 attempt_started ship, attempt 1
log orders A-1 #7 service_provided ship gets carrier from carriers, a ch13_a_carrier_for_each_attempt::MainCarrier
the main carrier books order A-1
log orders A-1 #8 step_info ship: the parcel leaves with receipt R-A-1
log orders A-1 #9 step_succeeded ship
log orders A-1 #10 contribution_committed tracking
log orders A-1 #11 journey_succeeded
the journey succeeded, with receipt R-A-1 and tracking T-A-1
Gil's order:
log orders A-7 #1 journey_started 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 ch13_a_carrier_for_each_attempt::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 attempt_started ship, attempt 1
log orders A-7 #7 service_provided ship gets carrier from carriers, a ch13_a_carrier_for_each_attempt::MainCarrier
the main carrier books order A-7
log orders A-7 #8 step_failed ship: too-heavy
log orders A-7 #9 step_retrying ship
log orders A-7 #10 attempt_started ship, attempt 2
log orders A-7 #11 service_provided ship gets carrier from carriers, a ch13_a_carrier_for_each_attempt::BackupCarrier
the backup carrier books order A-7
log orders A-7 #12 step_info ship: the parcel leaves with receipt R-A-7
log orders A-7 #13 step_succeeded ship
log orders A-7 #14 contribution_committed tracking
log orders A-7 #15 journey_succeeded
the journey succeeded, with receipt R-A-7 and tracking B-A-7
Jo's order:
log orders A-8 #1 journey_started 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 ch13_a_carrier_for_each_attempt::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_retrying charge
log orders A-8 #6 attempt_started charge, attempt 2
the bank declines card jo-2024: it has expired
log orders A-8 #7 step_failed charge: card-expired
log orders A-8 #8 step_given_up charge: retries exhausted
log orders A-8 #9 journey_failed charge
desk: order A-8 failed at charge
the journey failed, with receipt none and tracking none

How it works

  1. A provider decides how long what it makes is kept. A provider marked #[provider] alone, as in chapter 7, is called once for a journey, the first time a step asks, and every attempt that asks for its key gets the same instance. One marked per_attempt is called for every attempt that asks, and what it makes serves that attempt only, then is dropped. How long a service lives is the provider's decision, never the step's.
  2. A provider for every attempt may ask which attempt it serves. A parameter marked #[attempt], a NonZeroU32, is the number of the attempt the provider is called for, so the provider can decide from it which carrier to make. NonZeroU32::MIN is 1, the first attempt.
  3. The log shows which carrier served which attempt. Gil's journey now records service_provided before each attempt of the shipping: the MainCarrier for attempt 1, which wouldn't collect 60 kg, and the BackupCarrier for attempt 2, which did. The journey succeeds, and the tracking number committed is the backup carrier's, B-A-7, since only the attempt that succeeded commits what it contributed. Ann's parcel of 2 kg goes with the main carrier on attempt 1, and never meets the backup.
  4. The shipping is unchanged. It still uses whatever carrier it is lent, and says only whether the parcel was collected. Choosing the backup carrier for a retry is the workflow's decision, made in its provider, as choosing the main carrier was.

Jo's order still fails at the charge, tried twice in vain: the card expired in 2024, and no attempt with that card would ever be charged. But a bank usually knows which card replaced one that expired, and the charge has no way to ask for it: it charges the card it was given, and nothing more.

Outcomes

We have shown how a provider gives each attempt of a step a service of its own, chosen by the attempt's number.

The usual way With Itinera
Falling back to another client on a second try often means the code that retries also knows every client it may fall back to. The workflow's provider chooses the carrier for each attempt, and the step that uses it never knows there are two.