Skip to content

Envelopes

An Envelope is what is actually on the queue. Your job is the payload inside it; everything else is the metadata the worker needs to route, retry and account for the delivery.

pub struct Envelope {
pub job_id: Uuid,
pub job_type: String,
pub queue: String,
pub attempt: u32,
pub enqueued_at_ms: u64,
#[serde(default)] pub deferrals: u32,
#[serde(default)] pub priority: u8,
pub payload: serde_json::Value,
}

It is serialized as JSON in the message body.

Field Type Meaning
job_id Uuid Unique id, stable across retries and deferrals
job_type String Job::NAME of the payload; what the worker routes on
queue String Broker queue name this envelope was published to
attempt u32 1-based attempt counter. First delivery is attempt 1
enqueued_at_ms u64 Unix epoch milliseconds when the job was first enqueued
deferrals u32 How often this job was deferred. Independent of attempt: a deferral is not a failed attempt
priority u8 Broker message priority; 0 (the default) is normal work
payload serde_json::Value The serialized job

deferrals and priority are #[serde(default)], so envelopes written by a version of queuey that predates deferral still decode.

enqueued_at_ms is what JobContext::age is computed from, and it is not reset by a retry or a deferral. A job that has been failing for an hour reports an hour of age on its next attempt.

Transition attempt deferrals priority
next_attempt(), used by a retry +1 unchanged reset to 0
deferred(priority), used by a deferral unchanged +1 set to the queue’s max

Those two rows are the whole difference between a retry and a deferral on the wire. A retry spends an attempt and gives up its place in line; a deferral spends neither.

On RabbitMQ the envelope body is accompanied by properties derived from it:

Property Value
content-type application/json
delivery-mode 2 (persistent)
message-id job_id
type job_type
priority envelope.priority, always set
header x-attempt envelope.attempt
header x-deferrals envelope.deferrals

expiration is never set on a message. Per-message TTL would break the in-order draining that hold queues rely on, so delays are expressed as a queue-level x-message-ttl instead. See Broker topology.

An over-long message-id or type is truncated at a UTF-8 character boundary rather than panicking, since AMQP short strings cap at 255 bytes.

Application code does not normally construct or read an Envelope — the producer builds one and the worker decodes it. It is public for two audiences: people implementing a Backend, and people writing tooling that reads a dead-letter queue.

impl Envelope {
pub fn new<J: Job>(job: &J) -> Result<Self>;
pub fn decode<J: Job>(&self) -> Result<J>;
pub fn next_attempt(&self) -> Self;
pub fn deferred(&self, priority: u8) -> Self;
pub fn to_bytes(&self) -> Result<Vec<u8>>;
pub fn from_bytes(bytes: &[u8]) -> Result<Self>;
}

decode does not check job_type against J::NAME. Callers that read arbitrary messages — a dead-letter drain, for instance — should check it themselves before decoding:

let envelope = Envelope::from_bytes(&body)?;
if envelope.job_type == SendEmail::NAME {
let job: SendEmail = envelope.decode()?;
// ...
}

The body is plain JSON, so anything that can read a message can read a job:

{
"job_id": "0d8f9c22-5e3b-4f1a-9c77-2b1a6f0e3d44",
"job_type": "myapp::jobs::SendEmail",
"queue": "myapp.emails",
"attempt": 2,
"enqueued_at_ms": 1757846400123,
"deferrals": 0,
"priority": 0,
"payload": { "to": "a@b.c", "body": "hi" }
}

That is what you will see in the RabbitMQ management UI when you “Get message” from myapp.emails.dead, and it is enough to decide whether to replay it.