Files
lefarcen 50843ff149 give the unattributable client failures a real error code (#5960)
* give the unattributable client failures a real error code

Three buckets in the daily stability report name nothing, and in every case the
evidence was already in scope at the emission site:

- BYOK connection test: 801 failures across 201 devices in 24h — 10% of ALL
  connection tests — reported `result.kind` verbatim, so every response the
  daemon's status→kind map had no case for collapsed into `unknown`. The user
  is told "connection failed" and we cannot say whether it was an out-of-credits
  402, a self-signed cert, or a base URL pointing at an HTML login portal.
  `ConnectionTestResponse` already carried `status` and a redacted `detail`;
  only the emission site threw them away.

- Frontend exceptions: the Top-5 table literally renders a row as
  `Error  — 235 次 / ~74 台`. Two independent sources — `buildExceptionList`
  copied `error.name`/`error.message` straight through and both can be '', and
  `parseStack` returned a zero-frame stacktrace for a stackless error, which
  PostHog drops at ingestion so neither `$exception_type` nor
  `$exception_value` survives.

- Export failures (8.1%, 210/24h): everything that was not a sidecar-skew
  message fell through to `err.name`, i.e. the literal "Error".

The daemon side of the same path flattened every sidecar contract violation to
`UPSTREAM_UNAVAILABLE`, which forced the web layer to regex-match English prose
to tell a version-skewed mesh from a dead renderer; it now forwards the
sidecar's real code, and the web classifier treats the remaining generic
envelope codes as non-classifications so a specific message still wins.

Two existing export-code expectations changed on purpose and say why inline: a
generic envelope `.code` no longer outranks message classification, and a
message carrying an HTTP status now reports that status instead of "Error".

* keep the forwarded sidecar code inside ApiErrorCode

Follow-up on the same PR. Forwarding the sidecar contract violation's `code`
compiled, but it was leaving the declared union: `ctx.http.sendApiError` is
typed `(...args: any[]) => any` in server-context.ts, so nothing caught that
`SIDECAR_UNKNOWN_MESSAGE` is not an `ApiErrorCode`. Every web-side handler
typed against that union would have failed to match the code the daemon
actually put on the wire.

Declare the two SIDECAR_* codes in API_ERROR_CODES and narrow the helper's
return type to ApiErrorCode, allowlisting exactly those two so a future sidecar
code cannot silently leave the union again.

No consumer of the export 502 switches on the old code — the UPSTREAM_UNAVAILABLE
call sites are finalize (useFinalizeProject), AMR run guidance, and ChatPane run
errors, none of which are on this route — so this is additive, not a behavior
change for anything that exists today.

* make the attribution actually fire on the production paths

Three review findings, all correct, all verified against the real code paths
rather than the synthetic ones the first cut tested.

1. The sidecar code passthrough was dead. `requestJsonIpc` rejects with
   `new Error(response.error?.message)` — its response type does not even
   declare a `code` — so SIDECAR_UNKNOWN_MESSAGE never reaches the route, and
   the helper always fell back to UPSTREAM_UNAVAILABLE. Removed the passthrough
   (and the two API_ERROR_CODES entries added for it: nothing emits them). The
   message-based classifier already achieves the attribution and is tested;
   dead code that reads as working is worse than no code. Threading the code
   through IPC → API → web export result is a three-layer change and belongs in
   its own PR.

2. The zero-frame guard checked the input instead of the output. A header-only
   stack ("Error: Script error.") is truthy, so it skipped the fallback, and the
   parser then dropped that single line as a message — yielding `frames: []`,
   the exact ingestion failure this change exists to prevent. The guard now runs
   on the PARSED result, covering missing / empty / header-only / unparseable
   stacks, with a fixture matrix over all four.

3. The certificate bucket could not fire in production. undici reports a TLS
   failure as a TypeError whose message is the generic `fetch failed`, with the
   reason only on `cause.code`, and `networkErrorToKind` maps just an allowlist
   — so a self-signed cert became `kind: unknown` + `detail: "fetch failed"`.
   The unit test passed only because it was fed a detail string production never
   emits. The daemon now appends the cause code to `detail`, and the client
   tests assert against that real shape.

* stop a 2xx status from claiming a failure it cannot explain

Review catch, and it made this PR's headline case unreachable. The status
fallback ran before every detail classifier, so the daemon's
`{ kind: 'unknown', status: 200, detail: 'Unexpected token < …' }` — which is
exactly the 'base URL points at an HTML login portal' shape, returned when a 2xx
body fails JSON.parse (connectionTest.ts) — was reported as HTTP_200, and
INVALID_JSON_RESPONSE could never fire.

Reproduced before fixing:
  byokErrorCode({kind:'unknown',status:200,detail:'Unexpected token < …'})
    → 'HTTP_200'

The cut is now at 300 rather than 0. A 2xx that still failed did so for a reason
the status cannot express, so it falls through to the detail classifiers; 3xx
stays on the status side because the daemon fetches with `redirect: 'error'`,
making a redirect a real failure the status does name.

* read the daemon-appended cause code instead of enumerating codes

Review catch on a gap my own previous commit opened: forwarding cause.code from
the daemon put OpenSSL's TLS codes on the wire, and this file could not read
them. UNABLE_TO_GET_ISSUER_CERT_LOCALLY, CERT_NOT_YET_VALID and CERT_HAS_EXPIRED
have no Node-errno shape, and the `\bcertificate\b` fallback never matches a
name that spells it CERT — so the signal was preserved on the wire and dropped
here, landing back in UNKNOWN_NO_SIGNAL.

Read the daemon's known trailing `(CODE)` position directly rather than
extending an allowlist: an enumeration would silently drop every code added
upstream after this file was written, which is the same failure mode one layer
down. Inline codes keep a widened pattern, and the TLS fallback now also matches
CERT-shaped names.

Fixtures cover all seven codes the daemon can now forward.
2026-07-22 06:57:48 +00:00

181 lines
7.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { byokErrorCode } from '../../src/analytics/byok-error-code';
/**
* `settings_byok_test_result` used to report `result.kind` verbatim, so every
* response the daemon's status→kind map had no case for collapsed into the
* union's `unknown` member. In the field that is 801 tests across 201 devices
* in 24h — 10% of ALL connection tests — where the user is told "connection
* failed" and we cannot say why.
*
* Nothing upstream was missing: `ConnectionTestResponse` already carries
* `status` for HTTP-shaped failures and a secret-redacted `detail` for
* transport ones. Only the emission site threw both away.
*/
describe('byokErrorCode', () => {
it('keeps a kind the daemon actually classified', () => {
expect(byokErrorCode({ kind: 'auth_failed', status: 401 })).toBe('auth_failed');
});
it('recovers the HTTP status the kind map has no case for', () => {
// 402 = provider out of credits. Previously indistinguishable from a TLS
// failure or an HTML login portal behind the base URL.
expect(byokErrorCode({ kind: 'unknown', status: 402 })).toBe('HTTP_402');
expect(byokErrorCode({ kind: 'unknown', status: 409 })).toBe('HTTP_409');
});
it('recovers a Node error code out of the transport detail', () => {
expect(byokErrorCode({ kind: 'unknown', detail: 'connect ECONNREFUSED 127.0.0.1:11434' })).toBe(
'ECONNREFUSED',
);
expect(byokErrorCode({ kind: 'unknown', detail: 'getaddrinfo ENOTFOUND api.example.com' })).toBe(
'ENOTFOUND',
);
expect(byokErrorCode({ kind: 'unknown', detail: 'UND_ERR_CONNECT_TIMEOUT' })).toBe(
'UND_ERR_CONNECT_TIMEOUT',
);
});
it('names a self-signed / TLS rejection', () => {
expect(
byokErrorCode({ kind: 'unknown', detail: 'DEPTH_ZERO_SELF_SIGNED_CERT' }),
).toBe('DEPTH_ZERO_SELF_SIGNED_CERT');
expect(
byokErrorCode({ kind: 'unknown', detail: 'unable to verify the first certificate' }),
).toBe('TLS_FAILED');
});
it('names a base URL that answered with something that is not JSON', () => {
// The classic "base URL points at an HTML login portal" case.
expect(
byokErrorCode({ kind: 'unknown', detail: 'Unexpected token < in JSON at position 0' }),
).toBe('INVALID_JSON_RESPONSE');
});
it('marks a genuinely signal-free failure as its own countable bucket', () => {
// Distinct from `unknown` on purpose: the residue must be measurable
// instead of being mixed back in with failures we CAN classify.
expect(byokErrorCode({ kind: 'unknown' })).toBe('UNKNOWN_NO_SIGNAL');
expect(byokErrorCode({})).toBe('UNKNOWN_NO_SIGNAL');
});
it('treats a missing kind the same as an unclassified one', () => {
expect(byokErrorCode({ kind: '', status: 429 })).toBe('HTTP_429');
expect(byokErrorCode({ kind: null, status: 500 })).toBe('HTTP_500');
});
});
/**
* End-to-end shape guard (review of PR #5960).
*
* The first cut of these tests fed `byokErrorCode` a synthetic `detail` string
* that production never emits, so they passed while the certificate bucket
* stayed empty in the field. undici surfaces a TLS failure as a `TypeError`
* whose message is the generic `fetch failed`, with the real reason only on
* `cause.code` — and `networkErrorToKind` maps only an allowlist, so a
* self-signed cert lands as `kind: 'unknown'`.
*
* The daemon now appends the cause code to `detail` (see
* apps/daemon/src/connectionTest.ts `networkErrorDetail`); these assert against
* the shape it actually produces.
*/
describe('byokErrorCode against real daemon detail shapes', () => {
it('names a self-signed certificate from the undici cause code', () => {
expect(
byokErrorCode({ kind: 'unknown', detail: 'fetch failed (DEPTH_ZERO_SELF_SIGNED_CERT)' }),
).toBe('DEPTH_ZERO_SELF_SIGNED_CERT');
});
it('names a self-signed chain', () => {
expect(
byokErrorCode({ kind: 'unknown', detail: 'fetch failed (SELF_SIGNED_CERT_IN_CHAIN)' }),
).toBe('SELF_SIGNED_CERT_IN_CHAIN');
});
it('names a refused connection carried the same way', () => {
expect(byokErrorCode({ kind: 'unknown', detail: 'fetch failed (ECONNREFUSED)' })).toBe(
'ECONNREFUSED',
);
});
it('still degrades to the measurable residue when there is genuinely no cause', () => {
expect(byokErrorCode({ kind: 'unknown', detail: 'fetch failed' })).toBe('UNKNOWN_NO_SIGNAL');
});
});
/**
* Status-vs-detail precedence (review of PR #5960).
*
* The daemon returns `{ ok: false, kind: 'unknown', status: <2xx>, detail:
* <parse error> }` when a 2xx body fails `JSON.parse`
* (apps/daemon/src/connectionTest.ts) — that IS the "base URL points at an HTML
* login portal" case. An unconditional status fallback reported it as
* `HTTP_200` and made every detail classifier unreachable for it, so the
* headline case this helper advertises could never resolve.
*/
describe('byokErrorCode status precedence', () => {
it('names the HTML-login-portal case instead of reporting HTTP_200', () => {
expect(
byokErrorCode({
kind: 'unknown',
status: 200,
detail: 'Unexpected token < in JSON at position 0',
}),
).toBe('INVALID_JSON_RESPONSE');
});
it('does not let any 2xx claim a failure it cannot explain', () => {
expect(byokErrorCode({ kind: 'unknown', status: 204, detail: 'ECONNRESET' })).toBe(
'ECONNRESET',
);
// Nothing to go on beyond a success status — the residue stays measurable
// rather than being mislabelled HTTP_200.
expect(byokErrorCode({ kind: 'unknown', status: 200, detail: '' })).toBe('UNKNOWN_NO_SIGNAL');
});
it('still lets a real error status win', () => {
expect(byokErrorCode({ kind: 'unknown', status: 402, detail: 'insufficient credits' })).toBe(
'HTTP_402',
);
expect(byokErrorCode({ kind: 'unknown', status: 302, detail: 'redirect' })).toBe('HTTP_302');
});
});
/**
* OpenSSL cause codes (review of PR #5960).
*
* Forwarding `cause.code` from the daemon created a second gap: OpenSSL's TLS
* codes carry no Node-errno shape and no lowercase word, so
* `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` matched neither the errno pattern nor a
* `\bcertificate\b` fallback and came back `UNKNOWN_NO_SIGNAL` — the signal was
* preserved on the wire and then dropped here. The daemon appends the cause in
* a known trailing position, so read that rather than enumerate a set that
* upstream keeps growing.
*/
describe('byokErrorCode against OpenSSL cause codes', () => {
it.each([
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_NOT_YET_VALID',
'CERT_HAS_EXPIRED',
'SELF_SIGNED_CERT_IN_CHAIN',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'EPROTO',
])('names %s from the daemon-appended cause', (code) => {
expect(byokErrorCode({ kind: 'unknown', detail: `fetch failed (${code})` })).toBe(code);
});
it('falls back to TLS_FAILED for a cert failure with no parseable code', () => {
expect(
byokErrorCode({ kind: 'unknown', detail: 'CERT verification failed somewhere upstream' }),
).toBe('TLS_FAILED');
});
it('keeps the JSON-portal case ahead of a bare success status', () => {
expect(
byokErrorCode({ kind: 'unknown', status: 200, detail: 'Unexpected token < in JSON at position 0' }),
).toBe('INVALID_JSON_RESPONSE');
});
});