fix(wiki): Remove the hidden 60s default timeout, validate gitnexus wiki timeout/retry flags, and surface timeout errors (#1651)

This commit is contained in:
Copilot 2026-05-17 12:03:54 +01:00 committed by GitHub
parent dfbe68ad24
commit ed50a6729f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 579 additions and 16 deletions

View file

@ -725,7 +725,7 @@ gitnexus wiki --force
# Increase the timeout or retries for large codebase or slow LLM providers
gitnexus wiki --timeout <seconds> # Per-attempt LLM request timeout in seconds (default: 60)
gitnexus wiki --timeout <seconds> # LLM request timeout in seconds (default: disabled)
gitnexus wiki --retries <n> # Max LLM retry attempts per request (default: 3)
```

View file

@ -62,7 +62,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--gist` | Publish wiki as a public GitHub Gist |
| `--timeout <seconds>` | Per-attempt LLM request timeout in seconds (default: 60) |
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
### list — Show all indexed repos

View file

@ -161,7 +161,7 @@ program
)
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
.option('--timeout <seconds>', 'Per-attempt LLM request timeout in seconds (default: 60)')
.option('--timeout <seconds>', 'LLM request timeout in seconds (default: disabled)')
.option('--retries <n>', 'Max LLM retry attempts per request (default: 3)')
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')

View file

