Skip to content

fix: silent-stall RPC failures (DEVOP-594) - #152

Draft
gh-allora wants to merge 8 commits into
devfrom
fix/rpc-silent-stall
Draft

fix: silent-stall RPC failures (DEVOP-594)#152
gh-allora wants to merge 8 commits into
devfrom
fix/rpc-silent-stall

Conversation

@gh-allora

@gh-allora gh-allora commented May 13, 2026

Copy link
Copy Markdown

Fix RPC silent-stall failures (DEVOP-594)

Parent diagnostic: DEVOP-593
Fixes: DEVOP-594

What this fixes

~30% of testnet offchain-node pods (50/162 at time of investigation) had been silently stuck — Ready=True, restartCount=0, no error-level logs, but no successful tx in 24+ hours. Three compounding bugs prevented recovery from any RPC transport failure when only one gRPC endpoint is configured (which is true for all 467 production pods today).

Bug 1: ParseHTTPStatus regex never matched real gRPC errors

lib/errors.go:362 required the literal substring Status: to extract HTTP status codes. grpc-go surfaces upstream HTTP failures as unexpected HTTP status code received from server: NNN (Reason) — never matched. The 502/503/504-switching branch in triageHTTPStatusError was unreachable.

Bug 2: No string handler for common transport failures

connection reset by peer, unexpected EOF, and code = Unavailable desc = error reading from server: ... had zero handlers. All fell through to the info-level Unknown error catch-all in ProcessErrorTx and were treated as plain retryable errors → retry on the same dead conn → silent stall.

Bug 3: Single-endpoint switchToNodeLocked was a no-op

if len(nodes) == 1 {
    return &nodes[0], nil
}

Every deployed pod has exactly one nodeRpcs and one nodeGrpcs entry (verified across 467 snapshot configs in allora-offchain-config). So even if the switching path were reached, it accomplished nothing.

Changes (5 commits)

65a831c fix(errors): recognize gRPC transport-style HTTP status in ParseHTTPStatus
b32a4c7 fix(errors): treat gRPC transport failures as switching-node errors
61025f3 fix(errors): raise log level for unhandled RPC errors so alerts can fire
68b2d41 fix(connection): force re-dial when only one endpoint is configured
70204c6 fix(grpcclient): react to sustained Idle state and plug monitor leak
  1. ParseHTTPStatus now recognizes both Status: NNN and HTTP status code received from server: NNN [(Reason)] phrasings. 3 new tests for real Cloudflare-fronted gRPC error strings.
  2. isGRPCTransportError + ErrGRPCTransport (code 27): explicit handler in triageStringMatchingError for connection reset by peer, unexpected EOF, code = Unavailable desc = error reading from server: → returns ErrorProcessingSwitchingNode. IsErrorSwitchingNode updated.
  3. Log level fix: log.Info().Err() "Unknown error" → log.Error(). Per-retry log in QueryDataWithRetrylog.Warn(). Alerting keyed on level=error now fires for these cases.
  4. forceReconnectLocked replaces the single-node short-circuit. Rebuilds the chain client (GenerateNodeConfig), best-effort closes the old conn, replaces the NodeConfig slice entry in place under the existing write lock. GRPCReconnectionCount metric tracks success.
  5. monitorGRPCConnection also reacts to sustained Idle state (30s+); InitializeGRPCClient returns a context.CancelFunc so closeOld can stop the per-conn monitor cleanly — no goroutine leak per reconnect.

Verification done locally

  • go test ./... — all green
  • go test -race ./... — all green (lock invariants for switchToNodeLocked / forceReconnectLocked hold)
  • go build ./... — clean
  • TestParseHTTPStatus — 13 cases pass (3 new for gRPC phrasings)
  • TestIsGRPCTransportError — 8 cases pass

Rollout (suggested)

  1. Merge → tag new image (e.g. v0.13.3)
  2. Bump default tag in allora-internal-helm-charts/offchain-service
  3. Deploy one testnet canary pod. Watch for 24h:
    • goroutine count flat across reconnects (no leak)
    • GRPCReconnectionCount metric increments when Cloudflare-induced transport errors occur
    • time-since-last-tx SLI stays under 30 min
  4. Roll to full testnet
  5. Roll to mainnet after 24-48h testnet soak

