mirror of
https://github.com/signerless/llm-checker.git
synced 2026-10-19 09:54:45 +00:00
fix(ollama): retry native fetch failures with node-fetch
Retry Node's native fetch on Windows-style network failures before reporting Ollama as unavailable. Add regression coverage for the case where curl can reach Ollama but the undici fetch path throws 'fetch failed'.
This commit is contained in:
parent
59eb705ead
commit
68ec697a31
3 changed files with 190 additions and 4 deletions
|
|
@ -7,11 +7,68 @@ function getNodeFetch() {
|
|||
return nodeFetchPromise;
|
||||
}
|
||||
|
||||
function fetchWithFallback(...args) {
|
||||
if (typeof globalThis.fetch === 'function') {
|
||||
return globalThis.fetch(...args);
|
||||
function isNodeNativeFetch(fetchImpl) {
|
||||
if (typeof fetchImpl !== 'function' || process.release?.name !== 'node') {
|
||||
return false;
|
||||
}
|
||||
return getNodeFetch().then((fetchImpl) => fetchImpl(...args));
|
||||
|
||||
const source = Function.prototype.toString.call(fetchImpl);
|
||||
return (
|
||||
source.includes('internal/deps/undici/undici') ||
|
||||
source.includes('lazy loading of undici module') ||
|
||||
source.includes('[native code]')
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableNativeFetchError(error) {
|
||||
const details = [
|
||||
error?.message,
|
||||
error?.code,
|
||||
error?.errno,
|
||||
error?.cause?.message,
|
||||
error?.cause?.code,
|
||||
error?.cause?.errno
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((value) => String(value).toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
details.includes('fetch failed') ||
|
||||
details.includes('econnrefused') ||
|
||||
details.includes('econnreset') ||
|
||||
details.includes('enotfound') ||
|
||||
details.includes('eai_again') ||
|
||||
details.includes('etimedout') ||
|
||||
details.includes('timed out') ||
|
||||
details.includes('enetunreach') ||
|
||||
details.includes('ehostunreach') ||
|
||||
details.includes('network') ||
|
||||
details.includes('socket') ||
|
||||
details.includes('connect')
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchWithFallback(...args) {
|
||||
if (typeof globalThis.fetch === 'function') {
|
||||
if (!isNodeNativeFetch(globalThis.fetch)) {
|
||||
return globalThis.fetch(...args);
|
||||
}
|
||||
|
||||
try {
|
||||
return await globalThis.fetch(...args);
|
||||
} catch (error) {
|
||||
if (!isRetryableNativeFetchError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fetchImpl = await getNodeFetch();
|
||||
return fetchImpl(...args);
|
||||
}
|
||||
}
|
||||
|
||||
const fetchImpl = await getNodeFetch();
|
||||
return fetchImpl(...args);
|
||||
}
|
||||
|
||||
module.exports = fetchWithFallback;
|
||||
|
|
|
|||
128
tests/ollama-client-native-fetch-fallback.test.js
Normal file
128
tests/ollama-client-native-fetch-fallback.test.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
const assert = require('assert');
|
||||
const http = require('http');
|
||||
const OllamaClient = require('../src/ollama/client');
|
||||
|
||||
function createServer() {
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
requests.push(`${req.method} ${req.url}`);
|
||||
|
||||
if (req.url === '/api/version') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ version: '0.18.2' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/tags') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
models: [
|
||||
{
|
||||
name: 'llama3.2:3b',
|
||||
size: 2147483648,
|
||||
digest: 'abc123',
|
||||
modified_at: '2026-03-27T00:00:00Z',
|
||||
details: {
|
||||
family: 'llama',
|
||||
parameter_size: '3B',
|
||||
quantization_level: 'Q4_K_M',
|
||||
format: 'gguf'
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
resolve({
|
||||
server,
|
||||
requests,
|
||||
baseURL: `http://127.0.0.1:${address.port}`
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function testNativeFetchFailureFallsBackToNodeFetch() {
|
||||
const { server, requests, baseURL } = await createServer();
|
||||
const originalFetch = global.fetch;
|
||||
const nativeFetchCalls = [];
|
||||
|
||||
global.fetch = function fetch(url) {
|
||||
// Match the Node/undici source markers that the wrapper uses to detect native fetch.
|
||||
const undiciMarker = 'internal/deps/undici/undici';
|
||||
void undiciMarker;
|
||||
|
||||
nativeFetchCalls.push(url);
|
||||
const error = new TypeError('fetch failed');
|
||||
error.cause = { code: 'ECONNREFUSED' };
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
try {
|
||||
const client = new OllamaClient(baseURL);
|
||||
const availability = await client.checkOllamaAvailability();
|
||||
|
||||
assert.strictEqual(availability.available, true, 'availability should succeed via node-fetch retry');
|
||||
assert.strictEqual(client.baseURL, baseURL, 'client should preserve the working base URL');
|
||||
|
||||
const models = await client.getLocalModels();
|
||||
assert.strictEqual(models.length, 1, 'model listing should succeed via node-fetch retry');
|
||||
assert.strictEqual(models[0].name, 'llama3.2:3b');
|
||||
|
||||
assert.deepStrictEqual(
|
||||
nativeFetchCalls,
|
||||
[
|
||||
`${baseURL}/api/version`,
|
||||
`${baseURL}/api/tags`
|
||||
],
|
||||
'wrapper should try the native fetch path before retrying with node-fetch'
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
requests,
|
||||
[
|
||||
'GET /api/version',
|
||||
'GET /api/tags'
|
||||
],
|
||||
'node-fetch fallback should reach the Ollama endpoints after native fetch fails'
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
await closeServer(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await testNativeFetchFailureFallsBackToNodeFetch();
|
||||
console.log('ollama-client-native-fetch-fallback.test.js: OK');
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
run().catch((error) => {
|
||||
console.error('ollama-client-native-fetch-fallback.test.js: FAILED');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
|
|
@ -20,6 +20,7 @@ const TESTS = [
|
|||
{ name: 'Ollama capacity planner', file: 'ollama-capacity-planner.test.js', category: 'Ollama' },
|
||||
{ name: 'Ollama localhost fallback', file: 'ollama-client-localhost-fallback.test.js', category: 'Ollama' },
|
||||
{ name: 'Ollama IPv6 fallback', file: 'ollama-client-ipv6-fallback.test.js', category: 'Ollama' },
|
||||
{ name: 'Ollama native fetch fallback', file: 'ollama-client-native-fetch-fallback.test.js', category: 'Ollama' },
|
||||
{ name: 'Ollama selector localhost fallback', file: 'ollama-selector-localhost-fallback.test.js', category: 'Ollama' },
|
||||
{ name: 'Ollama selector IPv6 fallback', file: 'ollama-selector-ipv6-fallback.test.js', category: 'Ollama' },
|
||||
{ name: 'Ollama speed metrics', file: 'ollama-client-speed-metrics.test.js', category: 'Ollama' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue