From ba6100c5484b82179a41713995ae8b6d39b4b40d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 4 May 2026 19:54:29 -0400 Subject: [PATCH] refactor: deepen query failure classification module --- sdk/src/gsd-transport.ts | 7 +---- sdk/src/query-failure-classification.test.ts | 28 ++++++++++++++++++++ sdk/src/query-failure-classification.ts | 24 +++++++++++++++++ sdk/src/query-native-direct-adapter.ts | 3 ++- sdk/src/query-subprocess-adapter.ts | 5 ++-- sdk/src/query-tools-error-mapper.ts | 3 ++- sdk/src/query/query-dispatch-error-mapper.ts | 14 +++------- 7 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 sdk/src/query-failure-classification.test.ts create mode 100644 sdk/src/query-failure-classification.ts diff --git a/sdk/src/gsd-transport.ts b/sdk/src/gsd-transport.ts index 4b61f4093..67164310d 100644 --- a/sdk/src/gsd-transport.ts +++ b/sdk/src/gsd-transport.ts @@ -1,6 +1,7 @@ import type { QueryResult } from './query/utils.js'; import type { QueryRegistry } from './query/registry.js'; import type { TransportMode } from './gsd-transport-policy.js'; +import { isTimeoutLikeError } from './query-failure-classification.js'; export interface TransportRequest { legacyCommand: string; @@ -24,12 +25,6 @@ export interface TransportPolicyLike { allowFallbackToSubprocess: boolean; } -function isTimeoutLikeError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - if (error.name === 'TimeoutError' || error.name === 'AbortError') return true; - return error.message.includes('timed out after'); -} - export class GSDTransport { constructor( private readonly registry: QueryRegistry, diff --git a/sdk/src/query-failure-classification.test.ts b/sdk/src/query-failure-classification.test.ts new file mode 100644 index 000000000..7675f9012 --- /dev/null +++ b/sdk/src/query-failure-classification.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + errorMessage, + isTimeoutLikeError, + isTimeoutMessage, + parseTimeoutMs, + timeoutMessage, +} from './query-failure-classification.js'; + +describe('query failure classification', () => { + it('extracts timeout metadata from message', () => { + const msg = timeoutMessage('state', ['load'], 30000); + expect(isTimeoutMessage(msg)).toBe(true); + expect(parseTimeoutMs(msg)).toBe(30000); + }); + + it('classifies timeout-like errors', () => { + expect(isTimeoutLikeError(new Error('gsd-tools timed out after 1000ms: x'))).toBe(true); + const abort = new Error('aborted'); + abort.name = 'AbortError'; + expect(isTimeoutLikeError(abort)).toBe(true); + }); + + it('normalizes unknown error values', () => { + expect(errorMessage('boom')).toBe('boom'); + expect(errorMessage(new Error('x'))).toBe('x'); + }); +}); diff --git a/sdk/src/query-failure-classification.ts b/sdk/src/query-failure-classification.ts new file mode 100644 index 000000000..63ddbd3f1 --- /dev/null +++ b/sdk/src/query-failure-classification.ts @@ -0,0 +1,24 @@ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function parseTimeoutMs(message: string): number | undefined { + const m = message.match(/timed out after\s+(\d+)ms/i); + if (!m) return undefined; + const n = Number.parseInt(m[1], 10); + return Number.isFinite(n) ? n : undefined; +} + +export function isTimeoutMessage(message: string): boolean { + return /timed out after/i.test(message); +} + +export function isTimeoutLikeError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (error.name === 'TimeoutError' || error.name === 'AbortError') return true; + return isTimeoutMessage(error.message); +} + +export function timeoutMessage(command: string, args: string[], timeoutMs: number): string { + return `gsd-tools timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}`; +} diff --git a/sdk/src/query-native-direct-adapter.ts b/sdk/src/query-native-direct-adapter.ts index f87792d10..96a4982e5 100644 --- a/sdk/src/query-native-direct-adapter.ts +++ b/sdk/src/query-native-direct-adapter.ts @@ -1,4 +1,5 @@ import { formatQueryRawOutput } from './query-raw-output-projection.js'; +import { timeoutMessage } from './query-failure-classification.js'; import type { QueryResult } from './query/utils.js'; export interface QueryNativeDirectAdapterDeps { @@ -33,7 +34,7 @@ export class QueryNativeDirectAdapter { timeoutId = setTimeout(() => { reject( this.deps.createTimeoutError( - `gsd-tools timed out after ${this.deps.timeoutMs}ms: ${legacyCommand} ${legacyArgs.join(' ')}`, + timeoutMessage(legacyCommand, legacyArgs, this.deps.timeoutMs), legacyCommand, legacyArgs, ), diff --git a/sdk/src/query-subprocess-adapter.ts b/sdk/src/query-subprocess-adapter.ts index f7b567d37..e63df45f7 100644 --- a/sdk/src/query-subprocess-adapter.ts +++ b/sdk/src/query-subprocess-adapter.ts @@ -1,5 +1,6 @@ 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'; export interface QuerySubprocessAdapterDeps { @@ -40,7 +41,7 @@ export class QuerySubprocessAdapter { if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { reject( this.deps.createToolsError( - `gsd-tools timed out after ${this.deps.timeoutMs}ms: ${command} ${args.join(' ')}`, + timeoutMessage(command, args, this.deps.timeoutMs), command, args, null, @@ -106,7 +107,7 @@ export class QuerySubprocessAdapter { if (error.killed || (error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { reject( this.deps.createToolsError( - `gsd-tools timed out after ${this.deps.timeoutMs}ms: ${command} ${args.join(' ')}`, + timeoutMessage(command, args, this.deps.timeoutMs), command, args, null, diff --git a/sdk/src/query-tools-error-mapper.ts b/sdk/src/query-tools-error-mapper.ts index 162e23720..913b91d6f 100644 --- a/sdk/src/query-tools-error-mapper.ts +++ b/sdk/src/query-tools-error-mapper.ts @@ -1,5 +1,6 @@ import { GSDError, exitCodeFor } from './errors.js'; import { GSDToolsError } from './gsd-tools-error.js'; +import { errorMessage } from './query-failure-classification.js'; /** * Module owning projection of internal errors to GSDToolsError contract. @@ -16,7 +17,7 @@ export function toGSDToolsError(command: string, args: string[], err: unknown): ); } - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return new GSDToolsError( msg, command, diff --git a/sdk/src/query/query-dispatch-error-mapper.ts b/sdk/src/query/query-dispatch-error-mapper.ts index e12f89458..4d5a8e48a 100644 --- a/sdk/src/query/query-dispatch-error-mapper.ts +++ b/sdk/src/query/query-dispatch-error-mapper.ts @@ -1,4 +1,5 @@ import type { QueryDispatchError, QueryDispatchResult } from './query-dispatch-contract.js'; +import { errorMessage, isTimeoutMessage, parseTimeoutMs } from '../query-failure-classification.js'; import { fallbackFailureError, nativeFailureError, nativeTimeoutError } from './query-error-taxonomy.js'; import { dispatchFailure } from './query-dispatch-result-builder.js'; @@ -10,21 +11,14 @@ export function toDispatchFailure( } export function mapNativeDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - const message = error instanceof Error ? error.message : String(error); - if (/timed out after/i.test(message)) { + const message = errorMessage(error); + if (isTimeoutMessage(message)) { return nativeTimeoutError({ message, command, args, timeoutMs: parseTimeoutMs(message) }); } return nativeFailureError({ message, command, args }); } export function mapFallbackDispatchError(error: unknown, command: string, args: string[]): QueryDispatchError { - const message = error instanceof Error ? error.message : String(error); + const message = errorMessage(error); return fallbackFailureError({ message, command, args, backend: 'cjs' }); } - -function parseTimeoutMs(message: string): number | undefined { - const m = message.match(/timed out after\s+(\d+)ms/i); - if (!m) return undefined; - const n = Number.parseInt(m[1], 10); - return Number.isFinite(n) ? n : undefined; -}