Retries and dead letters
This guide retries a message later, caps its deliveries and moves what keeps failing to a dead-letter key.
Neither transport holds a message back or counts its deliveries. The framework does both: a later delivery is a copy the runtime publishes once a delay has passed, and the count travels in a header of that copy.
The example is a shipping service that ships an order once its payment has cleared.
The subscriber
- Macros
- Manual
/// How long an order waits before the service asks the payment service again.
const RECHECK: Duration = Duration::from_secs(2);
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "orders")]
struct Order {
id: u64,
}
#[subscriber(FileStream::new("orders"))]
async fn ship(order: &Order, State(payments): State<Arc<Payments>>) -> HandlerOutcome {
if !payments.has_cleared(order.id) {
// The payment may clear later: ask again after a pause.
return HandlerOutcome::retry_after(RECHECK);
}
tracing::info!(order.id, "order shipped");
HandlerOutcome::ack()
}
/// How long an order waits before the service asks the payment service again.
const RECHECK: Duration = Duration::from_secs(2);
#[derive(Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Order {
id: u64,
}
struct Ship;
impl Handle<Order, (), (), (), Clients> for Ship {
fn handle(
&self,
order: &Order,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
if !ctx.state().payments.has_cleared(order.id) {
// The payment may clear later: ask again after a pause.
return ready(Err(HandlerOutcome::retry_after(RECHECK)));
}
tracing::info!(order.id, "order shipped");
ready(Ok(()))
}
}
Payments is the service's client of the payment service, kept in the app's state.
While an order's payment has not cleared, ship answers retry_after and the service asks again two seconds later.
retry_after settles the delivery, waits out the delay and appends a copy of the order under the key the subscription reads.
The subscription reads the copy as a new message, after everything that was already in the file.
So the orders behind a waiting one are not held up.
The copy carries the retry-count header (RETRY_COUNT_HEADER), raised by one.
The copy waits in the service's memory until the delay is over, so a process that stops before then loses it.
A replay reads a copy only while it is still short of the end of the file.
The cap and the dead-letter key
- Macros
- Manual
fn shipping(file: FileBroker) -> impl App<State = Clients> {
RustStream::new(AppInfo::new("shipping", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(file, |b| {
// Three deliveries of one order, the first included; then the dead-letter key.
b.include(ship)
.max_attempts(nonzero!(3u32))
.dead_letter("orders.dead");
b.include(review);
})
}
fn app() -> impl App<State = Clients> {
shipping(FileBroker::new("/var/lib/shop/orders.ss"))
}
fn main() -> ExitCode {
run_main(app)
}
fn shipping(file: FileBroker) -> impl App<State = Clients> {
RustStream::new(AppInfo::new("shipping", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(file, |b| {
// Three deliveries of one order, the first included; then the dead-letter key.
b.include(subscriber(FileStream::new("orders"), Ship).build())
.max_attempts(nonzero!(3u32))
.dead_letter("orders.dead");
b.include(subscriber(FileStream::new("orders.dead"), Review).build());
})
}
fn app() -> impl App<State = Clients> {
shipping(FileBroker::new("/var/lib/shop/orders.ss"))
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts counts every delivery of one order, the first included.
The third delivery is not retried: dead_letter appends it under orders.dead, payload and headers as they arrived.
On a stream file the dead-letter key is one more stream key of the same file.
A cap with no dead-letter key beside it writes the spent order nowhere.
Reading the dead letters
- Macros
- Manual
#[subscriber(FileStream::new("orders.dead"))]
async fn review(order: &Order, ctx: &mut Context<'_>) -> HandlerOutcome {
let deliveries = ctx.headers().get_str(RETRY_COUNT_HEADER).unwrap_or("1");
tracing::error!(
order.id,
deliveries,
"unpaid order needs a person to look at it"
);
HandlerOutcome::ack()
}
struct Review;
impl Handle<Order, (), (), (), Clients> for Review {
fn handle(
&self,
order: &Order,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
let deliveries = ctx.headers().get_str(RETRY_COUNT_HEADER).unwrap_or("1");
tracing::error!(
order.id,
deliveries,
"unpaid order needs a person to look at it"
);
ready(Ok(()))
}
}
review reads orders.dead like any other key.
The retry-count header of a dead letter holds the number of deliveries the order got, 3 here.
An operator who inspects the dead letters later reads them with a replay instead.
Retries on a pipe
A stage's standard output reaches the next process and never its own input, so a copy cannot come back to the stage that deferred it.
On StdioBroker the mount site names the key the copies go to, b.include(ship).out_retry(Publish).to("orders.retry"), and a later stage reads them (out_retry).
The pipeline stage of Getting started is mounted this way.
A stage that names no key does not start, because any handler may answer retry_after.
The cap and the dead-letter key work on a pipe as they do on a file; a dead letter is a line under its key for a downstream stage.
Testing the delay
The tests in the example file run the app in process on a paused clock, so tb.advance(RECHECK) lets the two seconds pass at once.
One test clears the payment between two deliveries, and the order ships on the second.
The dead-letter test runs both in process and on a real stream file with TestApp::start_live, where the delays pass on the real clock.
Testing a file or pipeline service covers the harness.