Retries and dead letters
This guide retries a message now or later, caps the attempts and moves what keeps failing to a dead-letter topic.
MQTT holds no message back, counts no deliveries and has no dead-letter queue. The framework does all three: the mount site declares the cap and the dead-letter topic, and a retry travels as a copy of the message with a retry count in a header.
The example is a firmware service. It installs updates on devices, and a device may refuse an update for a while. It also stores the reports every device sends.
The subscriber
A device refuses an update in three ways, and each needs a different outcome.
Busy means it is running a job: an update offered now is refused again, so retry_after offers it in 30 seconds.
Interrupted means the transfer broke off: retry tries again at once.
Incompatible means the image does not fit the device, and another try gets the same answer, so the update is dropped.
- Macros
- Manual
#[subscriber(MqttTopic::new("updates/install").qos(Qos::AtLeastOnce))]
async fn install(update: &Update) -> HandlerOutcome {
match flash(update).await {
Ok(()) => HandlerOutcome::ack(),
Err(Refusal::Busy) => {
tracing::info!(update.device, "device busy, offering the update later");
HandlerOutcome::retry_after(BACKOFF)
}
Err(Refusal::Interrupted) => {
tracing::warn!(update.device, "transfer interrupted, retrying");
HandlerOutcome::retry()
}
Err(Refusal::Incompatible) => {
tracing::warn!(update.device, update.version, "incompatible image dropped");
HandlerOutcome::drop()
}
}
}
struct Install;
impl Handle<Update> for Install {
async fn handle(
&self,
update: &Update,
_outs: &(),
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
match flash(update).await {
Ok(()) => Ok(()),
Err(Refusal::Busy) => {
tracing::info!(update.device, "device busy, offering the update later");
Err(HandlerOutcome::retry_after(BACKOFF))
}
Err(Refusal::Interrupted) => {
tracing::warn!(update.device, "transfer interrupted, retrying");
Err(HandlerOutcome::retry())
}
Err(Refusal::Incompatible) => {
tracing::warn!(update.device, update.version, "incompatible image dropped");
Err(HandlerOutcome::drop())
}
}
}
}
fn updates() -> MqttTopic {
MqttTopic::new("updates/install").qos(Qos::AtLeastOnce)
}
retry_after acknowledges the delivery, waits, then publishes a copy back to updates/install.
The copy carries the retry-count header (RETRY_COUNT_HEADER), raised by one.
It works at every quality of service; a QoS 0 delivery has nothing to acknowledge, and its copy is published all the same.
The copy waits in the service's memory: a process that stops before the delay ends loses it.
retry becomes a copy too, published at once, because the mount site below declares a cap.
MQTT counts no redeliveries, so the cap counts the copies through their header.
Without a cap, retry leaves the delivery unacknowledged for the next session, as Delivery guarantees shows.
The cap and the dead-letter topic
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App<State = ()> {
RustStream::new(AppInfo::new("firmware", "0.1.0")).with_broker(
MqttBroker::new("mqtt://localhost:1883", "firmware-svc"),
|b| {
// Four deliveries of one update, the first included; the fourth refusal moves it to
// "updates/dead", payload and headers as they arrived.
b.include(install)
.max_attempts(nonzero!(4u32))
.dead_letter("updates/dead");
b.include(review);
b.include(record)
.max_attempts(nonzero!(4u32))
.dead_letter("reports/dead")
.out_retry(Publish::default())
.transform(ToDeliveryTopic);
},
)
}
fn app() -> impl App<State = ()> {
RustStream::new(AppInfo::new("firmware", "0.1.0")).with_broker(
MqttBroker::new("mqtt://localhost:1883", "firmware-svc"),
|b| {
// Four deliveries of one update, the first included; the fourth refusal moves it to
// "updates/dead", payload and headers as they arrived.
b.include(
subscriber("updates/install", Install)
.map_source(|_| updates())
.build(),
)
.max_attempts(nonzero!(4u32))
.dead_letter("updates/dead");
b.include(
subscriber("updates/dead", Review)
.map_source(|_| dead_updates())
.build(),
);
b.include(
subscriber("devices/+/reports", Record)
.map_source(|_| reports())
.build(),
)
.max_attempts(nonzero!(4u32))
.dead_letter("reports/dead")
.out_retry(Publish::default())
.transform(ToDeliveryTopic);
},
)
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts counts every delivery of one update, the first included.
The fourth delivery is not retried: dead_letter publishes it to updates/dead, payload and headers as they arrived.
A dropped update settles and goes nowhere; the dead-letter topic holds only the updates that ran out of attempts.
A copy is an ordinary publish, so every subscription whose filter matches its topic receives it, in this service and in any other.
So each retried topic and each dead-letter topic lies outside every other filter the service reads.
An updates/# subscription next to install would read every copy of every update, and every dead letter too.
Reading the dead-letter topic
- Macros
- Manual
/// The dead-letter topic is read like any other; the retry-count header says how many times the
/// update was delivered before it was moved there.
#[subscriber(MqttTopic::new("updates/dead").qos(Qos::AtLeastOnce))]
async fn review(update: &Update, ctx: &mut Context) -> HandlerOutcome {
let deliveries = ctx.headers().get_str(RETRY_COUNT_HEADER).unwrap_or("1");
tracing::error!(
update.device,
update.version,
deliveries,
"update needs a person"
);
HandlerOutcome::ack()
}
/// The dead-letter topic is read like any other; the retry-count header says how many times the
/// update was delivered before it was moved there.
struct Review;
impl Handle<Update> for Review {
fn handle(
&self,
update: &Update,
_outs: &(),
ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
let deliveries = ctx.headers().get_str(RETRY_COUNT_HEADER).unwrap_or("1");
tracing::error!(
update.device,
update.version,
deliveries,
"update needs a person"
);
ready(Ok(()))
}
}
fn dead_updates() -> MqttTopic {
MqttTopic::new("updates/dead").qos(Qos::AtLeastOnce)
}
The dead-letter topic is an ordinary topic, and review reads it like any other.
The retry-count header of a dead-lettered update holds the number of deliveries it went through, 4 here.
Retries over a filter
- Macros
- Manual
/// Sends every copy back to the topic its delivery arrived on, the one topic of the filter's many
/// that the report belongs to.
struct ToDeliveryTopic;
impl<Options> PublishTransform<ForReply<MqttContext>, Options> for ToDeliveryTopic {
type Destination = Names;
fn apply(
&self,
out: &mut Outgoing<'_>,
_options: &mut Option<Options>,
cx: &PublishContext<'_, MqttContext>,
) {
out.set_name(cx.context(DeliveryTopic).to_owned());
}
}
/// The `Ctx<DeliveryTopic>` parameter makes the crate's context the registration's, which is
/// what the transform reads.
#[subscriber(MqttFilter::new("devices/+/reports").qos(Qos::AtLeastOnce))]
async fn record(report: &Report, Ctx(topic): Ctx<DeliveryTopic>) -> HandlerOutcome {
if let Err(err) = store(report).await {
tracing::warn!(%topic, %err, "report not stored, retrying");
return HandlerOutcome::retry_after(BACKOFF);
}
HandlerOutcome::ack()
}
/// Sends every copy back to the topic its delivery arrived on, the one topic of the filter's many
/// that the report belongs to.
struct ToDeliveryTopic;
impl<Options> PublishTransform<ForReply<MqttContext>, Options> for ToDeliveryTopic {
type Destination = Names;
fn apply(
&self,
out: &mut Outgoing<'_>,
_options: &mut Option<Options>,
cx: &PublishContext<'_, MqttContext>,
) {
out.set_name(cx.context(DeliveryTopic).to_owned());
}
}
/// A body typed over `MqttContext` makes the crate's context the registration's, which is what
/// the transform reads.
struct Record;
impl Handle<Report, (), (), MqttContext> for Record {
async fn handle(
&self,
report: &Report,
_outs: &(),
ctx: &mut Context<'_, MqttContext>,
) -> Result<(), HandlerOutcome> {
if let Err(err) = store(report).await {
let topic = ctx.context(DeliveryTopic);
tracing::warn!(%topic, %err, "report not stored, retrying");
return Err(HandlerOutcome::retry_after(BACKOFF));
}
Ok(())
}
}
fn reports() -> MqttFilter {
MqttFilter::new("devices/+/reports").qos(Qos::AtLeastOnce)
}
A copy of a delivery on an MqttTopic goes back to that topic.
An MqttFilter matches many topics and names none, so its registration names where a copy goes, and the service refuses to start without it.
out_retry(Publish::default()).to(..) names one topic for every copy.
ToDeliveryTopic names one per delivery instead: it reads the topic the report arrived on from the MQTT context and sends the copy back there.
So a report from devices/dev7/reports comes back on devices/dev7/reports, to record alone.
The handler takes Ctx<DeliveryTopic>, which gives the registration the MQTT context; in the manual form its context type is MqttContext.
Testing the delays
The tests in the example file run the app in process with a paused clock, so tb.advance(BACKOFF) lets the 30 seconds pass at once.
They count the deliveries of each update, read the retry-count header of each copy, and check what reaches updates/dead.
Testing an MQTT service covers the harness.