Out of scope (tracked in parent DEVOP-593)

  • Adding a second gRPC fallback endpoint to allora-offchain-config
  • Prometheus alert rule for time_since_last_tx > 30m
  • Bypassing Cloudflare for chain gRPC traffic

Sibling PR

The same class of bugs (different shape) was found in allora-sdk-py. Companion PR: see DEVOP-595.


Summary by cubic

Fixes silent-stall RPC failures in allora-offchain-node by parsing gRPC/HTTP errors correctly, classifying transport failures, and forcing reconnects even with a single endpoint. Aligns with DEVOP-594 to restore automatic recovery and surface real errors for alerting.

  • Bug Fixes

    • ParseHTTPStatus now reads gRPC transport-style messages (e.g., “unexpected HTTP status code… 502/503/504”) and preserves the full reason phrase.
    • Detect connection reset by peer, unexpected EOF, and code = Unavailable desc = error reading from server as transport errors that trigger node switching (ErrGRPCTransport, code 27; included in IsErrorSwitchingNode).
    • Single-endpoint “switch” now force re-dials the same endpoint, closes the old client and monitor, and increments GRPCReconnectionCount.
    • gRPC monitor reacts to sustained Idle (30s), ticks every 5s; InitializeGRPCClient returns a cancel function to stop the monitor and prevent goroutine leaks.
    • Raise log levels: unknown errors to error; per-retry logs to warn.
  • Dependencies

    • Added .beads project metadata and updated .gitignore (no runtime impact).

Written for commit b5ecd9d. Summary will update on new commits.

Review in cubic

gh-allora added 5 commits May 13, 2026 16:54
…tatus

ParseHTTPStatus only matched 'Status: NNN' phrasing, which never appears in
real gRPC errors. grpc-go surfaces upstream HTTP failures as:

  rpc error: code = Unavailable desc = unexpected HTTP status code received
  from server: 502 (Bad Gateway); transport: ...

These slipped past the parser, caused triageHTTPStatusError to return no-op,
and led ProcessErrorTx to hit its info-level catch-all instead of routing the
error through the HTTPStatusCodeCodesSwitchingNode (502/503/504) handler.
Operationally this manifested as pods going silent: no successful tx, no
ERROR-level logs, just info-level rpc errors that no alerting rule fires on.

The new regex still accepts the legacy 'Status: NNN' form. Also fixes a pre-
existing bug where 'Internal Server Error - more details' truncated at the
first dash; the new pattern captures the full reason phrase up to a sentinel.

Tests: 3 new cases covering real Cloudflare-fronted gRPC error strings.
Existing 'wrong prefix' negative case retained — that one is still genuinely
invalid input that should not produce a code.
When a gRPC stream is half-closed by an upstream load balancer/CDN, grpc-go
surfaces errors in forms that don't carry a parseable HTTP status:

  rpc error: code = Unavailable desc = error reading from server: read tcp
  10.2.3.27:51484->104.26.0.124:443: read: connection reset by peer

  rpc error: code = Unavailable desc = unexpected EOF

  rpc error: code = Unavailable desc = error reading from server: stream
  terminated

Prior to this commit none of these matched any handler in ProcessErrorTx —
they fell through to the info-level 'Unknown error' catch-all, were treated
as regular retryable errors, and the same dead grpc.ClientConn was used on
every retry until the pod silently stopped submitting transactions. This
caused widespread silent stalls in production (see incident notes).

This adds isGRPCTransportError() which detects this class explicitly and
routes them through ErrorProcessingSwitchingNode, the same path that already
exists for 'connection refused' and HTTP 502/503/504. New ErrGRPCTransport
error (code 27) is also added to IsErrorSwitchingNode so the connection
manager's switching loop treats it as a recovery trigger.

The handler is intentionally narrow: 'Unavailable' alone is NOT classified
as a transport error because it commonly accompanies a parseable HTTP status
that the previous commit now extracts. Only the no-status-code variants are
caught here, with a corresponding test case asserting that boundary.

