Retries and dead letters
This guide retries a delivery now or later, caps the attempts and keeps what keeps failing in a dead-letter stream, on a stream consumer, a Core subject and a pattern.
NATS has no dead-letter mechanism. JetStream can cap the deliveries of a message, but it sends the last one nowhere. So the mount site declares the cap and the dead-letter subject, and the framework applies them to both models.
The example is a payments service that settles payments and sends refunds through a bank. It keeps its dead letters in a stream of their own:
nats stream add PAYMENTS --subjects 'payments.settle' --defaults
nats stream add DEAD --subjects 'dead.*' --defaults
Retrying on a stream
- Macros
- Manual
/// How long a busy bank asks its callers to wait.
const BACKOFF: Duration = Duration::from_secs(30);
/// The outcome a refusal of the bank calls for.
fn outcome(refusal: Refusal) -> HandlerOutcome {
match refusal {
// Past the rate limit: a call right now would be refused again.
Refusal::Busy => HandlerOutcome::retry_after(BACKOFF),
// No answer: try again at once, up to the attempt cap.
Refusal::Unavailable => HandlerOutcome::retry(),
Refusal::Declined => HandlerOutcome::drop(),
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "payments.settle")]
struct Payment {
id: u64,
card: String,
amount_cents: u64,
}
#[subscriber(JetStreamSubject::new("payments.settle", "PAYMENTS").durable("settler"))]
async fn settle(payment: &Payment, State(bank): State<Arc<Bank>>) -> HandlerOutcome {
match bank.charge(&payment.card).await {
Ok(()) => HandlerOutcome::ack(),
Err(refusal) => {
tracing::warn!(payment.id, ?refusal, "payment refused by the bank");
outcome(refusal)
}
}
}
/// How long a busy bank asks its callers to wait.
const BACKOFF: Duration = Duration::from_secs(30);
/// The outcome a refusal of the bank calls for.
fn outcome(refusal: Refusal) -> HandlerOutcome {
match refusal {
// Past the rate limit: a call right now would be refused again.
Refusal::Busy => HandlerOutcome::retry_after(BACKOFF),
// No answer: try again at once, up to the attempt cap.
Refusal::Unavailable => HandlerOutcome::retry(),
Refusal::Declined => HandlerOutcome::drop(),
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Payment {
id: u64,
card: String,
amount_cents: u64,
}
impl OutgoingDestination for Payment {
type Form = FixedName;
const DESTINATION: &'static str = "payments.settle";
}
impl MessageHeaders for Payment {
type Contract = NoHeaders;
}
impl MessageInfo for Payment {
const NAME: &'static str = "Payment";
}
struct Settle;
impl Handle<Payment, (), (), (), Clients> for Settle {
async fn handle(
&self,
payment: &Payment,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<(), HandlerOutcome> {
match ctx.state().bank.charge(&payment.card).await {
Ok(()) => Ok(()),
Err(refusal) => {
tracing::warn!(payment.id, ?refusal, "payment refused by the bank");
Err(outcome(refusal))
}
}
}
}
outcome turns the bank's refusal into the handler's outcome.
A busy bank is asked again in 30 seconds with retry_after.
On a JetStream consumer the delay travels in the negative acknowledgement: the server holds the payment and delivers it again on the same consumer, with the same stream sequence.
Nothing is published, and a process that stops in between loses nothing.
A silent bank is asked again at once with retry, and a declined card is dropped.
The cap and the dead letters
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("payments", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(NatsBroker::new("nats://localhost:4222"), |b| {
// Five deliveries of one payment, the first included; then the dead-letter subject.
b.include(settle)
.max_attempts(nonzero!(5u32))
.dead_letter("dead.payments");
b.include(review);
// A pattern is no subject to publish a copy to, so the mount site names one.
b.include(send_refund)
.max_attempts(nonzero!(3u32))
.dead_letter("dead.refunds")
.out_retry(Publish)
.to("refunds.retry");
})
}
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("payments", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(NatsBroker::new("nats://localhost:4222"), |b| {
b.include(
subscriber(
JetStreamSubject::new("payments.settle", "PAYMENTS").durable("settler"),
Settle,
)
.build(),
)
// Five deliveries of one payment, the first included; then the dead-letter subject.
.max_attempts(nonzero!(5u32))
.dead_letter("dead.payments");
b.include(
subscriber(
JetStreamSubject::new("dead.payments", "DEAD").durable("review"),
Review,
)
.build(),
);
// A pattern is no subject to publish a copy to, so the mount site names one.
b.include(subscriber(CoreWildcard::new("refunds.*"), SendRefund).build())
.max_attempts(nonzero!(3u32))
.dead_letter("dead.refunds")
.out_retry(Publish)
.to("refunds.retry");
})
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts counts every delivery of one payment, the first included.
On a JetStream consumer the count is the server's own, so an ack_wait that ran out counts too.
The fifth delivery is not retried: dead_letter publishes it to dead.payments, payload and headers as they arrived.
A dropped payment settles and goes nowhere, so the dead letters hold only the payments that ran out of attempts.
A capped consumer without a dead-letter subject terminates its last delivery.
dead.payments is a plain publish, and Core NATS keeps nothing nobody reads.
The DEAD stream stores it, so a dead letter waits until someone looks at it.
No subject the app reads matches a dead-letter subject, or a spent delivery would come back to the handler that gave up on it.
Reading the dead letters
- Macros
- Manual
#[subscriber(JetStreamSubject::new("dead.payments", "DEAD").durable("review"))]
async fn review(payment: &Payment) -> HandlerOutcome {
tracing::error!(payment.id, "payment needs a person to look at it");
HandlerOutcome::ack()
}
struct Review;
impl Handle<Payment, (), (), (), Clients> for Review {
fn handle(
&self,
payment: &Payment,
_outs: &(),
_ctx: &mut Context<'_, (), Clients>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::error!(payment.id, "payment needs a person to look at it");
ready(Ok(()))
}
}
review reads the dead payments from the DEAD stream like any other stream and logs each for a person to look at.
Retrying on Core subjects
- Macros
- Manual
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
struct Refund {
id: u64,
card: String,
amount_cents: u64,
}
#[subscriber(CoreWildcard::new("refunds.*"))]
async fn send_refund(refund: &Refund, State(bank): State<Arc<Bank>>) -> HandlerOutcome {
match bank.charge(&refund.card).await {
Ok(()) => HandlerOutcome::ack(),
Err(refusal) => {
tracing::warn!(refund.id, ?refusal, "refund refused by the bank");
outcome(refusal)
}
}
}
#[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 SendRefund;
impl Handle<Refund, (), (), (), Clients> for SendRefund {
async fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<(), HandlerOutcome> {
match ctx.state().bank.charge(&refund.card).await {
Ok(()) => Ok(()),
Err(refusal) => {
tracing::warn!(refund.id, ?refusal, "refund refused by the bank");
Err(outcome(refusal))
}
}
}
}
Core NATS cannot hold a message back.
On a Core subscription a retry is a copy of the delivery that the framework publishes: retry publishes it at once, retry_after when the delay ends.
The copy carries the delivery count in its x-ruststream-retry-count header, which the cap reads.
A delayed copy is at most once: a process that stops before the delay ends loses it.
A CoreSubject publishes its copies to its own subject.
send_refund reads the pattern refunds.*, which has no single subject to publish to.
So the app names one with out_retry(Publish).to("refunds.retry"), a subject the pattern matches, and the copies come back to send_refund.
A registration over a pattern without it refuses to start, whether its handler retries or not.
The third delivery of a refund goes to dead.refunds, with its count in the header.
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 a NATS service covers the harness.