Retries and dead letters
This guide retries a delivery now or later, caps the attempts, moves what keeps failing to a dead-letter address and marks the copies.
AMQP 1.0 has no delayed redelivery, and a link cannot declare a delivery limit or a dead-letter address. The framework covers both: the mount site declares the cap and the dead-letter address, and a delayed retry travels as a copy of the message with a retry count in a header.
The example is a refunds service that sends refunds through a card processor.
The subscriber
The processor refuses a refund in three ways, and each needs a different outcome.
Busy means it takes no more large refunds for now: a retry right now would be refused again, so retry_after waits 30 seconds.
Unavailable means the card's bank did not answer: retry tries again at once.
Declined means the card account is closed, and another try gets the same answer, so the refund is dropped.
- Macros
- Manual
/// How long a busy processor asks its callers to wait.
const BACKOFF: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
#[subscriber(AmqpAddress::queue("refunds"))]
async fn process_refund(refund: &Refund) -> HandlerOutcome {
match send_refund(refund) {
Ok(()) => HandlerOutcome::ack(),
// Past the rate limit: a delivery right now would be refused again.
Err(Refusal::Busy) => HandlerOutcome::retry_after(BACKOFF),
// No answer: try again at once, up to the attempt cap.
Err(Refusal::Unavailable) => HandlerOutcome::retry(),
Err(Refusal::Declined) => {
tracing::warn!(refund.id, "card declined, refund dropped");
HandlerOutcome::drop()
}
}
}
/// How long a busy processor asks its callers to wait.
const BACKOFF: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
impl OutgoingDestination for Refund {
type Form = CallerName;
}
impl MessageHeaders for Refund {
type Contract = NoHeaders;
}
impl MessageInfo for Refund {
const NAME: &'static str = "Refund";
}
struct ProcessRefund;
impl Handle<Refund> for ProcessRefund {
fn handle(
&self,
refund: &Refund,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
ready(match send_refund(refund) {
Ok(()) => Ok(()),
// Past the rate limit: a delivery right now would be refused again.
Err(Refusal::Busy) => Err(HandlerOutcome::retry_after(BACKOFF)),
// No answer: try again at once, up to the attempt cap.
Err(Refusal::Unavailable) => Err(HandlerOutcome::retry()),
Err(Refusal::Declined) => {
tracing::warn!(refund.id, "card declined, refund dropped");
Err(HandlerOutcome::drop())
}
})
}
}
retry_after settles the delivery and publishes a copy to refunds once the delay has passed.
The copy carries the retry-count header (RETRY_COUNT_HEADER), raised by one.
The delayed copy is at most once: a process that stops before the delay ends loses it.
On a queue the copy goes to one consumer like any other message.
The cap and the dead-letter address
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App {
RustStream::new(AppInfo::new("refunds", "0.1.0")).with_broker(
AmqpBroker::new("amqp://artemis:artemis@localhost:5672"),
|b| {
// Five deliveries of one refund, the first included; then the dead-letter address.
b.include(process_refund)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dead")
.out_retry(Publish)
.transform(Retried);
b.include(review);
},
)
}
fn app() -> impl App {
RustStream::new(AppInfo::new("refunds", "0.1.0")).with_broker(
AmqpBroker::new("amqp://artemis:artemis@localhost:5672"),
|b| {
// Five deliveries of one refund, the first included; then the dead-letter address.
b.include(subscriber(AmqpAddress::queue("refunds"), ProcessRefund).build())
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dead")
.out_retry(Publish)
.transform(Retried);
b.include(subscriber(AmqpAddress::queue("refunds.dead"), Review).build());
},
)
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts counts every delivery of one refund, the first included.
The fifth delivery is not retried: dead_letter publishes it to refunds.dead, payload and headers as they arrived.
A dropped refund is rejected and goes to the broker's own dead-letter policy, not to refunds.dead.
Under a cap the framework reads the count from the delivery.
It is the broker's delivery-count when the message carries an AMQP header section.
A message published by this crate carries none, so retry then publishes a copy at once, and the retry-count header counts the attempts.
A cap without dead_letter rejects the last delivery instead, and the broker's dead-letter policy decides.
The broker also has a limit of its own: Artemis stops after max-delivery-attempts, 10 by default.
Choose one owner for the limit: a cap below the broker's limit makes the framework's count the one that applies.
Reading the dead letters
- Macros
- Manual
#[subscriber(AmqpAddress::queue("refunds.dead"))]
async fn review(refund: &Refund, ctx: &mut Context<'_>) -> HandlerOutcome {
let deliveries = ctx.headers().get_str(RETRY_COUNT_HEADER).unwrap_or("1");
tracing::error!(refund.id, deliveries, "refund needs a person to look at it");
HandlerOutcome::ack()
}
struct Review;
impl Handle<Refund> for Review {
fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
let deliveries = ctx.headers().get_str(RETRY_COUNT_HEADER).unwrap_or("1");
tracing::error!(refund.id, deliveries, "refund needs a person to look at it");
ready(Ok(()))
}
}
refunds.dead is an ordinary queue, and review reads it like any other.
The retry-count header of a dead letter holds the number of deliveries it went through, 5 here.
review reads it with ctx.headers() and logs the refund for a person to look at.
Marking the copies
- Macros
- Manual
/// Marks every copy with the address its delivery came from.
struct Retried;
impl<C, Options> PublishTransform<ForReply<C>, Options> for Retried {
type Destination = Reads;
fn apply(
&self,
out: &mut Outgoing<'_>,
_options: &mut Option<Options>,
cx: &PublishContext<'_, C>,
) {
out.headers_mut()
.insert("x-retried-from", cx.name().to_owned());
}
}
/// Marks every copy with the address its delivery came from.
struct Retried;
impl<C, Options> PublishTransform<ForReply<C>, Options> for Retried {
type Destination = Reads;
fn apply(
&self,
out: &mut Outgoing<'_>,
_options: &mut Option<Options>,
cx: &PublishContext<'_, C>,
) {
out.headers_mut()
.insert("x-retried-from", cx.name().to_owned());
}
}
A copy has no call site in the handler, so a publish transform is the one place that reaches it.
The app mounts process_refund with .out_retry(Publish).transform(Retried).
.out_retry(Publish) names the publisher of the copies, and the steps after it apply to them.
Retried writes the address the delivery came from into x-retried-from.
Every copy goes back to the address of its subscription, so a transform on this position may not change the destination.
Retries on a topic
A copy is published to the subscription's own address. On a topic address that address is shared, so every subscription on the topic gets the copy, not only the one that asked for it. Retry on a queue when one reader owns the message.
Testing the delay
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.
Testing an AMQP service covers the harness.