refactor: add typed query failure signals
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
export interface GSDToolsErrorClassification {
|
||||
kind: 'timeout' | 'failure';
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export class GSDToolsError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -5,9 +10,12 @@ export class GSDToolsError extends Error {
|
||||
public readonly args: string[],
|
||||
public readonly exitCode: number | null,
|
||||
public readonly stderr: string,
|
||||
options?: { cause?: unknown },
|
||||
options?: { cause?: unknown; classification?: GSDToolsErrorClassification },
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'GSDToolsError';
|
||||
this.classification = options?.classification;
|
||||
}
|
||||
|
||||
public readonly classification?: GSDToolsErrorClassification;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
isTimeoutMessage,
|
||||
parseTimeoutMs,
|
||||
timeoutMessage,
|
||||
toFailureSignal,
|
||||
} from './query-failure-classification.js';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
|
||||
describe('query failure classification', () => {
|
||||
it('extracts timeout metadata from message', () => {
|
||||
@@ -25,4 +27,11 @@ describe('query failure classification', () => {
|
||||
expect(errorMessage('boom')).toBe('boom');
|
||||
expect(errorMessage(new Error('x'))).toBe('x');
|
||||
});
|
||||
|
||||
it('prefers typed classification from GSDToolsError', () => {
|
||||
const err = new GSDToolsError('x', 'state', ['load'], null, '', {
|
||||
classification: { kind: 'timeout', timeoutMs: 2000 },
|
||||
});
|
||||
expect(toFailureSignal(err)).toEqual({ kind: 'timeout', message: 'x', timeoutMs: 2000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
|
||||
export interface QueryFailureSignal {
|
||||
kind: 'timeout' | 'failure';
|
||||
message: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -22,3 +30,19 @@ export function isTimeoutLikeError(error: unknown): boolean {
|
||||
export function timeoutMessage(command: string, args: string[], timeoutMs: number): string {
|
||||
return `gsd-tools timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`;
|
||||
}
|
||||
|
||||
export function toFailureSignal(error: unknown): QueryFailureSignal {
|
||||
if (error instanceof GSDToolsError && error.classification) {
|
||||
return {
|
||||
kind: error.classification.kind,
|
||||
message: error.message,
|
||||
timeoutMs: error.classification.timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
const message = errorMessage(error);
|
||||
if (isTimeoutMessage(message)) {
|
||||
return { kind: 'timeout', message, timeoutMs: parseTimeoutMs(message) };
|
||||
}
|
||||
return { kind: 'failure', message };
|
||||
}
|
||||
|
||||
@@ -33,14 +33,15 @@ export function createGSDToolsRuntime(opts: {
|
||||
gsdToolsPath: opts.gsdToolsPath,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
workstream: opts.workstream,
|
||||
createToolsError: (message, command, args, exitCode, stderr) =>
|
||||
new GSDToolsError(message, command, args, exitCode, stderr),
|
||||
createToolsError: (message, command, args, exitCode, stderr, classification) =>
|
||||
new GSDToolsError(message, command, args, exitCode, stderr, classification ? { classification } : undefined),
|
||||
});
|
||||
|
||||
const nativeDirectAdapter = new QueryNativeDirectAdapter({
|
||||
timeoutMs: opts.timeoutMs,
|
||||
dispatch: (registryCommand, registryArgs) => registry.dispatch(registryCommand, registryArgs, opts.projectDir),
|
||||
createTimeoutError: (message, command, args) => new GSDToolsError(message, command, args, null, ''),
|
||||
createTimeoutError: (message, command, args) =>
|
||||
new GSDToolsError(message, command, args, null, '', { classification: { kind: 'timeout', timeoutMs: opts.timeoutMs } }),
|
||||
});
|
||||
|
||||
const transport = new GSDTransport(registry, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { timeoutMessage } from './query-failure-classification.js';
|
||||
import type { GSDToolsError } from './gsd-tools-error.js';
|
||||
import type { GSDToolsError, GSDToolsErrorClassification } from './gsd-tools-error.js';
|
||||
|
||||
export interface QuerySubprocessAdapterDeps {
|
||||
projectDir: string;
|
||||
@@ -14,6 +14,7 @@ export interface QuerySubprocessAdapterDeps {
|
||||
args: string[],
|
||||
exitCode: number | null,
|
||||
stderr: string,
|
||||
classification?: GSDToolsErrorClassification,
|
||||
) => GSDToolsError;
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ export class QuerySubprocessAdapter {
|
||||
args,
|
||||
null,
|
||||
stderrStr,
|
||||
{ kind: 'timeout', timeoutMs: this.deps.timeoutMs },
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -112,6 +114,7 @@ export class QuerySubprocessAdapter {
|
||||
args,
|
||||
null,
|
||||
stderrStr,
|
||||
{ kind: 'timeout', timeoutMs: this.deps.timeoutMs },
|
||||
),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GSDError, exitCodeFor } from './errors.js';
|
||||
import { GSDToolsError } from './gsd-tools-error.js';
|
||||
import { errorMessage } from './query-failure-classification.js';
|
||||
import { isTimeoutMessage, errorMessage, parseTimeoutMs } from './query-failure-classification.js';
|
||||
|
||||
/**
|
||||
* Module owning projection of internal errors to GSDToolsError contract.
|
||||
@@ -24,6 +24,15 @@ export function toGSDToolsError(command: string, args: string[], err: unknown):
|
||||
args,
|
||||
1,
|
||||
'',
|
||||
err instanceof Error ? { cause: err } : undefined,
|
||||
err instanceof Error
|
||||
? {
|
||||
cause: err,
|
||||
...(isTimeoutMessage(msg)
|
||||
? { classification: { kind: 'timeout' as const, timeoutMs: parseTimeoutMs(msg) } }
|
||||
: undefined),
|
||||
}
|
||||
: (isTimeoutMessage(msg)
|
||||
? { classification: { kind: 'timeout' as const, timeoutMs: parseTimeoutMs(msg) } }
|
||||
: undefined),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mapFallbackDispatchError,
|
||||
toDispatchFailure,
|
||||
} from './query-dispatch-error-mapper.js';
|
||||
import { GSDToolsError } from '../gsd-tools-error.js';
|
||||
|
||||
describe('query dispatch error mapper', () => {
|
||||
it('maps native timeout errors', () => {
|
||||
@@ -24,6 +25,18 @@ describe('query dispatch error mapper', () => {
|
||||
expect(err.details).toMatchObject({ command: 'state.json', args: [] });
|
||||
});
|
||||
|
||||
it('maps typed timeout classification from GSDToolsError', () => {
|
||||
const err = mapNativeDispatchError(
|
||||
new GSDToolsError('timeout', 'state', ['load'], null, '', {
|
||||
classification: { kind: 'timeout', timeoutMs: 1234 },
|
||||
}),
|
||||
'state.load',
|
||||
[],
|
||||
);
|
||||
expect(err.kind).toBe('native_timeout');
|
||||
expect(err.details).toMatchObject({ timeout_ms: 1234 });
|
||||
});
|
||||
|
||||
it('maps fallback errors', () => {
|
||||
const err = mapFallbackDispatchError(new Error('spawn ENOENT'), 'state', ['load']);
|
||||
expect(err.kind).toBe('fallback_failure');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js';
|
||||
import { errorMessage, isTimeoutMessage, parseTimeoutMs } from '../query-failure-classification.js';
|
||||
import { toFailureSignal } from '../query-failure-classification.js';
|
||||
import { fallbackFailureError, nativeFailureError, nativeTimeoutError } from './query-error-taxonomy.js';
|
||||
import { dispatchFailure } from './query-dispatch-result-builder.js';
|
||||
|
||||
@@ -11,14 +11,14 @@ export function toDispatchFailure(
|
||||
}
|
||||
|
||||
export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError {
|
||||
const message = errorMessage(error);
|
||||
if (isTimeoutMessage(message)) {
|
||||
return nativeTimeoutError({ message, command, args, timeoutMs: parseTimeoutMs(message) });
|
||||
const signal = toFailureSignal(error);
|
||||
if (signal.kind === 'timeout') {
|
||||
return nativeTimeoutError({ message: signal.message, command, args, timeoutMs: signal.timeoutMs });
|
||||
}
|
||||
return nativeFailureError({ message, command, args });
|
||||
return nativeFailureError({ message: signal.message, command, args });
|
||||
}
|
||||
|
||||
export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError {
|
||||
const message = errorMessage(error);
|
||||
return fallbackFailureError({ message, command, args, backend: 'cjs' });
|
||||
const signal = toFailureSignal(error);
|
||||
return fallbackFailureError({ message: signal.message, command, args, backend: 'cjs' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user