@ -37,6 +37,23 @@ export interface WikiCommandOptions {
retries?: string;
}
function parsePositiveIntegerOption(
value: string | undefined,
flag: string,
multiplier = 1,
): number | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (!/^[1-9]\d*$/.test(trimmed)) {
throw new Error(`${flag} must be a positive integer`);
}
const parsed = parseInt(trimmed, 10);
if (parsed > Math.floor(Number.MAX_SAFE_INTEGER / multiplier)) {
throw new Error(`${flag} is too large`);
}
return parsed;
}
/**
* Prompt the user for input via stdin.
*/
@ -127,6 +144,17 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
return;
}
let timeoutSeconds: number | undefined;
let retries: number | undefined;
try {
timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000);
retries = parsePositiveIntegerOption(options?.retries, '--retries');
} catch (error) {
console.log(` Error: ${(error as Error).message}\n`);
process.exitCode = 1;
return;
}
// ── Resolve LLM config (with interactive fallback) ─────────────────
// Save any CLI overrides immediately
if (
@ -350,13 +378,11 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
}
// ── Apply per-run overrides not saved to config ────────────────────
if (options?.timeout) {
const secs = parseInt(options.timeout, 10);
if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000;
if (timeoutSeconds !== undefined) {
llmConfig.requestTimeoutMs = timeoutSeconds * 1000;
}
if (options?.retries) {
const n = parseInt(options.retries, 10);
if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n;
if (retries !== undefined) {
llmConfig.maxAttempts = retries;
}
// ── Setup progress bar with elapsed timer ──────────────────────────
@ -563,6 +589,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
if (err.message?.includes('No source files')) {
console.log(`\n ${err.message}\n`);
} else if (err.message?.includes('LLM request timed out after')) {
console.log(`\n Timeout: ${err.message}\n`);
} else if (err.message?.includes('content filter')) {
// Content filter block — actionable message
console.log(`\n Content Filter: ${err.message}\n`);

View file

@ -23,7 +23,7 @@ export interface LLMConfig {
apiVersion?: string;
/** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */
isReasoningModel?: boolean;
/** Per-attempt fetch timeout in ms (default: 60_000). */
/** Per-attempt fetch timeout in ms. Omit to disable request timeouts. */
requestTimeoutMs?: number;
/** Max fetch attempts before giving up (default: 3). */
maxAttempts?: number;
@ -81,6 +81,19 @@ export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function formatTimeoutDuration(timeoutMs: number): string {
if (timeoutMs >= 1000 && timeoutMs % 1000 === 0) {
return `${timeoutMs / 1000}s`;
}
return `${timeoutMs}ms`;
}
function isTimeoutLikeError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
if (err.name === 'TimeoutError' || err.name === 'AbortError') return true;
return /time(d)?\s*out|timeout/i.test(err.message);
}
/**
* Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS
* endpoint (CWE-918 / CodeQL js/http-to-file-access).
@ -237,12 +250,13 @@ export async function callLLM(
...authHeaders,
},
body: JSON.stringify(body),
// Per-attempt timeout. Without this each retry can hang
// indefinitely on a frozen TCP connection — the per-call
// signal is the only timeout `resilientFetch` honors;
// `capDelayMs` only bounds the *backoff* between attempts.
// Default 60s; raise via --timeout for slow models or large pages.
signal: AbortSignal.timeout(config.requestTimeoutMs ?? 60_000),
// Request timeout is opt-in for wiki generation. Large local
// model runs can legitimately take well over a minute, so the
// default runtime path must not impose a hidden 60s ceiling.
signal:
config.requestTimeoutMs !== undefined
? AbortSignal.timeout(config.requestTimeoutMs)
: undefined,
},
{
breakerKey: `wiki-llm-${new URL(url).host}`,
@ -261,6 +275,12 @@ export async function callLLM(
`LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`,
);
}
if (config.requestTimeoutMs !== undefined && isTimeoutLikeError(err)) {
throw new Error(
`LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` +
'Increase --timeout or omit it to disable the request timeout.',
);
}
throw err;
}

View file

@ -264,6 +264,384 @@ describe('WikiGenerator --review mode', () => {
});
});
describe('wikiCommand --timeout validation', () => {
const originalExitCode = process.exitCode;
const tooLargeTimeout = String(Math.floor(Number.MAX_SAFE_INTEGER / 1000) + 1);
beforeEach(() => {
vi.resetModules();
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock('../../src/storage/git.js');
vi.doUnmock('../../src/storage/repo-manager.js');
vi.doUnmock('../../src/core/wiki/llm-client.js');
vi.doUnmock('../../src/core/wiki/generator.js');
vi.doUnmock('cli-progress');
process.exitCode = originalExitCode;
});
it.each(['', ' ', '0', '-1', 'abc', '3.14', tooLargeTimeout])(
'rejects invalid --timeout value %s before starting generation',
async (timeout) => {
const generatorCtor = vi.fn().mockImplementation(() => ({
run: vi.fn(),
}));
vi.doMock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(),
isGitRepo: vi.fn().mockReturnValue(true),
}));
vi.doMock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: vi
.fn()
.mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }),
loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }),
loadCLIConfig: vi.fn().mockResolvedValue({
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
provider: 'openai',
}),
saveCLIConfig: vi.fn(),
}));
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
return {
...actual,
resolveLLMConfig: vi.fn().mockResolvedValue({
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 16_384,
temperature: 0,
provider: 'openai',
}),
};
});
vi.doMock('../../src/core/wiki/generator.js', () => ({
WikiGenerator: generatorCtor,
}));
vi.doMock('cli-progress', () => ({
default: {
SingleBar: vi.fn(function () {
return {
start: vi.fn(),
update: vi.fn(),
stop: vi.fn(),
};
}),
Presets: { shades_grey: {} },
},
}));
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { wikiCommand } = await import('../../src/cli/wiki.js');
await wikiCommand('/tmp/repo', { timeout });
expect(process.exitCode).toBe(1);
expect(generatorCtor).not.toHaveBeenCalled();
const expectedMessage =
timeout === tooLargeTimeout
? ' Error: --timeout is too large\n'
: ' Error: --timeout must be a positive integer\n';
expect(consoleSpy).toHaveBeenCalledWith(expectedMessage);
},
);
});
describe('wikiCommand --retries validation', () => {
const originalExitCode = process.exitCode;
beforeEach(() => {
vi.resetModules();
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock('../../src/storage/git.js');
vi.doUnmock('../../src/storage/repo-manager.js');
vi.doUnmock('../../src/core/wiki/llm-client.js');
vi.doUnmock('../../src/core/wiki/generator.js');
vi.doUnmock('cli-progress');
process.exitCode = originalExitCode;
});
it.each(['', ' ', '0', '-1', 'abc', '3.14'])(
'rejects invalid --retries value %s before starting generation',
async (retries) => {
const generatorCtor = vi.fn().mockImplementation(() => ({
run: vi.fn(),
}));
vi.doMock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(),
isGitRepo: vi.fn().mockReturnValue(true),
}));
vi.doMock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: vi
.fn()
.mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }),
loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }),
loadCLIConfig: vi.fn().mockResolvedValue({
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
provider: 'openai',
}),
saveCLIConfig: vi.fn(),
}));
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
return {
...actual,
resolveLLMConfig: vi.fn().mockResolvedValue({
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 16_384,
temperature: 0,
provider: 'openai',
}),
};
});
vi.doMock('../../src/core/wiki/generator.js', () => ({
WikiGenerator: generatorCtor,
}));
vi.doMock('cli-progress', () => ({
default: {
SingleBar: vi.fn(function () {
return {
start: vi.fn(),
update: vi.fn(),
stop: vi.fn(),
};
}),
Presets: { shades_grey: {} },
},
}));
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { wikiCommand } = await import('../../src/cli/wiki.js');
await wikiCommand('/tmp/repo', { retries });
expect(process.exitCode).toBe(1);
expect(generatorCtor).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(' Error: --retries must be a positive integer\n');
},
);
});
describe('wikiCommand --timeout mapping', () => {
const originalExitCode = process.exitCode;
beforeEach(() => {
vi.resetModules();
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock('../../src/storage/git.js');
vi.doUnmock('../../src/storage/repo-manager.js');
vi.doUnmock('../../src/core/wiki/llm-client.js');
vi.doUnmock('../../src/core/wiki/generator.js');
vi.doUnmock('cli-progress');
process.exitCode = originalExitCode;
});
async function loadWikiCommandHarness() {
let capturedConfig: Record<string, unknown> | undefined;
const generatorCtor = vi
.fn()
.mockImplementation(function (_repoPath, _storagePath, _lbugPath, config) {
capturedConfig = config;
return {
run: vi.fn().mockResolvedValue({ mode: 'up-to-date', pagesGenerated: 0 }),
};
});
vi.doMock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(),
isGitRepo: vi.fn().mockReturnValue(true),
}));
vi.doMock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: vi
.fn()
.mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }),
loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }),
loadCLIConfig: vi.fn().mockResolvedValue({
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
provider: 'openai',
}),
saveCLIConfig: vi.fn(),
}));
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
return {
...actual,
resolveLLMConfig: vi.fn().mockResolvedValue({
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 16_384,
temperature: 0,
provider: 'openai',
}),
};
});
vi.doMock('../../src/core/wiki/generator.js', () => ({
WikiGenerator: generatorCtor,
}));
vi.doMock('cli-progress', () => ({
default: {
SingleBar: vi.fn(function () {
return {
start: vi.fn(),
update: vi.fn(),
stop: vi.fn(),
};
}),
Presets: { shades_grey: {} },
},
}));
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { wikiCommand } = await import('../../src/cli/wiki.js');
return {
wikiCommand,
generatorCtor,
consoleSpy,
getCapturedConfig: () => capturedConfig,
};
}
it('maps --timeout seconds to requestTimeoutMs before constructing WikiGenerator', async () => {
const harness = await loadWikiCommandHarness();
await harness.wikiCommand('/tmp/repo', { timeout: '120' });
expect(harness.generatorCtor).toHaveBeenCalledTimes(1);
expect(harness.getCapturedConfig()?.requestTimeoutMs).toBe(120_000);
});
it('leaves requestTimeoutMs undefined when --timeout is omitted', async () => {
const harness = await loadWikiCommandHarness();
await harness.wikiCommand('/tmp/repo', {});
expect(harness.generatorCtor).toHaveBeenCalledTimes(1);
expect(harness.getCapturedConfig()?.requestTimeoutMs).toBeUndefined();
});
it('maps --retries to maxAttempts before constructing WikiGenerator', async () => {
const harness = await loadWikiCommandHarness();
await harness.wikiCommand('/tmp/repo', { retries: '5' });
expect(harness.generatorCtor).toHaveBeenCalledTimes(1);
expect(harness.getCapturedConfig()?.maxAttempts).toBe(5);
});
});
describe('wikiCommand timeout messaging', () => {
const originalExitCode = process.exitCode;
beforeEach(() => {
vi.resetModules();
process.exitCode = undefined;
});
afterEach(() => {
vi.restoreAllMocks();
vi.doUnmock('../../src/storage/git.js');
vi.doUnmock('../../src/storage/repo-manager.js');
vi.doUnmock('../../src/core/wiki/llm-client.js');
vi.doUnmock('../../src/core/wiki/generator.js');
vi.doUnmock('cli-progress');
process.exitCode = originalExitCode;
});
it('surfaces a dedicated timeout message when wiki generation hits the configured timeout', async () => {
const generatorCtor = vi.fn().mockImplementation(function () {
return {
run: vi
.fn()
.mockRejectedValue(
new Error(
'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.',
),
),
};
});
vi.doMock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(),
isGitRepo: vi.fn().mockReturnValue(true),
}));
vi.doMock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: vi
.fn()
.mockReturnValue({ storagePath: '/tmp/wiki-storage', lbugPath: '/tmp/wiki-db' }),
loadMeta: vi.fn().mockResolvedValue({ createdAt: '2026-01-01T00:00:00Z' }),
loadCLIConfig: vi.fn().mockResolvedValue({
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
provider: 'openai',
}),
saveCLIConfig: vi.fn(),
}));
vi.doMock('../../src/core/wiki/llm-client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/core/wiki/llm-client.js')>();
return {
...actual,
resolveLLMConfig: vi.fn().mockResolvedValue({
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 16_384,
temperature: 0,
provider: 'openai',
}),
};
});
vi.doMock('../../src/core/wiki/generator.js', () => ({
WikiGenerator: generatorCtor,
}));
vi.doMock('cli-progress', () => ({
default: {
SingleBar: vi.fn(function () {
return {
start: vi.fn(),
update: vi.fn(),
stop: vi.fn(),
};
}),
Presets: { shades_grey: {} },
},
}));
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const { wikiCommand } = await import('../../src/cli/wiki.js');
await wikiCommand('/tmp/repo', { timeout: '120' });
expect(process.exitCode).toBe(1);
expect(generatorCtor).toHaveBeenCalledTimes(1);
expect(consoleSpy).toHaveBeenCalledWith(
'\n Timeout: LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.\n',
);
});
});
// ─── CLI config round-trip with cursor provider ──────────────────────
describe('CLI config round-trip with cursor provider', () => {

View file

@ -237,6 +237,143 @@ describe('callLLM — reasoning model params', () => {
});
});
describe('callLLM — timeout handling', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('does not apply a default timeout when requestTimeoutMs is omitted', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchSpy);
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
await callLLM('test', {
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 500,
temperature: 0,
});
expect(timeoutSpy).not.toHaveBeenCalled();
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(init.signal).toBeUndefined();
});
it('applies an explicit timeout when requestTimeoutMs is provided', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: 'answer' } }], usage: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchSpy);
const timeoutSignal = new AbortController().signal;
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(timeoutSignal);
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
await callLLM('test', {
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 500,
temperature: 0,
requestTimeoutMs: 120_000,
});
expect(timeoutSpy).toHaveBeenCalledWith(120_000);
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(init.signal).toBe(timeoutSignal);
});
it('surfaces a clear timeout error when the request timeout fires', async () => {
const fetchSpy = vi
.fn()
.mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError'));
vi.stubGlobal('fetch', fetchSpy);
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
await expect(
callLLM('test', {
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 500,
temperature: 0,
requestTimeoutMs: 120_000,
}),
).rejects.toThrow(
'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.',
);
});
it('surfaces millisecond timeout durations when the timeout is not a whole second', async () => {
const fetchSpy = vi
.fn()
.mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError'));
vi.stubGlobal('fetch', fetchSpy);
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
await expect(
callLLM('test', {
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 500,
temperature: 0,
requestTimeoutMs: 1_500,
}),
).rejects.toThrow(
'LLM request timed out after 1500ms. Increase --timeout or omit it to disable the request timeout.',
);
});
it('surfaces the same timeout message for timeout-like non-DOM errors', async () => {
const fetchSpy = vi
.fn()
.mockRejectedValue(new Error('request timed out while waiting for response'));
vi.stubGlobal('fetch', fetchSpy);
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
await expect(
callLLM('test', {
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 500,
temperature: 0,
requestTimeoutMs: 120_000,
}),
).rejects.toThrow(
'LLM request timed out after 120s. Increase --timeout or omit it to disable the request timeout.',
);
});
it('does not mislabel generic aborted connections as request timeouts', async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error('connection aborted by server'));
vi.stubGlobal('fetch', fetchSpy);
const { callLLM } = await import('../../src/core/wiki/llm-client.js');
await expect(
callLLM('test', {
apiKey: 'sk-test',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
maxTokens: 500,
temperature: 0,
requestTimeoutMs: 120_000,
}),
).rejects.toThrow('connection aborted by server');
});
});
describe('callLLM — Azure content_filter error', () => {
afterEach(() => vi.unstubAllGlobals());