Skip to content

Deferral

An external API answers 429 Too Many Requests with Retry-After: 30. Nothing went wrong. The job has to wait exactly that long, and then run before the backlog that piled up meanwhile. queuey gets it there by republishing the held job at a higher broker priority than ordinary work, so it rejoins the queue at the front instead of at the back. That is a deferral, and it is deliberately not a retry.

#[async_trait]
impl JobHandler for CallApiHandler {
type Job = CallApi;
async fn handle(&self, job: CallApi, ctx: JobContext) -> Result<(), JobError> {
let response = self.http.get(&job.url).send().await.map_err(JobError::retryable)?;
if response.status() == 429 {
// A handler that wants a cap enforces its own: nothing else will.
if ctx.deferrals >= 5 {
return Err(JobError::fatal_msg("still rate limited after five deferrals"));
}
let retry_after = parse_retry_after(&response).unwrap_or(Duration::from_secs(30));
return Err(JobError::deferred_msg(retry_after, "rate limited"));
}
Ok(())
}
}

A deferral costs no attempt. ctx.attempt is unchanged and the retry policy is never consulted: neither its backoff, because the handler stated the delay, nor its max_attempts, because nothing failed and so nothing should be dead-lettered. The worker logs it at INFO, not WARN or ERROR.

The job goes to the front, not the back. The held envelope is republished at the highest priority level its queue was declared with, while everything enqueued normally sits at priority 0. A priority queue hands out the highest-priority message first, so the deferred job is delivered ahead of the backlog rather than queueing behind it. ctx.deferrals counts how often it happened, and ctx.priority is the priority the delivery arrived with.

The priority lasts exactly one round. If the job then fails and is retried, the retry goes out at priority 0. A retry is not a deferral, and nothing is owed a permanent place at the front. ctx.deferrals still carries.

“First” means first among what is still on the queue

Section titled ““First” means first among what is still on the queue”

A consumer with prefetch 10 may already be holding ten backlog messages when the deferred job is released. Those ten run first. Priority orders what the broker still has, not what a consumer has already been handed.

That is usually fine, and lowering prefetch on a queue where deferral ordering matters tightens it.

The same mechanism, without a first run. Use it to schedule work you already know cannot happen yet:

producer.defer(&CallApi { url }, Duration::from_secs(30)).await?;

The contrast with enqueue_after is the whole point of having both:

Call Waits Comes back at Spends a deferral
enqueue(&job) not at all priority 0 no
enqueue_after(&job, d) d priority 0, behind the backlog no
defer(&job, d) d the queue’s top priority, ahead of the backlog no

defer from the producer leaves deferrals at 0 — the producer never deferred it once, it scheduled it — while a handler-side deferral increments it. Both arrive at the same elevated priority.

Deferral works on any queue, but coming back ahead of the backlog requires the queue to have been declared with priority levels. max_priority defaults to 10, so this works by default. Set it to 0 and deferred jobs come back FIFO:

#[queue(name = "slow", prefetch = 2, max_priority = 0)]
SlowApi,
assert_eq!(
runs[1].priority, 0,
"`max_priority = 0` is not a priority queue, so the job comes back FIFO"
);

That is a reasonable choice when you cannot redeclare an existing queue. The job still waits the right amount of time; it just takes its turn.

The envelope is published into a hold queue whose entire purpose is to expire: q.deferred.30000 has x-message-ttl = 30000 and dead-letters back to q. When the TTL runs out, the broker moves the message onto the work queue with its AMQP priority property already set to the queue’s maximum, which is what puts it at the front. Nothing in your process is waiting, and a worker restart does not lose the deferral.

Delays are rounded up to deferred_granularity, 1 second by default, so Retry-After: 30 and a 29.2 second delay share one hold queue. Rounding is always up, so a job is never released early.

The longest delay a hold queue can express is about 24.8 days. A longer one is refused with an error rather than quietly released early.

Full detail, including why retries use the same mechanism, is in Broker topology.

MemoryBackend counts held envelopes, which is what makes deferral testable:

let id = h.producer
.defer(&CallApi::new("scheduled"), Duration::from_secs(30))
.await?;
assert_eq!(h.backend.deferred(EMAILS), 1, "held, not deliverable yet");
assert_eq!(h.backend.pending(EMAILS), 0);
h.wait_for("the held job to run", || h.log.runs() == 1).await;
let runs = h.log.runs_of("scheduled");
assert_eq!(runs[0].attempt, 1);
assert_eq!(runs[0].deferrals, 0, "the producer never deferred it once");
assert_eq!(runs[0].priority, 10, "it still arrives ahead of the backlog");

On RabbitMQ, hold queues show up in the management UI as myapp.emails.deferred.30000 and disappear on their own once idle, because they are declared with x-expires = 2 * ttl.

The queue you defer onto must have been declared through the same backend — the one Producer::new or WorkerBuilder::build gave you. A producer built with Producer::new_undeclared cannot defer or delay on RabbitMQ, because there would be no q for the hold queue to dead-letter into. That case returns Error::UnknownQueue.