Skip to content

fix: recover from a channel closed by the broker - #25

Merged
KARTIKrocks merged 2 commits into
mainfrom
fix/channel-level-recovery
Aug 1, 2026
Merged

fix: recover from a channel closed by the broker#25
KARTIKrocks merged 2 commits into
mainfrom
fix/channel-level-recovery

Conversation

@KARTIKrocks

@KARTIKrocks KARTIKrocks commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

A channel-level exception closes the channel while the connection stays healthy, so no reconnect signal is ever produced. Publishing to a missing exchange is the easy way to hit it, and the offending publish usually returns nil because the broker's 404 arrives asynchronously.

The publisher was then left with a dead channel for the rest of the process: every later publish failed with 504 channel/connection is not open. The consumer recovered, but only when the consume loop's retry timer fired up to five seconds later.

Both now register a NotifyClose watcher per established channel and re-establish as soon as the broker closes one. The watcher reports a death only while its channel is still the current one, so a channel replaced by a later setup and the graceful close done by Close do not trigger re-establishment; deaths coalesce into one pending signal. A channel that dies before its watcher registers is caught directly, since NotifyClose reports nothing once the channel is already shutting down.

The publisher also closes the channel it replaces, as the consumer already did. That leaked before whenever a channel was replaced on a live connection, which this recovery makes routine.

Consumer recovery stays owned by the consume loop, so it applies while Start/Consume is running; the retry timer remains for failures no channel close can signal, such as a deleted queue.

Motivation

Fixes #

Changes

Checklist

  • fmt, vet, lint, test, build passes (make all)
  • New code has tests where appropriate
  • Breaking changes are documented

Summary by CodeRabbit

  • New Features

    • Added automatic publisher and consumer channel recovery after broker-initiated channel closures.
    • Declarative topology is reapplied when channels are re-established.
    • Recovery occurs promptly without requiring the underlying connection to reconnect.
  • Bug Fixes

    • Prevented resource leaks when replacing closed publisher channels.
    • Clarified that failed publishes are not replayed and documented detection requirements for asynchronous failures.
  • Documentation

    • Added channel recovery guidance and release notes for version 0.12.0.

A channel-level exception closes the channel while the connection stays
healthy, so no reconnect signal is ever produced. Publishing to a missing
exchange is the easy way to hit it, and the offending publish usually returns
nil because the broker's 404 arrives asynchronously.

The publisher was then left with a dead channel for the rest of the process:
every later publish failed with 504 channel/connection is not open. The
consumer recovered, but only when the consume loop's retry timer fired up to
five seconds later.

Both now register a NotifyClose watcher per established channel and
re-establish as soon as the broker closes one. The watcher reports a death
only while its channel is still the current one, so a channel replaced by a
later setup and the graceful close done by Close do not trigger
re-establishment; deaths coalesce into one pending signal. A channel that dies
before its watcher registers is caught directly, since NotifyClose reports
nothing once the channel is already shutting down.

The publisher also closes the channel it replaces, as the consumer already
did. That leaked before whenever a channel was replaced on a live connection,
which this recovery makes routine.

Consumer recovery stays owned by the consume loop, so it applies while
Start/Consume is running; the retry timer remains for failures no channel
close can signal, such as a deleted queue.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@KARTIKrocks, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 45 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dfe00bf8-c7ef-42ad-b513-97919440b147

📥 Commits

Reviewing files that changed from the base of the PR and between d26bf54 and 12e020e.

📒 Files selected for processing (5)
  • README.md
  • consumer.go
  • integration_test.go
  • publisher.go
  • rabbitmq.go

Walkthrough

The library now detects broker-closed publisher and consumer channels and re-establishes them without reconnecting the underlying connection. It adds per-channel close watchers, immediate recovery signals, replacement-channel cleanup, integration tests, and documentation for recovery behavior.

Changes

Channel Recovery