Tests cover all 4 production-observed forms plus 3 negative cases.
Two spots silently downgraded errors to info-level, making them invisible to
any alerting rule keyed on 'level=error' (which is virtually all of them):

  - lib/errors.go ProcessErrorTx fallback: 'Unknown error' was logged at info
    even though it returns ErrorProcessingError to the caller. This is the
    line that fires when an RPC error doesn't match any known signature,
    which in the silent-stall incident covered every 502/connection-reset
    flavour. Raised to ERROR. Message also clarified: 'Unknown error - no
    specific handler matched' makes the failure mode self-describing.

  - lib/repo_query_utils.go QueryDataWithRetry: 'Query failed, retrying...'
    was logged at info on every retry attempt. WARN is the right level:
    we are not yet at error (there are retries remaining and they may
    succeed) but we are also not in the happy path. Operationally this
    surfaces transient flakes on a metrics dashboard without spamming the
    error channel.

No behaviour change beyond log routing. ProcessErrorTx still returns the
same error code and processing instruction; QueryDataWithRetry still retries
the same number of times.
switchToNodeLocked previously short-circuited when len(nodes) == 1:

	if len(nodes) == 1 {
		return &nodes[0], nil
	}

This made every "switch to next node" call a no-op for single-endpoint
deployments — which is the configuration of every Allora offchain node
currently running in production (467/467 pods have exactly one nodeGrpcs
and one nodeRpcs entry). Combined with the silent-error issue fixed in the
preceding commits, this meant the recovery path that exists in theory
(error -> switching -> fresh dial) was never reachable for any pod.

This commit replaces the no-op with a forceReconnectLocked call that:
  - Rebuilds the chain client (gRPC or RPC) via GenerateNodeConfig
  - Best-effort closes the previous client to free pooled connections
  - Replaces the NodeConfig slice entry in place so existing callers
    that captured &nodes[i] observe the new client on next deref

Concurrency: forceReconnectLocked is a private method that must be called
under the same write lock that already protects switchToNodeLocked. Public
entry points (SwitchToNextQueryNode, SwitchToNextTxNode, SwitchToQueryNode,
SwitchToTxNode) all acquire that lock already, so no callers need to change.

Failure mode: if GenerateNodeConfig itself fails (endpoint down at the DNS
or TCP layer), forceReconnectLocked logs an error and returns the old
NodeConfig unchanged. That preserves the existing 'still broken, will
retry' semantics callers already handle. The metric GRPCReconnectionCount
is incremented on each successful re-dial so this is observable in Grafana.

Known limitation: the per-connection monitor goroutine in lib/grpcclient
that was watching the old grpc.ClientConn will continue ticking until the
process exits or the parent context is cancelled. It will try to reconnect
the closed conn and fail every 10s. This leaks a goroutine per reconnect
event but does not affect correctness — the closed conn is unreachable from
any caller. A follow-up commit in this PR addresses the monitor's
state-detection blind spot more thoroughly.
Two follow-on improvements to make the connection monitor a real safety net
instead of a partial one:

1. React to sustained Idle in monitorGRPCConnection

   grpc-go transitions a ClientConn to connectivity.Idle when the underlying
   HTTP/2 stream dies but no in-flight RPCs were affected. Prior to this
   commit the monitor only reacted to TransientFailure and Shutdown, so an
   Idle conn would sit there indefinitely and every new RPC would fail with
   Unavailable until the client gave up. We now also force a reconnect after
   30 seconds of continuous Idle (6 ticks * 5s). Brief Idle (just no traffic)
   is still tolerated.

   Tick interval also reduced from 10s to 5s so we catch upstream resets
   faster. The 30s Idle grace window absorbs the small amount of extra noise
   that comes from tighter polling.

2. Plug the monitor-goroutine leak that the previous commit acknowledged

   forceReconnectLocked in lib/connection_manager.go calls Close() on the old
   grpc.ClientConn but the per-connection monitor goroutine was still rooted
   to the parent (process-level) context. Every forced reconnect leaked one
   goroutine that would tick forever against the closed conn.

   InitializeGRPCClient now also returns a context.CancelFunc that stops the
   monitor cleanly. ChainConfig gains a GRPCMonitorCancel field so callers
   can invoke it without keeping a separate book of cancel funcs. closeOld
   calls the cancel before closing the conn, so the monitor exits, the conn
   is closed, and the goroutine count returns to zero after each reconnect.

API change: InitializeGRPCClient now returns (*grpc.ClientConn, CancelFunc,
error) instead of (*grpc.ClientConn, error). Only one in-tree caller
(GenerateNodeConfig in factory_config.go) needed updating. No exported
behaviour change beyond the new return slot.
@linear

