fix: handle ambiguous prompt transport failures
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user