Skip to content

Retries and delays

Fibril separates three related behaviors:

  • immediate retry after a consumer rejects work
  • redelivery after a lease expires
  • delayed delivery or delayed retry after a specific timestamp

Manual-ack consumers can request immediate requeue:

msg.retry().await?;

At the state layer, this removes the offset from inflight, increments the retry count, and returns the offset to ready unless the retry policy is exhausted.

When a message is delivered, it becomes inflight with a lease deadline. If the consumer disappears or does not settle the message, the broker checks for expired inflight messages and returns them to ready.

This is the core failure-recovery path for best-effort at-least-once delivery.

Fibril persists delayed-delivery state. Messages can be held until not_before, and delayed-delivery state is included in recovery snapshots.

The clients expose delayed publish methods:

publisher.publish_delayed(payload, std::time::Duration::from_secs(30)).await?;
publisher.publish_delayed_confirmed(payload, std::time::Duration::from_secs(30)).await?;

Delay units differ by client: Rust a Duration, TypeScript milliseconds by default (or an explicit { seconds } / { ms } / { minutes }, or a Date deadline), Python seconds (or a timedelta), Go a time.Duration, C# a TimeSpan.

The delayed publish path uses a distinct protocol frame instead of adding an optional delay field to the common publish frame.

Manual-ack consumers can ask the broker to retry a message after a delay. Fibril records a not_before Unix-millisecond deadline on the settlement event, keeps the offset out of ready delivery until that deadline, and then makes it eligible for redelivery.

msg.retry_after(std::time::Duration::from_secs(30)).await?;
msg.retry_after(std::time::Duration::from_millis(250)).await?;

The delay units match delayed publish for each client.

A message can be dropped if it is not consumed before a deadline. This is the work-queue “do not process stale work” behavior, and it is distinct from queue expiration (auto-deleting an idle queue), which is not implemented.

Set a TTL per message, or a per-queue default that applies when a message carries no TTL of its own. A per-message TTL wins over the queue default. With neither set a message never expires. The owner resolves the deadline against its own clock at publish, so it survives recovery and replication.

An expired message is never dropped while it is in flight. When it does drop, it follows the queue’s dead-letter policy: discarded when no DLQ is configured, otherwise dead-lettered with reason expired.

Per-message TTL via an expiring publisher (a per-message WithTTL/with_ttl still overrides the default):

let publisher = client.publisher("rpc.reply")?.expiring(std::time::Duration::from_secs(30));
publisher.publish(reply).await?;

Per-queue default TTL at declare time:

client
.declare_queue(QueueConfig::new("rpc.reply")?.default_message_ttl(std::time::Duration::from_secs(30)))
.await?;

The broker resolves the deadline from Publish.ttl_ms (or the queue’s default_message_ttl_ms) and the expiry worker drops expired ready messages on its normal tick.