Skip to content

fix(proxy): log why a connect failed instead of an empty line - #183

Open
ak2k wants to merge 1 commit into
KarpelesLab:masterfrom
ak2k:fix/blank-connect-errors
Open

fix(proxy): log why a connect failed instead of an empty line#183
ak2k wants to merge 1 commit into
KarpelesLab:masterfrom
ak2k:fix/blank-connect-errors

Conversation

@ak2k

@ak2k ak2k commented Aug 20, 2026

Copy link
Copy Markdown

When a connect to the upstream fails, the log line that should say why is empty. The same request against the same refused upstream, before and after, quoted so the trailing space is visible:

before  "[TeamClaude] Upstream error (account \"alice\"): "
after   "[TeamClaude] Upstream error (account \"alice\"): connect ECONNREFUSED ::1:1; connect ECONNREFUSED 127.0.0.1:1"

Why

Node's happy-eyeballs dialer (autoSelectFamily, on by default across the versions this package supports; package.json declares node >=20, measured here on 24) reports a connect where every address failed as an AggregateError. Node builds that error with an empty message; the per-address reasons are in .errors. Dialing localhost:1:

name: AggregateError, message: '' (empty), code: 'ECONNREFUSED'
errors: ['connect ECONNREFUSED ::1:1', 'connect ECONNREFUSED 127.0.0.1:1']

Every connect-failure log printed err.message. The upstream is multi-address, so the ordinary failure printed nothing.

The fix

One exported helper, used at every connect-failure log in server.js and at the MITM tunnel's in mitm.js:

export function describeConnectError(err) {
  const reasons = (e) => (Array.isArray(e?.errors) ? e.errors.map(c => c?.message).filter(Boolean) : []);
  const own = reasons(err);
  return (own.length ? own : reasons(err?.cause)).join('; ') || err?.message;
}

The || err.message fallback is required, not defensive: with autoSelectFamily: false, and on every single-address failure, the reason arrives as a plain Error in message.

The .cause descent covers TEAMCLAUDE_UPSTREAM_GLOBAL_FETCH, where global fetch wraps the same failure in a TypeError whose own message is fetch failed:

default       described "connect ECONNREFUSED ::1:65534; connect ECONNREFUSED 127.0.0.1:65534"
global fetch  described "fetch failed"          <- before the descent
global fetch  described "connect ECONNREFUSED ::1:65534; connect ECONNREFUSED 127.0.0.1:65534"

The client-visible 502 body interpolates the same expression in the same catch, so it is converted too. That site is not held by a test and fixes nothing observable today: a refused multi-address connect carries code: 'ECONNREFUSED', which is classified transient a few lines above, so the connection is destroyed before that 502 is built. Measured on master, the client gets a transport-level ECONNRESET, not a blank 502.

Tests

Seven, in test/connect-error-message.test.js, all against a real refused connect rather than a constructed error. Each establishes the error shape it received before asserting on the text, because a single-address failure carries its reason in message and would satisfy a substring check against the unfixed code; the end-to-end tests assert all the per-address reasons, joined.

Where the shape depends on the host, tests skip rather than assert: an IPv4-only or IPv6-disabled machine resolves localhost to one address and never produces the aggregated shape. The shape is probed once at load and the five dependent tests are gated on it; the two that hold the fallback always run.

The end-to-end tests cover the proxied path, the MITM tunnel and the HTTP forward proxy. The remaining conversions are the same one-word substitution on handlers of the same shape and are not separately anchored.

$ node --test --test-timeout=120000
ℹ tests 532
ℹ pass 532
ℹ fail 0

525 before, 532 after. npx eslint . clean.

Compatibility

Log and error text only. No routing, selection, retry or transport changes; every failure that produced a line before produces one now, with the reason filled in.

Node's happy-eyeballs dialer (`autoSelectFamily`, on by default across the
versions this package supports; `package.json` declares `node >=20`, measured
here on 24) reports a connect where every address failed as an AggregateError.
Node builds that error with an empty `message`; the per-address reasons are in
`.errors`. A refused `localhost:1`, both ::1 and 127.0.0.1, gives

  name: AggregateError, message: '' (empty), code: 'ECONNREFUSED'
  errors: ['connect ECONNREFUSED ::1:1', 'connect ECONNREFUSED 127.0.0.1:1']

The upstream is multi-address, so that is the ordinary shape of a real network
event, and both sites that report one printed `err.message`. An outage produced
pages of

  [TeamClaude] Upstream error (account "alice"):

with nothing after the colon, at the moment the log is worth reading.

One helper, used at every connect-failure log rather than copied into each,
since copies would drift. All of them printed `err.message`: the proxied path,
the HTTP forward proxy, both Remote Control relays, the raw relay and the MITM
tunnel. Its `err.message` fallback is required: with the dialer off, and on
every single-address failure, the reason arrives as a plain Error in `message`.

It also looks one level down, because `TEAMCLAUDE_UPSTREAM_GLOBAL_FETCH` is a
supported path and changes the shape: global fetch wraps the same failure in a
TypeError whose own message is the equally uninformative "fetch failed", with the
AggregateError in `.cause`. Without that, the escape hatch trades a blank line
for "fetch failed".

The 502 body interpolates the same expression, on the same error, in the same
catch, so it is converted with them rather than left as the one inconsistency a
reader has to ask about. It is not held by a test and is not claimed as a fix:
the failures that reach this catch today are classified transient a few lines
above it, so they destroy the connection and never get as far as the 502. What
would reach it is a multi-address host failing with a code outside that list,
and simulating one would prove nothing about the real path.

Seven tests, each establishing the shape it is working with before asserting
anything about the text, because a single-address failure carries its reason in
`message` and would pass against the unfixed code either way. The ones that need
an aggregated failure skip when the host cannot produce one, since an IPv4-only
machine is not a defect in this change.
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