fix: handle ambiguous prompt transport failures

This commit is contained in:
Bohdan Triapitsyn
2026-07-07 19:49:11 +03:00
parent c801c7a74b
commit 40dfff4a9a
12 changed files with 456 additions and 100 deletions
@@ -2,8 +2,21 @@ export const createOpenCodeNetworkRuntime = (deps) => {
const {
state,
getOpenCodeAuthHeaders,
configuredOpenCodeHostname = '127.0.0.1',
} = deps;
const resolveConnectHostname = () => {
const raw = typeof configuredOpenCodeHostname === 'string' ? configuredOpenCodeHostname.trim() : '';
const hostname = raw || '127.0.0.1';
if (hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]') {
return '127.0.0.1';
}
if (hostname.startsWith('[') && hostname.endsWith(']')) {
return hostname;
}
return hostname.includes(':') ? `[${hostname}]` : hostname;
};
const normalizeApiPrefix = (prefix) => {
if (!prefix) {
return '';
@@ -77,7 +90,7 @@ export const createOpenCodeNetworkRuntime = (deps) => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : '');
const fullPath = `${prefix}${normalizedPath}`;
const base = state.openCodeBaseUrl ?? `http://localhost:${state.openCodePort}`;
const base = state.openCodeBaseUrl ?? `http://${resolveConnectHostname()}:${state.openCodePort}`;
return `${base}${fullPath}`;
};
@@ -2,36 +2,56 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { createOpenCodeNetworkRuntime } from './network-runtime.js';
const createRuntime = () => createOpenCodeNetworkRuntime({
const originalFetch = globalThis.fetch;
const createRuntime = (overrides = {}) => createOpenCodeNetworkRuntime({
state: {
openCodePort: 4096,
openCodeBaseUrl: null,
openCodeApiPrefix: '',
openCodeApiPrefixDetected: false,
openCodeApiDetectionTimer: null,
...overrides.state,
},
getOpenCodeAuthHeaders: () => ({}),
configuredOpenCodeHostname: overrides.configuredOpenCodeHostname,
});
describe('OpenCode network runtime', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
globalThis.fetch = originalFetch;
});
it('clears the probe abort timer when readiness fetch rejects', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
vi.stubGlobal('fetch', vi.fn(async () => {
it('returns false when readiness fetch rejects', async () => {
globalThis.fetch = vi.fn(async () => {
throw new Error('offline');
}));
});
const runtime = createRuntime();
const readyPromise = runtime.waitForReady('http://127.0.0.1:4096', 1);
await vi.advanceTimersByTimeAsync(100);
await expect(readyPromise).resolves.toBe(false);
});
expect(vi.getTimerCount()).toBe(0);
it('builds managed OpenCode URLs against IPv4 loopback by default', () => {
const runtime = createRuntime();
expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://127.0.0.1:4096/provider');
});
it('keeps external OpenCode base URLs authoritative', () => {
const runtime = createRuntime({
state: { openCodeBaseUrl: 'http://remote.example:4096' },
});
expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://remote.example:4096/provider');
});
it('normalizes wildcard and IPv6 OpenCode bind hosts for local connects', () => {
expect(createRuntime({ configuredOpenCodeHostname: '0.0.0.0' }).buildOpenCodeUrl('/provider'))
.toBe('http://127.0.0.1:4096/provider');
expect(createRuntime({ configuredOpenCodeHostname: '::1' }).buildOpenCodeUrl('/provider'))
.toBe('http://[::1]:4096/provider');
});
});
+48 -7
View File
@@ -181,6 +181,7 @@ export const registerOpenCodeProxy = (app, deps) => {
os,
path,
OPEN_CODE_READY_GRACE_MS,
LONG_REQUEST_TIMEOUT_MS,
getRuntime,
getOpenCodeAuthHeaders,
buildOpenCodeUrl,
@@ -291,13 +292,48 @@ export const registerOpenCodeProxy = (app, deps) => {
return externalBase;
}
if (runtimeState.openCodePort) {
return `http://localhost:${runtimeState.openCodePort}`;
}
return FALLBACK_PROXY_TARGET;
};
const normalizeProxyTimeout = (value) => {
return Number.isFinite(value) && value > 0 ? value : 4 * 60 * 1000;
};
const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS);
const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut');
const isProxyTimeoutError = (error) => {
const code = typeof error?.code === 'string' ? error.code : '';
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
return code === 'ETIMEDOUT'
|| code === 'ESOCKETTIMEDOUT'
|| message.includes('timeout')
|| message.includes('timed out');
};
const sendProxyErrorResponse = (res, statusCode) => {
if (!res || res.headersSent || res.writableEnded || typeof res.status !== 'function') {
return false;
}
res.status(statusCode).json({ error: statusCode === 504 ? 'OpenCode upstream timed out' : 'OpenCode service unavailable' });
return true;
};
const applyProxyResponseDeadline = (req, res, next) => {
const timeout = setTimeout(() => {
req[PROXY_TIMEOUT_MARKER] = true;
if (sendProxyErrorResponse(res, 504)) {
res.once('finish', () => req.destroy?.());
}
}, PROXY_REQUEST_TIMEOUT_MS);
timeout.unref?.();
const clear = () => clearTimeout(timeout);
res.once('finish', clear);
res.once('close', clear);
next();
};
const forwardSseRequest = async (req, res) => {
const abortController = new AbortController();
const closeUpstream = () => abortController.abort();
@@ -665,6 +701,8 @@ export const registerOpenCodeProxy = (app, deps) => {
target: resolveProxyTarget(),
changeOrigin: true,
pathRewrite: { '^/api': '' },
timeout: PROXY_REQUEST_TIMEOUT_MS,
proxyTimeout: PROXY_REQUEST_TIMEOUT_MS,
// Dynamic target — port can change after restart
router: () => resolveProxyTarget(),
on: {
@@ -700,11 +738,13 @@ export const registerOpenCodeProxy = (app, deps) => {
}
}
},
error: (err, _req, res) => {
error: (err, req, res) => {
console.error('[proxy] OpenCode proxy error:', err.message);
if (res && !res.headersSent && typeof res.status === 'function') {
res.status(503).json({ error: 'OpenCode service unavailable' });
if (req?.[PROXY_TIMEOUT_MARKER]) {
return;
}
const statusCode = isProxyTimeoutError(err) ? 504 : 503;
sendProxyErrorResponse(res, statusCode);
},
},
});
@@ -724,5 +764,6 @@ export const registerOpenCodeProxy = (app, deps) => {
next();
});
app.use('/api', applyProxyResponseDeadline);
app.use('/api', apiProxy);
};