Layer / File(s) Summary
Publisher channel recovery
publisher.go, integration_test.go
Publishers detect broker-closed channels, coalesce recovery signals, close replaced channels, distinguish recovery causes, and resume publishing after asynchronous channel failures.
Consumer channel recovery
consumer.go, integration_test.go
Consumers detect current channel closures, ignore stale or graceful closures, trigger immediate re-establishment, and resume consumption without reconnecting the connection.
Recovery contract and documentation
README.md, CHANGELOG.md
Documentation describes topology restoration, failed publish behavior, asynchronous error detection, retry behavior, and channel recovery scope.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Publisher
  participant Consumer
  participant RabbitMQ
  participant Connection
  RabbitMQ-->>Publisher: closes publisher channel
  Publisher->>Connection: re-establishes publisher channel
  RabbitMQ-->>Consumer: closes consumer channel
  Consumer->>Connection: re-establishes consumer channel
  Connection-->>Publisher: publishing resumes
  Connection-->>Consumer: consumption resumes
Loading

Possibly related PRs

Poem

Channels close, then rise anew,
Publishers resume what they do.
Consumers wait for signals bright,
RabbitMQ restores the flow just right.
No replayed publish slips from view.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: recovery from broker-closed channels.
Description check ✅ Passed The description clearly explains the problem, motivation, implementation, recovery behavior, and resource-leak fix, despite an incomplete Changes list and unchecked checklist.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/channel-level-recovery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 62 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
publisher.go 0.00% 35 Missing ⚠️
consumer.go 0.00% 27 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes two longstanding gaps in channel lifecycle management: publishers were left permanently holding a dead channel after any broker-initiated channel close (e.g. a 404 on a missing exchange), and consumers recovered only after the 5-second retry timer rather than immediately.

  • watchChannelClose in rabbitmq.go: A new shared helper registers a per-channel goroutine via NotifyClose. Deaths coalesce into a buffered chDeadCh channel; a stale signal (from a connection loss that also kills the channel) is filtered by an isCurrent predicate that checks pointer identity and closed state.
  • Publisher (publisher.go): handleReconnect now has a chDeadCh arm with an explicit channelDead() staleness guard to avoid closing a freshly established channel mid-confirm. Replaced channels are also closed now, fixing a resource leak.
  • Consumer (consumer.go): waitForReconnect gains a chDeadCh arm without a staleness guard — every arm calls setupChannel unconditionally, so a stale signal costs only an extra channel open/close cycle with no incorrect state.

Confidence Score: 5/5

Safe to merge — the channel recovery logic is well-designed, edge cases (stale signals, graceful closes, TOCTOU on IsClosed) are all handled, and three integration tests exercise the new paths end-to-end.

The stale-signal problem flagged in the previous review is now handled: the publisher's chDeadCh arm checks channelDead() before acting, and the consumer's unconditional setupChannel() on every arm makes a stale signal harmless. Replaced channels are now closed, godoc on both DeclareExchange methods is updated, and the previous comments are all addressed. No new correctness issues were found.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
rabbitmq.go Adds watchChannelClose — the core mechanism that registers a per-channel goroutine to detect broker-initiated closes and signal re-establishment via a buffered dead channel.
publisher.go Adds chDeadCh field, wires watchChannelClose into setupChannel, and adds a chDeadCh arm to handleReconnect with an explicit channelDead() staleness guard; also closes replaced channels to fix a resource leak.
consumer.go Adds chDeadCh field, wires watchChannelClose into setupChannel, and adds a chDeadCh arm to waitForReconnect; the consumer intentionally skips the staleness guard because every arm of waitForReconnect calls setupChannel unconditionally.
integration_test.go Adds three integration tests: publisher channel-exception recovery, stale-signal guard for the publisher, and consumer channel-exception recovery with retryDelay set to 10 min to prove the watcher — not the timer — drives recovery.
CHANGELOG.md Adds v0.12.0 release notes documenting the channel-recovery fix and the publisher channel-leak fix.
README.md Adds a Channel Recovery section explaining what triggers recovery, its scope limitations, and caveats about async 404 returns and confirms vs routing.

Sequence Diagram

