Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion src/common/telemetry/errorClassifier.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { CancellationError } from 'vscode';
import * as rpc from 'vscode-jsonrpc/node';
import { RpcTimeoutError } from '../../managers/common/nativePythonFinder';
import { BaseError } from '../errors/types';

export type DiscoveryErrorType =
| 'spawn_timeout'
| 'spawn_enoent'
| 'permission_denied'
| 'canceled'
| 'parse_error'
| 'tool_not_found'
| 'command_failed'
| 'connection_error'
| 'rpc_error'
| 'process_crash'
| 'already_registered'
| 'unknown';

/**
Expand All @@ -22,6 +30,21 @@ export function classifyError(ex: unknown): DiscoveryErrorType {
return 'spawn_timeout';
}

// JSON-RPC connection errors (e.g., PET process died mid-request, connection closed/disposed)
if (ex instanceof rpc.ConnectionError) {
return 'connection_error';
}

// JSON-RPC response errors (PET returned an error response, e.g., internal error)
if (ex instanceof rpc.ResponseError) {
return 'rpc_error';
}

// BaseError subclasses: EnvironmentManagerAlreadyRegisteredError, PackageManagerAlreadyRegisteredError
if (ex instanceof BaseError) {
return 'already_registered';
}

if (!(ex instanceof Error)) {
return 'unknown';
}
Expand All @@ -35,15 +58,34 @@ export function classifyError(ex: unknown): DiscoveryErrorType {
return 'permission_denied';
}

// Check message patterns
// Check message patterns (order matters — more specific patterns first)
const msg = ex.message.toLowerCase();
if (msg.includes('timed out') || msg.includes('timeout')) {
return 'spawn_timeout';
}

// CLI command execution failures — checked before parse_error because command args
// may contain words like "json" (e.g., 'Failed to run "conda info --envs --json"')
if (msg.includes('failed to run') || msg.includes('error spawning')) {
return 'command_failed';
}

if (msg.includes('parse') || msg.includes('unexpected token') || msg.includes('json')) {
return 'parse_error';
}

// Tool/executable not found — e.g., "Conda not found", "Python extension not found",
// "Poetry executable not found"
if (msg.includes('not found')) {
return 'tool_not_found';
}

// PET process crash/hang recovery failures — e.g., "PET is currently restarting",
// "failed after 3 restart attempts", "Failed to create stdio streams for PET process"
if (msg.includes('restart') || msg.includes('stdio stream')) {
return 'process_crash';
}

// Check error name for cancellation variants
if (ex.name === 'CancellationError' || ex.name === 'AbortError') {
return 'canceled';
Expand Down
61 changes: 61 additions & 0 deletions src/test/common/telemetry/errorClassifier.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import assert from 'node:assert';
import { CancellationError } from 'vscode';
import * as rpc from 'vscode-jsonrpc/node';
import { BaseError } from '../../../common/errors/types';
import { classifyError } from '../../../common/telemetry/errorClassifier';
import { RpcTimeoutError } from '../../../managers/common/nativePythonFinder';

Expand Down Expand Up @@ -64,5 +66,64 @@ suite('Error Classifier', () => {
test('should classify unrecognized errors as unknown', () => {
assert.strictEqual(classifyError(new Error('something went wrong')), 'unknown');
});

test('should classify ConnectionError as connection_error', () => {
assert.strictEqual(
classifyError(new rpc.ConnectionError(rpc.ConnectionErrors.Closed, 'Connection closed')),
'connection_error',
);
assert.strictEqual(
classifyError(new rpc.ConnectionError(rpc.ConnectionErrors.Disposed, 'Connection disposed')),
'connection_error',
);
});

test('should classify ResponseError as rpc_error', () => {
assert.strictEqual(classifyError(new rpc.ResponseError(-32600, 'Invalid request')), 'rpc_error');
assert.strictEqual(classifyError(new rpc.ResponseError(-32601, 'Method not found')), 'rpc_error');
});

test('should classify BaseError subclasses as already_registered', () => {
// Using a concrete subclass to test (BaseError is abstract)
class TestRegisteredError extends BaseError {
constructor(message: string) {
super('InvalidArgument', message);
}
}
assert.strictEqual(
classifyError(new TestRegisteredError('Environment manager with id test already registered')),
'already_registered',
);
});

test('should classify "not found" messages as tool_not_found', () => {
assert.strictEqual(classifyError(new Error('Conda not found')), 'tool_not_found');
assert.strictEqual(classifyError(new Error('Python extension not found')), 'tool_not_found');
assert.strictEqual(classifyError(new Error('Poetry executable not found')), 'tool_not_found');
});

test('should classify command execution failures as command_failed', () => {
assert.strictEqual(
classifyError(new Error('Failed to run "conda info --envs --json":\n some error')),
'command_failed',
);
assert.strictEqual(classifyError(new Error('Failed to run poetry install')), 'command_failed');
assert.strictEqual(classifyError(new Error('Error spawning conda: ENOENT')), 'command_failed');
});

test('should classify PET process crash/restart errors as process_crash', () => {
assert.strictEqual(
classifyError(new Error('Python Environment Tools (PET) is currently restarting. Please try again.')),
'process_crash',
);
assert.strictEqual(
classifyError(new Error('Python Environment Tools (PET) failed after 3 restart attempts.')),
'process_crash',
);
assert.strictEqual(
classifyError(new Error('Failed to create stdio streams for PET process')),
'process_crash',
);
});
});
});
Loading