mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
f23c96edff
* fix(web): classify deploy failures instead of the generic "Error" code `artifact_deploy_result` reported `err.name` on the deploy catch path, which collapses to the generic "Error" for a plain thrown Error — ~half of failed deploys (~40/day) land in that opaque bucket with no way to tell an auth failure from a network drop from a provider 5xx, so the deploy failure rate can't be root-caused from telemetry. Add `deployErrorCode(err)` (mirrors export-error-code.ts / #5220): structured `.code` wins, then map the common transport / provider-API signatures (NETWORK, TIMEOUT, RATE_LIMITED, FORBIDDEN, NOT_FOUND, HTTP_<status>, desktop sidecar skew) to fixed codes, else fall back to err.name. Only fixed codes are emitted — never free-form message text — so no deploy token, account id, zone, or URL can leak into analytics. * fix(web): preserve deploy HTTP status/code so classification isn't lost upstream Review follow-up (#5554): deployErrorCode can only emit HTTP_<status> or a structured code if that survives into the deploy catch, but deployProjectFile threw a bare `new Error(payload.error.message || ... )`, dropping the status whenever the provider/daemon returned a human message. So the daemon-backed deploy failures the classifier was meant to bucket still collapsed to "Error". deployProjectFile now attaches a `.code` to the thrown error: a structured provider/daemon `error.code` when present, else `HTTP_<status>`. deployErrorCode reads `.code` first, so those failures now bucket by status instead of "Error". Transport failures (fetch throws before a response) still classify via message. * fix(web): ignore the daemon's generic deploy envelope code so status bucketing runs Follow-up to @nettee's review: the daemon deploy route (apps/daemon/src/routes/deploy.ts) collapses every non-404 provider failure's code to `BAD_REQUEST` (and 404 to `FILE_NOT_FOUND`) while keeping the REAL provider HTTP status on the response and the real message in the body. Since the prior change preferred `payload.error.code`, a live 403/429/5xx was rethrown as `{ code: 'BAD_REQUEST' }` and deployErrorCode returned that generic code, never reaching HTTP_403 / RATE_LIMITED / etc. Ignore the generic envelope codes (shared `GENERIC_DEPLOY_ENVELOPE_CODES`) at both the throw site (fall back to the real `HTTP_${resp.status}`) and inside deployErrorCode as a safety net, so status/message-based bucketing runs. Added registry tests that exercise the actual daemon envelope (BAD_REQUEST+403 → HTTP_403, FILE_NOT_FOUND+404 → HTTP_404) and a deployErrorCode generic-code test.
82 lines
3.9 KiB
TypeScript
82 lines
3.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { deployErrorCode } from '../../src/analytics/deploy-error-code';
|
|
|
|
describe('deployErrorCode', () => {
|
|
it('prefers a structured .code over message classification', () => {
|
|
const err = Object.assign(new Error('502 Bad Gateway'), { code: 'UPSTREAM_UNAVAILABLE' });
|
|
expect(deployErrorCode(err)).toBe('UPSTREAM_UNAVAILABLE');
|
|
});
|
|
|
|
it('ignores generic envelope codes and falls through to status/message bucketing', () => {
|
|
// The daemon wraps non-404 provider failures as `BAD_REQUEST` (404 as
|
|
// `FILE_NOT_FOUND`) — those carry no signal and must not shortcut the real
|
|
// status/message classification. Safety net in case one reaches here.
|
|
expect(deployErrorCode(Object.assign(new Error('403 Forbidden'), { code: 'BAD_REQUEST' }))).toBe('FORBIDDEN');
|
|
expect(deployErrorCode(Object.assign(new Error('deploy failed (500)'), { code: 'BAD_REQUEST' }))).toBe('HTTP_500');
|
|
expect(deployErrorCode(Object.assign(new Error('file not found'), { code: 'FILE_NOT_FOUND' }))).toBe('NOT_FOUND');
|
|
});
|
|
|
|
it('returns UNKNOWN for non-Error throwables', () => {
|
|
expect(deployErrorCode('nope')).toBe('UNKNOWN');
|
|
expect(deployErrorCode(undefined)).toBe('UNKNOWN');
|
|
expect(deployErrorCode(null)).toBe('UNKNOWN');
|
|
});
|
|
|
|
it('classifies the daemon↔desktop sidecar version skew before renderer-unavailable', () => {
|
|
const err = new Error(
|
|
'desktop renderer unavailable: unknown desktop sidecar message: render-slides',
|
|
);
|
|
expect(deployErrorCode(err)).toBe('DESKTOP_SIDECAR_UNKNOWN_MESSAGE');
|
|
});
|
|
|
|
it('classifies a plain renderer-unavailable failure separately from the skew', () => {
|
|
expect(deployErrorCode(new Error('desktop renderer unavailable: connection refused'))).toBe(
|
|
'DESKTOP_RENDERER_UNAVAILABLE',
|
|
);
|
|
});
|
|
|
|
it('buckets transport failures as NETWORK', () => {
|
|
expect(deployErrorCode(new Error('fetch failed'))).toBe('NETWORK');
|
|
expect(deployErrorCode(new TypeError('Failed to fetch'))).toBe('NETWORK');
|
|
expect(deployErrorCode(new Error('connect ECONNREFUSED 1.2.3.4:443'))).toBe('NETWORK');
|
|
expect(deployErrorCode(new Error('getaddrinfo ENOTFOUND api.cloudflare.com'))).toBe('NETWORK');
|
|
});
|
|
|
|
it('buckets timeouts as TIMEOUT', () => {
|
|
expect(deployErrorCode(new Error('deploy request timed out after 30s'))).toBe('TIMEOUT');
|
|
expect(deployErrorCode(new Error('ETIMEDOUT'))).toBe('TIMEOUT');
|
|
});
|
|
|
|
it('buckets rate limiting as RATE_LIMITED', () => {
|
|
expect(deployErrorCode(new Error('Cloudflare API rate limit exceeded'))).toBe('RATE_LIMITED');
|
|
expect(deployErrorCode(new Error('request failed with 429'))).toBe('RATE_LIMITED');
|
|
});
|
|
|
|
it('buckets auth failures as FORBIDDEN (keyword wins over bare status)', () => {
|
|
expect(deployErrorCode(new Error('403 Forbidden'))).toBe('FORBIDDEN');
|
|
expect(deployErrorCode(new Error('401 Unauthorized'))).toBe('FORBIDDEN');
|
|
expect(deployErrorCode(new Error('Invalid API token'))).toBe('FORBIDDEN');
|
|
});
|
|
|
|
it('buckets remaining HTTP failures by status code', () => {
|
|
expect(deployErrorCode(new Error('Vercel returned 500 Internal Server Error'))).toBe('HTTP_500');
|
|
expect(deployErrorCode(new Error('deploy failed (502 Bad Gateway)'))).toBe('HTTP_502');
|
|
});
|
|
|
|
it('classifies not-found messages without an explicit status', () => {
|
|
expect(deployErrorCode(new Error('project not found'))).toBe('NOT_FOUND');
|
|
});
|
|
|
|
it('does not misread a non-status number as an HTTP code', () => {
|
|
// Multi-digit ids / durations must not collapse to HTTP_xxx.
|
|
expect(deployErrorCode(new Error('deploy 50012 failed'))).toBe('Error');
|
|
expect(deployErrorCode(new Error('took 404ms'))).toBe('Error');
|
|
});
|
|
|
|
it('falls back to the error name for a truly unclassified failure (documents the residual)', () => {
|
|
expect(deployErrorCode(new Error('something went wrong'))).toBe('Error');
|
|
expect(deployErrorCode(new RangeError('boom'))).toBe('RangeError');
|
|
});
|
|
});
|