Skip to content

fix(network): close idle peer connections, back off on already connected, lower maxbackoff - #4467

Open
stevenvegt wants to merge 3 commits into
masterfrom
fix/network-idle-timeout-and-already-connected-backoff
Open

fix(network): close idle peer connections, back off on already connected, lower maxbackoff#4467
stevenvegt wants to merge 3 commits into
masterfrom
fix/network-idle-timeout-and-already-connected-backoff

Conversation

@stevenvegt

@stevenvegt stevenvegt commented Sep 2, 2026

Copy link
Copy Markdown
Member

Problem

A node kept logging this every few seconds for the same four peers, and never got connected to them:

level=warning msg="Peer connection error" error="rpc error: code = Unknown desc = already connected"

Two things went wrong at once:

  • The remote side (in our case an nginx in front of the node, but a half-open TCP connection does the same) kept a stream open for our node after our node had already dropped it. The remote therefore thought it was still connected to us and rejected every new connection. Nothing ever cleaned that up short of restarting the proxy.
  • Our node treated that rejection as a clean disconnect and reconnected after 1 to 5 seconds, forever. The backoff never kicked in.

Changes

Idle timeout on connections (network.idletimeout, default 2m, 0 disables). Peers send gossip and diagnostics every 5 seconds on every stream, so a stream with no incoming message for two minutes is dead. The node now closes it and the regular reconnect logic takes over. Time spent handling a message (a large transaction list during sync, for example) does not count as idle. This works through reverse proxies, unlike gRPC keepalive pings, which proxies answer themselves.

Back off on "already connected". The rejection is now handled like an authentication failure: exponential backoff instead of a retry every few seconds. Fixing this exposed a race between the receive loop storing the peer's close status and the stream context cancelling the connection, which also affected the existing Unauthenticated path. The close status is now read only after the gRPC connection is closed and the receive loops have exited.

network.maxbackoff default lowered from 24h to 1h. The backoff is persisted across restarts and only reset when a peer's NutsComm address changes, so a peer that was unreachable for a few days could go unattempted for up to a day after it came back.

Tests

All new behavior was written test-first. New tests in connection_test.go and connection_manager_test.go:

  • a client rejected with "already connected" calls Backoff() and not Reset()
  • a silent stream is disconnected after the idle timeout
  • a stream that keeps delivering messages stays connected
  • a handler that blocks longer than the idle timeout does not trigger a disconnect
  • a zero idle timeout disables the check
  • TestDefaultConfig asserts the new maxbackoff and idletimeout defaults

go test -race ./network/... passes.

Operational notes

For deployments behind nginx, grpc_read_timeout and grpc_send_timeout at their 60s default (not raised to hours) plus so_keepalive on the listen directive let the proxy drop dead streams on its own as well.

…nected

Connections to peers on which no message is received for a configurable
period (network.idletimeout, default 2 minutes) are now closed, after
which the regular reconnect logic takes over. Peers send gossip and
diagnostics messages every few seconds, so a silent connection is dead:
a half-open TCP connection or a reverse proxy that kept the stream open
after the other side went away. Such connections previously lingered
until the proxy or node was restarted, and the peer holding the stale
connection rejected every new connection attempt with "already
connected". The idle check pauses while a message is being handled, so
long-running handling (e.g. a large transaction list during sync) is not
mistaken for silence.

An "already connected" rejection was treated as a clean disconnect and
retried every 1 to 5 seconds, bypassing the backoff. It now goes through
the exponential backoff like an authentication failure. To make that
decision reliable, openOutboundStreams closes the gRPC client connection
and waits for the receive loops to exit before reading the close status,
which was previously racing with the stream context cancellation.

Assisted-by: AI
The backoff towards an unreachable peer grows by 1.5x per failed
attempt, is persisted across restarts and is only reset when the peer's
NutsComm address changes. A peer that was unreachable for a few days
could therefore go unattempted for up to a day after it came back. One
extra TLS handshake per hour per dead peer is a negligible price for
recovering within the hour.

Assisted-by: AI
@qltysh

qltysh Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

0 new issues

Tool Category Rule Count

@qltysh

qltysh Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (7)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
network/cmd/cmd.go100.0%
Coverage rating: B Coverage rating: B
network/network.go100.0%
Coverage rating: A Coverage rating: A
network/config.go100.0%
Coverage rating: B Coverage rating: B
network/transport/grpc/connection.go95.5%272-274
Coverage rating: A Coverage rating: A
network/transport/grpc/connection_list.go100.0%
Coverage rating: B Coverage rating: C
network/transport/grpc/config.go16.7%112-116
Coverage rating: A Coverage rating: A
network/transport/grpc/connection_manager.go100.0%
Total91.1%
🤖 Increase coverage with AI coding...
In the `fix/network-idle-timeout-and-already-connected-backoff` branch, add test coverage for this new code:

- `network/transport/grpc/config.go` -- Line 112-116
- `network/transport/grpc/connection.go` -- Line 272-274

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

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.

1 participant