fix: silent-stall RPC failures (DEVOP-594) - #152
Conversation
…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.
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) ScopeCode-only fix to Branch
What changes
Verification done
Rollout
Out of scope for this ticket
|
gh-allora
left a comment
There was a problem hiding this comment.
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:270 —
forceReconnectLockedcallsGenerateNodeConfigwithcontext.Background(). The monitor goroutine spawned insideInitializeGRPCClientderives its lifetime from this context. If the process's parent context is later cancelled (graceful shutdown, SIGTERM), the original connection's monitor exits cleanly viacloseOld'sGRPCMonitorCancel, but any monitors spawned from reconnects continue running until process exit. The cancel-chain only stops on the next reconnect. Suggested fix: add actx context.Contextfield toConnectionManager(saved duringNewConnectionManager) and pass that here instead ofBackground(). 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 asTransientFailureis correct for our use case (we setPermitWithoutStream: true, so a healthy keepalive should prevent Idle entirely), but modern grpc-go also has a separateIdleTimeoutdial option (default 30 min in 1.55+) that can independently transition the conn toIdle. 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'sIdleTimeoutdefault, or explicitly settinggrpc.WithIdleTimeout(0)to disable it.
💡 Suggestions
-
lib/errors.go:319 — The new
isGRPCTransportErrorbranch is wedged betweenErrorMessageConnectionRefusedandErrorReputerNonceWindowNotAvailable. 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 longif/else ifchain into a table-driven dispatch, or at minimum add a comment at the top oftriageStringMatchingErrornoting 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 intriageHTTPStatusErrorand 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] = *newNodeis 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 switchToNodeLockedcomment above the assignment would save them the trip.
✅ Looks good
- The
GRPCMonitorCancelfield + cancel-before-close ordering incloseOldcleanly solves the goroutine-leak class. Linear flow (cancel → close → nil) is easy to verify. isGRPCTransportErrorhas 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=erroralerting rules work. - The regex change preserves all 5 of the original
TestParseHTTPStatuscases 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
NodeConfigreplacement inforceReconnectLockedis 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)
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:
ParseHTTPStatusregex never matched real gRPC errorslib/errors.go:362required the literal substringStatus:to extract HTTP status codes. grpc-go surfaces upstream HTTP failures asunexpected HTTP status code received from server: NNN (Reason)— never matched. The 502/503/504-switching branch intriageHTTPStatusErrorwas unreachable.Bug 2: No string handler for common transport failures
connection reset by peer,unexpected EOF, andcode = Unavailable desc = error reading from server: ...had zero handlers. All fell through to the info-levelUnknown errorcatch-all inProcessErrorTxand were treated as plain retryable errors → retry on the same dead conn → silent stall.Bug 3: Single-endpoint
switchToNodeLockedwas a no-opEvery deployed pod has exactly one
nodeRpcsand onenodeGrpcsentry (verified across 467 snapshot configs inallora-offchain-config). So even if the switching path were reached, it accomplished nothing.Changes (5 commits)
ParseHTTPStatusnow recognizes bothStatus: NNNandHTTP status code received from server: NNN [(Reason)]phrasings. 3 new tests for real Cloudflare-fronted gRPC error strings.isGRPCTransportError+ErrGRPCTransport(code 27): explicit handler intriageStringMatchingErrorforconnection reset by peer,unexpected EOF,code = Unavailable desc = error reading from server:→ returnsErrorProcessingSwitchingNode.IsErrorSwitchingNodeupdated.log.Info().Err()"Unknown error" →log.Error(). Per-retry log inQueryDataWithRetry→log.Warn(). Alerting keyed onlevel=errornow fires for these cases.forceReconnectLockedreplaces the single-node short-circuit. Rebuilds the chain client (GenerateNodeConfig), best-effort closes the old conn, replaces theNodeConfigslice entry in place under the existing write lock.GRPCReconnectionCountmetric tracks success.monitorGRPCConnectionalso reacts to sustainedIdlestate (30s+);InitializeGRPCClientreturns acontext.CancelFuncsocloseOldcan stop the per-conn monitor cleanly — no goroutine leak per reconnect.Verification done locally
go test ./...— all greengo test -race ./...— all green (lock invariants forswitchToNodeLocked/forceReconnectLockedhold)go build ./...— cleanTestParseHTTPStatus— 13 cases pass (3 new for gRPC phrasings)TestIsGRPCTransportError— 8 cases passRollout (suggested)
v0.13.3)allora-internal-helm-charts/offchain-serviceGRPCReconnectionCountmetric increments when Cloudflare-induced transport errors occurtime-since-last-txSLI stays under 30 minOut of scope (tracked in parent DEVOP-593)
allora-offchain-configtime_since_last_tx > 30mSibling 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-nodeby 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
ParseHTTPStatusnow reads gRPC transport-style messages (e.g., “unexpected HTTP status code… 502/503/504”) and preserves the full reason phrase.connection reset by peer,unexpected EOF, andcode = Unavailable desc = error reading from serveras transport errors that trigger node switching (ErrGRPCTransport, code 27; included inIsErrorSwitchingNode).GRPCReconnectionCount.InitializeGRPCClientreturns a cancel function to stop the monitor and prevent goroutine leaks.Dependencies
.beadsproject metadata and updated.gitignore(no runtime impact).Written for commit b5ecd9d. Summary will update on new commits.