linear Bot commented May 13, 2026

Copy link
Copy Markdown
DEVOP-594 Fix: allora-offchain-node silent-stall — gRPC error classification + single-endpoint recovery

allora-offchain-node — fix silent-stall RPC failures (PR draft ready)

Parent diagnostic: DEVOP-593 (DEVOP-593)

Scope

Code-only fix to allora-network/allora-offchain-node Go service that runs as the node container in all 467 production offchain pods. No helm chart, flux, or config changes.

Branch

fix/rpc-silent-stall (local in ~/src/allora-network/allora-offchain-node) — 5 commits, ~282 LoC across 7 files, all tests + race detector pass. Not pushed pending review.

65a831c fix(errors): recognize gRPC transport-style HTTP status in ParseHTTPStatus
b32a4c7 fix(errors): treat gRPC transport failures as switching-node errors
61025f3 fix(errors): raise log level for unhandled RPC errors so alerts can fire
68b2d41 fix(connection): force re-dial when only one endpoint is configured
70204c6 fix(grpcclient): react to sustained Idle state and plug monitor leak

What changes

  1. ParseHTTPStatus now recognizes both Status: NNN and HTTP status code received from server: NNN [(Reason)] phrasings. 3 new test cases for real Cloudflare-fronted gRPC error strings.
  2. New isGRPCTransportError helper + ErrGRPCTransport error code (27); explicit handler in triageStringMatchingError for connection reset by peer, unexpected EOF, code = Unavailable desc = error reading from server: → returns ErrorProcessingSwitchingNode. IsErrorSwitchingNode updated.
  3. log.Info().Err(err).Msg("Unknown error") log.Error(), and the per-retry log in QueryDataWithRetrylog.Warn() (was info). Existing alerting rules keyed on level=error will now fire.
  4. Replaced the single-node short-circuit in switchToNodeLocked with forceReconnectLocked — rebuilds the chain client (GenerateNodeConfig), best-effort closes the old conn, replaces the NodeConfig slice entry in place under the existing write lock. GRPCReconnectionCount metric tracks success rate.
  5. monitorGRPCConnection now also reacts to sustained Idle state (30s+); InitializeGRPCClient returns a context.CancelFunc so closeOld can stop the per-conn monitor goroutine cleanly — no leak per reconnect.

Verification done

  • go test ./... — all green
  • go test -race ./... — all green (lock invariants for switchToNodeLocked/forceReconnectLocked hold)
  • go build ./... — clean
  • TestParseHTTPStatus 13 cases pass (3 new for gRPC phrasings)
  • TestIsGRPCTransportError 8 cases pass

Rollout

  1. Push branch + open PR against dev
  2. Merge → tag new image (e.g. v0.13.3)
  3. Update helm chart in allora-internal-helm-charts/offchain-service to bump default tag
  4. Deploy one testnet canary pod, watch for 24h: goroutine count flat across reconnects, GRPCReconnectionCount metric incrementing, time-since-last-tx SLI stays under 30 min
  5. Roll to full testnet
  6. Roll to mainnet after 24-48h testnet soak

Out of scope for this ticket

  • Adding a second gRPC fallback endpoint to allora-offchain-config (tracked in parent diagnostic)
  • Prometheus alert rule for time_since_last_tx > 30m (tracked in parent diagnostic)
  • Bypassing Cloudflare for chain gRPC traffic (tracked in parent diagnostic)

Review in Linear

@gh-allora gh-allora left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Self-review of fix/rpc-silent-stall

Reviewed by Hermes Agent at author's request. Verdict: conditional approve — code is correct and well-tested; one real bug + one architectural concern below that should be addressed before merge to mainnet (testnet canary can proceed with current code).