sequenceDiagram
    participant Broker
    participant Channel
    participant watchChannelClose goroutine
    participant chDeadCh (buf=1)
    participant handleReconnect / waitForReconnect

    Broker->>Channel: Channel-level exception (e.g. 404 NOT_FOUND)
    Channel-->>watchChannelClose goroutine: closeCh <- amqpError (non-nil)
    watchChannelClose goroutine->>watchChannelClose goroutine: isCurrent(ch)? yes
    watchChannelClose goroutine->>chDeadCh (buf=1): signal (non-blocking)
    chDeadCh (buf=1)->>handleReconnect / waitForReconnect: wakes select arm

    alt Publisher path
        handleReconnect / waitForReconnect->>handleReconnect / waitForReconnect: channelDead()? yes -> proceed
        handleReconnect / waitForReconnect->>Channel: setupChannel() -> new channel
        handleReconnect / waitForReconnect->>watchChannelClose goroutine: register new watcher
    else Consumer path
        handleReconnect / waitForReconnect->>Channel: setupChannel() -> new channel (unconditional)
        handleReconnect / waitForReconnect->>watchChannelClose goroutine: register new watcher
    end

    Note over chDeadCh (buf=1): Stale signal scenario
    Broker->>Channel: Connection loss (kills channel + fires reconnectCh)
    Channel-->>watchChannelClose goroutine: both chDeadCh and reconnectCh signaled
    handleReconnect / waitForReconnect->>Channel: reconnectCh arm wins -> setupChannel() first
    Note over handleReconnect / waitForReconnect: chDeadCh arm fires next
    handleReconnect / waitForReconnect->>handleReconnect / waitForReconnect: Publisher: channelDead()? no -> continue (skip)
    handleReconnect / waitForReconnect->>handleReconnect / waitForReconnect: Consumer: setupChannel() (harmless extra cycle)
Loading

Reviews (2): Last reviewed commit: "fix: ignore a stale channel-death signal..." | Re-trigger Greptile

@KARTIKrocks

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@consumer.go`:
- Around line 375-419: Extract the duplicated channel-death watching and
signaling into one package-level watchChannelClose helper, preserving the
existing closed-channel, broker-error, current-channel, and nonblocking
notification behavior. In consumer.go lines 375-419, remove
Consumer.watchChannelClose and Consumer.signalChannelDead, then call the helper
from setupChannel with a c.mu-protected isCurrent closure. Apply the equivalent
change in publisher.go lines 231-275 using p.mu and the publisher-specific dead
channel, logger, and log prefix.

In `@integration_test.go`:
- Around line 2993-3013: Update the recovery assertions in the channel-exception
test to verify connection identity rather than calling conn.IsHealthy(). Before
triggering the exception, capture the underlying connection pointer using the
existing conn.mu/conn.conn pattern demonstrated by the nearby test, then after
publishing succeeds, capture it again and assert both pointers are identical
while retaining the health check before recovery.

In `@README.md`:
- Around line 165-167: Update the README guidance on publishing to distinguish
broker confirms from routing success: remove publisher confirms as a
routing-detection option, state that missing exchanges produce an asynchronous
channel-level NOT_FOUND, and recommend Mandatory with NotifyReturn for detecting
unroutable messages when an exchange exists without a matching queue.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 44f91d86-4277-4328-9a8e-3c5a4f7b0fb0

📥 Commits

Reviewing files that changed from the base of the PR and between 0d696f4 and d26bf54.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • README.md
  • consumer.go
  • integration_test.go
  • publisher.go

Comment thread consumer.go Outdated
Comment thread integration_test.go
Comment thread README.md Outdated
A connection loss signals both a reconnect and a channel death, so
handleReconnect wakes on whichever arm it selects first, re-establishes
the channel, and then reads the other signal — which by then refers to a
channel that is already gone. Acting on it closed the channel just
established, failing any confirm in flight on it.

The dead-channel arm now checks whether the current channel is really
dead instead of trusting the signal. The consumer's waitForReconnect
keeps its signal as-is: it only runs when the consume loop needs a
channel, so every arm ends in the same setup and a leftover signal costs
nothing.

Also fold the duplicated per-channel close watcher into one package-level
watchChannelClose, correct the DeclareExchange docs that still said the
channel is only re-established on connection recovery, and separate
publisher confirms from routing in the README: a confirm means the broker
accepted the message, not that it reached a queue.
@KARTIKrocks
KARTIKrocks merged commit b9fed5b into main Aug 1, 2026
12 checks passed
@KARTIKrocks
KARTIKrocks deleted the fix/channel-level-recovery branch August 1, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants