Retries and dead-letter topics
This guide retries a Kafka record now or later, caps the attempts and moves what keeps failing to a dead-letter topic, on one topic or several.
Kafka does not hold a record back and does not count deliveries. The framework does both: the mount site declares the cap and the dead-letter topic, and a retry travels as a copy of the record 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 its rate limit is used up: a retry right now would be refused again, so retry_after waits 30 seconds.
Unavailable means it 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(KafkaTopic::new("refunds").commit(Commit::Tracked))]
async fn process_refund(
refund: &Refund,
State(processor): State<Arc<Processor>>,
) -> HandlerOutcome {
settle(&processor, refund).await
}
async fn settle(processor: &Processor, refund: &Refund) -> HandlerOutcome {
match processor.refund(refund).await {
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, (), (), (), Clients> for ProcessRefund {
async fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> Result<(), HandlerOutcome> {
settle(&ctx.state().processor, refund).await
}
}
async fn settle(processor: &Processor, refund: &Refund) -> Result<(), HandlerOutcome> {
match processor.refund(refund).await {
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 record and publishes a copy back 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.
The copy is a new record at the end of the topic, so the records behind the refund do not wait for it.
retry under a declared cap also becomes a copy, published at once.
Without a cap it hands the record back to the subscription under Commit::Tracked, as Commits and delivery guarantees shows.
The cap and the dead-letter topic
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("refunds", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(
KafkaBroker::new(["localhost:9092"]).default_group("refunds"),
|b| {
// Five deliveries of one refund, the first included; then the dead-letter topic.
b.include(process_refund)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dlq");
b.include(review);
// A set of topics names where a copy goes: back to the topic it came from.
b.include(process_regional_refund)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dlq")
.out_retry(Publish::default())
.transform(ToSourceTopic);
},
)
}
fn app() -> impl App<State = Clients> {
RustStream::new(AppInfo::new("refunds", "0.1.0"))
.on_startup(async move |()| Ok::<_, Infallible>(Clients::default()))
.with_broker(
KafkaBroker::new(["localhost:9092"]).default_group("refunds"),
|b| {
// Five deliveries of one refund, the first included; then the dead-letter topic.
b.include(
subscriber("refunds", ProcessRefund)
.map_source(|_| KafkaTopic::new("refunds").commit(Commit::Tracked))
.build(),
)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dlq");
b.include(
subscriber("refunds.dlq", Review)
.map_source(|_| {
KafkaTopic::new("refunds.dlq")
.group("refunds-review")
.start(StartOffset::Earliest)
})
.build(),
);
// A set of topics names where a copy goes: back to the topic it came from.
b.include(
subscriber("refunds.eu,refunds.us", ProcessRegionalRefund)
.map_source(|_| {
KafkaTopics::new(["refunds.eu", "refunds.us"])
.group("regional-refunds")
.commit(Commit::Tracked)
})
.build(),
)
.max_attempts(nonzero!(5u32))
.dead_letter("refunds.dlq")
.out_retry(Publish::default())
.transform(ToSourceTopic);
},
)
}
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.dlq, payload and headers as they arrived.
A dropped refund settles and goes nowhere; the dead-letter topic holds only the refunds that ran out of attempts.
Retry and dead-letter topics are the service's infrastructure: create them like any other topic, and the framework only publishes to them.
Reading the dead-letter topic
The dead-letter topic is an ordinary topic, and review reads it like any other.
- Macros
- Manual
#[subscriber(
KafkaTopic::new("refunds.dlq")
.group("refunds-review")
.start(StartOffset::Earliest)
)]
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, (), (), (), Clients> for Review {
fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, (), Clients>,
) -> 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(()))
}
}
The retry-count header of a dead-lettered record 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.
Retries on several topics
A copy of a record is published to the topic the subscription reads.
KafkaTopic reads one topic, so it needs nothing more.
KafkaTopics and KafkaPartitions read a set that no single publish reaches, so their registration names where a copy goes, and the service refuses to start without it.
- Macros
- Manual
#[subscriber(
KafkaTopics::new(["refunds.eu", "refunds.us"])
.group("regional-refunds")
.commit(Commit::Tracked)
)]
async fn process_regional_refund(
refund: &Refund,
Ctx(topic): Ctx<Topic>,
State(processor): State<Arc<Processor>>,
) -> HandlerOutcome {
tracing::debug!(refund.id, %topic, "regional refund");
settle(&processor, refund).await
}
struct ProcessRegionalRefund;
impl Handle<Refund, (), (), KafkaContext, Clients> for ProcessRegionalRefund {
async fn handle(
&self,
refund: &Refund,
_outs: &(),
ctx: &mut Context<'_, KafkaContext, Clients>,
) -> Result<(), HandlerOutcome> {
tracing::debug!(refund.id, topic = %ctx.context(Topic), "regional refund");
settle(&ctx.state().processor, refund).await
}
}
The app mounts process_regional_refund with .out_retry(Publish::default()).transform(ToSourceTopic).
ToSourceTopic sends each copy back to the topic its delivery came from, so a retried refund from refunds.us comes back on refunds.us.
It reads the topic from the Kafka context, so the handler takes a context key such as Ctx<Topic>; in the manual form its context type is KafkaContext.
.out_retry(Publish::default()).to("refunds.retry") sends every copy to one topic of your own instead; list that topic in the subscription so the copies come back.
The same rule holds for a subscription that never retries: every registration over a set of topics names a retry destination.
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 a Kafka service covers the harness.