⚠️ Warnings

  • lib/connection_manager.go:270forceReconnectLocked calls GenerateNodeConfig with context.Background(). The monitor goroutine spawned inside InitializeGRPCClient derives its lifetime from this context. If the process's parent context is later cancelled (graceful shutdown, SIGTERM), the original connection's monitor exits cleanly via closeOld's GRPCMonitorCancel, but any monitors spawned from reconnects continue running until process exit. The cancel-chain only stops on the next reconnect. Suggested fix: add a ctx context.Context field to ConnectionManager (saved during NewConnectionManager) and pass that here instead of Background(). Or document explicitly that reconnect monitors are process-lifetime by design and not tied to a parent.

  • lib/grpcclient/grpc_connection.go:96 — Treating sustained connectivity.Idle (30s) the same as TransientFailure is correct for our use case (we set PermitWithoutStream: true, so a healthy keepalive should prevent Idle entirely), but modern grpc-go also has a separate IdleTimeout dial option (default 30 min in 1.55+) that can independently transition the conn to Idle. If that fires after low-traffic periods, the monitor will now force a reconnect every 30 min on otherwise-healthy connections. Worth confirming the in-use grpc-go version's IdleTimeout default, or explicitly setting grpc.WithIdleTimeout(0) to disable it.

💡 Suggestions

  • lib/errors.go:319 — The new isGRPCTransportError branch is wedged between ErrorMessageConnectionRefused and ErrorReputerNonceWindowNotAvailable. Ordering is currently correct (none of the transport-error substrings overlap with adjacent matches), but the order-dependence is invisible from the surrounding code. Consider extracting the long if/else if chain into a table-driven dispatch, or at minimum add a comment at the top of triageStringMatchingError noting that branch ordering matters and adding a new branch requires checking adjacency.

  • lib/errors.go:362 — The new regex captures more of the reason phrase than the old one did. Existing test case "With extra text" was updated from expected "Internal Server Error" to "Internal Server Error - more details" to reflect this. That's a beneficial behavior change but also a silent change to the public function's return value for inputs the old regex already matched. If anything downstream depends on the truncated form (substring matching on the returned message?), that consumer breaks. grep -r 'ParseHTTPStatus' ./... returns only the call site in triageHTTPStatusError and the tests, so the practical risk is zero — but worth noting in the PR description so reviewers don't miss it.

  • lib/connection_manager.go:280 — Minor: the in-place slice mutation nodes[index] = *newNode is correct under the existing write-lock invariants, but a reader of the code in 6 months will have to derive that invariant from convention. A one-line // Caller holds queryMu/txMu — see switchToNodeLocked comment above the assignment would save them the trip.

✅ Looks good

  • The GRPCMonitorCancel field + cancel-before-close ordering in closeOld cleanly solves the goroutine-leak class. Linear flow (cancel → close → nil) is easy to verify.
  • isGRPCTransportError has both a positive substring set AND a negative test ("Unavailable alone is NOT a transport error") — the boundary between this handler and the HTTP-status handler is asserted, not implicit.
  • Log-level changes (info → error on catch-all, info → warn on retry) are the minimum change needed to make existing level=error alerting rules work.
  • The regex change preserves all 5 of the original TestParseHTTPStatus cases AND adds 3 real-production gRPC strings. Negative cases (Status: Not Found, Error: 404 Not Found, empty, abc, -404) are retained.
  • Race detector clean across the whole package — the in-place NodeConfig replacement in forceReconnectLocked is the kind of code that often surfaces races, and the test suite doesn't trip them.

Self-review at author's request. Recommended action: address the context.Background() warning before mainnet rollout (testnet canary OK as-is). Suggestions are non-blocking polish.

Epic allora-offchain-node-l0o tracks the migration of the hand-rolled
chain-client layer to allora-sdk-go. 4 glm-5.2 subagents produced migration
specs for surfaces A (query repos), B (tx path), C (connection layer),
D (errors+usecase) against sdk snapshot at a4f33a6.

3 blockers identified (all 4 lanes converged):
- l0o.5: SendOptions.TimeoutHeight missing in sdk (blocks surface B Shape B)
- l0o.6: single-endpoint force-re-dial gap (sdk pool never re-dials; prod
  467/467 pods are single-endpoint)
- l0o.7: feemarket module not in sdk (hybrid side-channel interim)

2 follow-ups:
- l0o.8: double gas-adjustment bug in sdk
- l0o.9: adapter must re-log sdk transport failures at Error for alerting parity

Partition boundaries locked for wave-2:
- A: 12 lib/repo_query_*.go files (disjoint)
- B: lib/repo_tx_utils.go + repo_query_simulate.go
- C: lib/connection_manager.go + grpcclient/ + rpcclient/ + factory_config + domain_config
- D: lib/errors.go scope trim + usecase/*.go wiring (option c: adapter wraps *allora.Client)
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