fix(db): retry on ECONNREFUSED — stop exiting 1 on a Postgres endpoint gap (BLO-21615) - #12
fix(db): retry on ECONNREFUSED — stop exiting 1 on a Postgres endpoint gap (BLO-21615)#12allyblockcast[bot] wants to merge 1 commit into
Conversation
…t gap (BLO-21615)
`gbrain serve` has had connect retry with exponential backoff since v0.21,
but it never fired for the most common transient failure in a container
environment, because that error was classified as permanent.
`connect ECONNREFUSED <ip>:<port>` is the kernel's connect(2) refusal. It is
a different string from Postgres's own `connection refused` prose and was in
neither retry list, so `isRetryableDbConnectError` returned false,
`connectWithRetry` rethrew on attempt 1, and the process exited 1.
On Kubernetes that is what kube-proxy REJECTs with for the entire duration
of any database pod restart, so every DB blip became a container restart:
125 restarts over 17 days on the gbrain-mcp `admin-ui` container, each a
four-second life ending in the single line
Cannot connect to database: connect ECONNREFUSED 10.99.216.174:5432.
Fix: Check your connection URL in ~/.gbrain/config.json
The retry machinery was always correct and always wired in. Only the
classification was wrong.
Two changes:
- Add /ECONNREFUSED/i to CONN_PATTERNS in retry-matcher.ts.
- Delegate db.ts:isRetryableDbConnectError to retry-matcher.ts. db.ts kept
its own inline copy of the pattern list — the exact drift retry-matcher.ts
was written to end — and it had fallen five patterns behind: no
ECONNREFUSED, no 08xxx SQLSTATE class, no CONNECTION_ENDED, no 53300. So
connect-time retry recovered from strictly fewer conditions than every
other retry site. There is now one list, not two.
Tests pin the exact production error string at both levels: the matcher, and
connectWithRetry surviving a two-attempt refusal window. Both fail on the
pre-change source (connectWithRetry throws on attempt 1) and pass after.
Permanent errors — missing extension, missing relation, syntax error — are
still not retried.
Co-Authored-By: Claude <noreply@anthropic.com>
|
🔗 Paperclip issue: BLO-21615 |
1 similar comment
|
🔗 Paperclip issue: BLO-21615 |
|
@ally please review at head b346fc6. Small diff, but three things are worth your scrutiny:
Context: this is the root cause of 125 restarts/17d on the |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Critical Issues (0)Important Issues (1)
Suggestions (2)
Strengths
Recommended Action
This PR is authored by |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b346fc6
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- Centralizing the connect-error predicate removes the duplicate, already-drifted retry list while retaining the existing public export.
- Regression coverage exercises both the matcher and the actual retry loop using the production-shaped refusal message.
- The project CI matrix is green, including mechanical, LLM-skill, serial, sharded, and verification checks.
Recommended Action
- Safe to merge from this review perspective.
The bug
gbrain servehas had connect retry with exponential backoff since v0.21. It never fired for the most common transient failure in a container environment, because that error was classified as permanent.connect ECONNREFUSED <ip>:<port>is the kernel'sconnect(2)refusal as surfaced by Node/Bun. It is a different string from Postgres's ownconnection refusedprose, and it was in neither retry list:So
isRetryableDbConnectErrorreturnedfalse,connectWithRetryrethrew on attempt 1, and the process exited 1.On Kubernetes,
ECONNREFUSEDon a ClusterIP is exactly what kube-proxy REJECTs with while a Service has zero ready endpoints — i.e. for the entire duration of any database pod restart. So every DB blip became a container restart.Production impact
gbrain-mcppod,admin-uicontainer: 125 restarts over 17 days. Each incarnation lived ~4 seconds and logged one line:That is
GBrainError's composed${problem}: ${cause_description}. Fix: ${fix}fromdb.tsconnect(), propagating out ofconnectWithRetryuncaught. The absence of any[connect] attempt N failed … retryingline in those logs is the tell: retry never engaged.gbrain is a cross-run memory layer, so each restart landed as
Unable to connecton whichever writer was mid-write.The retry machinery was always correct and always wired in (
connectEngine()callsconnectWithRetry). Only the classification was wrong. The issue this came from hypothesised "no retry or backoff" — that turned out to be wrong, and the real defect is narrower and cheaper to fix.The fix
/ECONNREFUSED/itoCONN_PATTERNSinretry-matcher.ts.db.ts:isRetryableDbConnectErrortoretry-matcher.ts.db.tskept its own inline 5-pattern list — the exact driftretry-matcher.tswas written to end, per its own docstring:db.tswas never migrated, and had fallen five patterns behind: noECONNREFUSED, no08xxxSQLSTATE class, noCONNECTION_ENDED, no53300. Connect-time retry therefore recovered from strictly fewer conditions than every other retry site in the codebase. There is now one list, not two.Verification
Tests pin the exact production error string at two levels — the matcher, and
connectWithRetrysurviving a two-attempt refusal window.They were confirmed to fail on the pre-change source (not merely pass after). Stashing only the two
src/files:That stack is the production crash reproduced in a test. With the fix:
bun test test/retry-matcher.test.tsbun test test/core/retry.test.tstsc --noEmitscripts/check-no-double-retry.shscripts/check-exports-count.shscripts/check-key-files-current-state.sh0.42.51.1Permanent errors — missing extension, missing relation, syntax error — are still not retried (asserted).
test/minions.test.tsadditions could not be executed locally: that file'sbeforeEachneeds a live Postgres. They are placed there to sit with the existingconnectWithRetrytests; the equivalent assertions were run standalone and are reported above.Notes for the reviewer
importindb.tsis mid-file, matching the repo's existing pattern (cli.tsdoes the same forbuildGatewayConfig, with the same "imported not just re-exported so local call sites bind it" reasoning). It must be an import + re-export, not a bareexport … from, becauseconnectWithRetrycalls the predicate locally.graphify-out/GRAPH_REPORT.mdis missing a trailing newline onmaster, soscripts/check-trailing-newline.shfails independently of this branch. Left alone to keep this diff focused — worth a separate one-byte fix.Refs BLO-21615.
🤖 Generated with Claude Code