Retries and dead-letter queues
This guide retries an SQS message now or later, caps its deliveries and lets SQS move what keeps failing to a dead-letter queue.
SQS does both jobs itself. A retry is the same message made visible again, with its receive count intact. The cap and the dead-letter queue are the queue's redrive policy, and the mount site declares it.
The example is an invoices service that charges invoices to customers.
Retry now and retry later
- Macros
- Manual
/// The largest invoice charged without a credit check.
const CREDIT_LIMIT_CENTS: u64 = 100_000;
/// How long an invoice waits for the credit check before it comes back.
const CREDIT_CHECK: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "invoices")]
struct Invoice {
id: u64,
customer: String,
amount_cents: u64,
}
#[subscriber(SqsQueue::new("invoices").create_if_missing())]
async fn charge(invoice: &Invoice) -> HandlerOutcome {
if invoice.customer.is_empty() {
// Another try gets the same answer.
tracing::warn!(invoice.id, "invoice without a customer dropped");
return HandlerOutcome::drop();
}
if invoice.amount_cents > CREDIT_LIMIT_CENTS {
tracing::info!(invoice.id, "invoice waits for the credit check");
return HandlerOutcome::retry_after(CREDIT_CHECK);
}
tracing::info!(invoice.id, invoice.amount_cents, "invoice charged");
HandlerOutcome::ack()
}
/// The largest invoice charged without a credit check.
const CREDIT_LIMIT_CENTS: u64 = 100_000;
/// How long an invoice waits for the credit check before it comes back.
const CREDIT_CHECK: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Invoice {
id: u64,
customer: String,
amount_cents: u64,
}
impl OutgoingDestination for Invoice {
type Form = FixedName;
const DESTINATION: &'static str = "invoices";
}
impl MessageHeaders for Invoice {
type Contract = NoHeaders;
}
impl MessageInfo for Invoice {
const NAME: &'static str = "Invoice";
}
struct Charge;
impl Handle<Invoice> for Charge {
fn handle(
&self,
invoice: &Invoice,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
if invoice.customer.is_empty() {
// Another try gets the same answer.
tracing::warn!(invoice.id, "invoice without a customer dropped");
return ready(Err(HandlerOutcome::drop()));
}
if invoice.amount_cents > CREDIT_LIMIT_CENTS {
tracing::info!(invoice.id, "invoice waits for the credit check");
return ready(Err(HandlerOutcome::retry_after(CREDIT_CHECK)));
}
tracing::info!(invoice.id, invoice.amount_cents, "invoice charged");
ready(Ok(()))
}
}
An invoice over the credit limit waits for a credit check.
retry_after sets the message's visibility timeout to 30 seconds, and SQS hands it out again when they end.
SQS counts whole seconds, so a fraction of a second rounds up.
SQS keeps a message hidden for at most twelve hours from its receive, so a delay ends within them.
A longer delay is shortened to end with them, and the crate logs a warning that names the queue.
The message stays on its queue the whole time, so a deferred retry survives a restart of the service.
retry sets the visibility to zero, and the message is handed out again at once.
An invoice without a customer gets the same answer on every try, so charge drops it.
The cap and the dead-letter queue
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App {
RustStream::new(AppInfo::new("invoices", "0.1.0")).with_broker(
SqsBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
|b| {
// Four deliveries of one invoice, the first included; then SQS moves it.
b.include(charge)
.max_attempts(nonzero!(4u32))
.dead_letter("invoices-dead");
b.include(review);
},
)
}
fn app() -> impl App {
RustStream::new(AppInfo::new("invoices", "0.1.0")).with_broker(
SqsBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
|b| {
// Four deliveries of one invoice, the first included; then SQS moves it.
b.include(
subscriber("invoices", Charge)
.map_source(|_| SqsQueue::new("invoices").create_if_missing())
.build(),
)
.max_attempts(nonzero!(4u32))
.dead_letter("invoices-dead");
b.include(
subscriber("invoices-dead", Review)
.map_source(|_| SqsQueue::new("invoices-dead").create_if_missing())
.build(),
);
},
)
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts and dead_letter together are the queue's redrive policy.
The cap becomes its maxReceiveCount, and the dead-letter queue becomes its target.
The subscription writes the policy onto the queue when it opens.
From then on SQS counts the receives and moves a message that ran out of them; the service publishes nothing.
Here an invoice is delivered four times, and on the fifth receive SQS moves it to invoices-dead.
The two steps go together.
A cap without a dead-letter queue, or a dead-letter queue without a cap, stops the service at startup with SqsError::IncompleteRedrive, which names the missing half.
SQS takes a maxReceiveCount of at most 1000.
The dead-letter queue must exist when the subscription opens, unless a descriptor of the app creates it with create_if_missing.
It must be of the same kind as its queue: a FIFO queue takes a FIFO dead-letter queue.
Writing the policy needs sqs:SetQueueAttributes on the queue and sqs:GetQueueAttributes on the dead-letter queue.
drop deletes a message.
On a capped queue, a drop of the delivery that used up the last receive returns the message instead, so SQS moves it to the dead-letter queue rather than losing it.
Reading the dead-letter queue
- Macros
- Manual
/// Reads the invoices SQS moved away after their last delivery.
#[subscriber(SqsQueue::new("invoices-dead").create_if_missing())]
async fn review(invoice: &Invoice) -> HandlerOutcome {
tracing::error!(invoice.id, invoice.customer, "invoice needs a review");
HandlerOutcome::ack()
}
/// Reads the invoices SQS moved away after their last delivery.
struct Review;
impl Handle<Invoice> for Review {
fn handle(
&self,
invoice: &Invoice,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::error!(invoice.id, invoice.customer, "invoice needs a review");
ready(Ok(()))
}
}
A dead-letter queue is an ordinary queue, and review reads it like any other.
The message arrives as it was sent, with its attributes.
A queue with no reader keeps its messages until its retention period ends, so a person can also look at them in the console.
Testing the delay
The tests in the example file run the app in process with a paused clock, so tb.advance(CREDIT_CHECK) lets the 30 seconds pass at once.
One test runs an invoice through its four deliveries into invoices-dead, another checks that a cap without a dead-letter queue does not start.
Testing an SQS service covers the harness.