You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When RabbitMQ force-closes a channel (not the whole connection) — e.g. via a precondition_failed channel exception such as a consumer ack-timeout — a queue's subscription dies permanently and silently. The queue FSM transitions to its closed state and just sits there forever. There is no automatic recovery, no automatic re-subscription, and no documented signal telling the application it needs to do anything. The application's connection stays healthy throughout (rabbot.on('unreachable', ...) never fires, since the connection itself never drops), so nothing in a typical integration notices anything is wrong.
In our case this caused complete, silent, unrecovered loss of message consumption for ~16 hours across every replica of a service, discovered only because a human happened to notice downstream data wasn't updating — not because of any error, log line, or alert.
Environment
rabbot 2.1.0
Node.js 24.x
RabbitMQ 3.13 (management image), single node
Consumer declared via rabbot.handle('#', handler, queueName) + rabbot.configure({ exchanges, queues, bindings }), subscribe: true on the queue definition. No limit (prefetch) set on the queue definition.
What happened (production incident)
A long-running consumer's handler took long enough processing one message (compounded by a slow/restarting downstream dependency at the time) to exceed RabbitMQ's default 30-minute consumer ack-timeout (consumer_timeout, 1,800,000 ms).
RabbitMQ logged and force-closed the channel:
Consumer <N> on channel 5 has timed out waiting for delivery acknowledgement. Timeout used: 1800000 ms.
Channel error on connection ..., channel 5:
operation none caused a channel exception precondition_failed: delivery acknowledgement on channel 5 timed out.
This happened on 4 separate process replicas simultaneously (each independently exceeded the timeout at nearly the same moment).
None of the 4 processes crashed or restarted — their HTTP servers, other unrelated functionality, etc. all continued running normally. The TCP connection to RabbitMQ stayed up (confirmed via rabbitmqctl list_connections — connections present throughout).
The RabbitMQ queue for each of the 4 consumers silently disappeared from rabbitmqctl list_queues shortly after (its x-expires idle-timeout kicked in once nothing was consuming from it).
For the next ~16 hours, every message published to the exchange these queues were bound to was accepted by the broker (the exchange itself was fine) but had no bound queue to route to, and was silently dropped — no error, no dead-letter, no publisher-confirm failure (the publisher only confirms broker acceptance into the exchange, which succeeded every time).
The only fix was manually restarting all 4 processes (kubectl rollout restart), which re-ran the full rabbot.configure()/rabbot.handle() sequence from scratch and recreated working consumers.
Root cause, traced through rabbot's own source
src/queueFsm.js binds to the channel's closed event and transitions the FSM into its own closed state:
Note that check()would kick the FSM back into initializing (which re-runs _define/_listen/subscribe) — but nothing calls it automatically. subscribe() in the closed state just defers forever, waiting for a ready transition that never arrives on its own.
src/amqp/iomonad.js (the underlying channel abstraction) confirms a broker-initiated channel close is logged and transitions to closed:
channel.on('close',function(){
...
log.warn(...'closed by the broker with reason...');machine.transition('closed');});
Its own closed state's operate() only re-acquires on the next discrete RPC call (a publish/ack) — which never happens for an idle, long-lived consumer subscription that isn't actively being invoked.
The only application-facing recovery path is calling rabbot.getQueue(queueName).check() (or presumably .reconnect()), but:
This is never called automatically by rabbot itself in response to the queue's own closed event.
It isn't clearly documented as something a consumer of the library must wire up for correctness.
The only broker-level event rabbot surfaces at the top level (rabbot.on('unreachable', ...)) is connection-scoped and does not fire for this failure mode, since the connection never actually drops — only the channel does.
Expected behavior
One of:
(Preferred) rabbot's queue FSM automatically attempts recovery (re-declare, re-bind, re-subscribe) when its channel is closed by the broker, the same way a fresh configure() would establish it — mirroring what already happens for a full connection loss/reconnect.
At minimum, rabbot should emit a clearly-documented, prominent, queue-scoped event (e.g. rabbot.on('queue.closed', ...), analogous to the existing connection-level unreachable) that unambiguously tells the application "this queue's subscription is dead and will stay dead until you call .check()/.reconnect() yourself" — today this is only discoverable by reading queueFsm.js's source directly.
Actual behavior
The consumer dies permanently and silently. No error is thrown, no rejection surfaces anywhere in application code, no built-in event fires that a typical integration would already be listening to (the existing unreachable handler many apps already wire up does not cover this case). The only way to notice is external monitoring of the symptom (e.g. "downstream data stopped updating"), not the cause.
Suggested fixes (would be happy to help validate/PR)
Add an automatic recovery path from closed — e.g. closed._onEnter schedules a this.handle('check') (possibly with backoff), instead of requiring the application to notice and call it manually.
Regardless of (1), emit a clearly-named, documented event distinct from unreachable specifically for a queue-level channel closure, so applications that want to build their own recovery/alerting have something reliable to listen for.
Document this failure mode explicitly in the README's messaging-patterns/production-usage section — right now nothing in the docs suggests a channel (as opposed to the connection) can silently die and require manual recovery.
Related/possibly relevant prior art: Add support for clean teardown + reconstruction #35 ("Add support for clean teardown + reconstruction") looks adjacent but is about connection-level retry-limit exhaustion, not a channel-level broker-initiated close while the connection stays healthy — flagging in case it's useful context, but this looks like a distinct failure mode.
Happy to provide a minimal reproduction (a consumer that sleeps past consumer_timeout before acking) if useful.
Summary
When RabbitMQ force-closes a channel (not the whole connection) — e.g. via a
precondition_failedchannel exception such as a consumer ack-timeout — a queue's subscription dies permanently and silently. The queue FSM transitions to itsclosedstate and just sits there forever. There is no automatic recovery, no automatic re-subscription, and no documented signal telling the application it needs to do anything. The application's connection stays healthy throughout (rabbot.on('unreachable', ...)never fires, since the connection itself never drops), so nothing in a typical integration notices anything is wrong.In our case this caused complete, silent, unrecovered loss of message consumption for ~16 hours across every replica of a service, discovered only because a human happened to notice downstream data wasn't updating — not because of any error, log line, or alert.
Environment
2.1.0rabbot.handle('#', handler, queueName)+rabbot.configure({ exchanges, queues, bindings }),subscribe: trueon the queue definition. Nolimit(prefetch) set on the queue definition.What happened (production incident)
consumer_timeout, 1,800,000 ms).rabbitmqctl list_connections— connections present throughout).rabbitmqctl list_queuesshortly after (itsx-expiresidle-timeout kicked in once nothing was consuming from it).kubectl rollout restart), which re-ran the fullrabbot.configure()/rabbot.handle()sequence from scratch and recreated working consumers.Root cause, traced through rabbot's own source
src/queueFsm.jsbinds to the channel'sclosedevent and transitions the FSM into its ownclosedstate:The
closedstate itself does nothing to recover:Note that
check()would kick the FSM back intoinitializing(which re-runs_define/_listen/subscribe) — but nothing calls it automatically.subscribe()in theclosedstate just defers forever, waiting for areadytransition that never arrives on its own.src/amqp/iomonad.js(the underlying channel abstraction) confirms a broker-initiated channel close is logged and transitions toclosed:Its own
closedstate'soperate()only re-acquireson the next discrete RPC call(a publish/ack) — which never happens for an idle, long-lived consumer subscription that isn't actively being invoked.The only application-facing recovery path is calling
rabbot.getQueue(queueName).check()(or presumably.reconnect()), but:closedevent.rabbot.on('unreachable', ...)) is connection-scoped and does not fire for this failure mode, since the connection never actually drops — only the channel does.Expected behavior
One of:
configure()would establish it — mirroring what already happens for a full connection loss/reconnect.rabbot.on('queue.closed', ...), analogous to the existing connection-levelunreachable) that unambiguously tells the application "this queue's subscription is dead and will stay dead until you call.check()/.reconnect()yourself" — today this is only discoverable by readingqueueFsm.js's source directly.Actual behavior
The consumer dies permanently and silently. No error is thrown, no rejection surfaces anywhere in application code, no built-in event fires that a typical integration would already be listening to (the existing
unreachablehandler many apps already wire up does not cover this case). The only way to notice is external monitoring of the symptom (e.g. "downstream data stopped updating"), not the cause.Suggested fixes (would be happy to help validate/PR)
closed— e.g.closed._onEnterschedules athis.handle('check')(possibly with backoff), instead of requiring the application to notice and call it manually.unreachablespecifically for a queue-level channel closure, so applications that want to build their own recovery/alerting have something reliable to listen for.Happy to provide a minimal reproduction (a consumer that sleeps past
consumer_timeoutbefore acking) if useful.