Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions packages/studio/src/muapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ function notifyAuthRequired(status, detail) {
window.dispatchEvent(new CustomEvent('muapi:auth-required', { detail: { status, message: detail } }));
}

// Marks an error as terminal so a polling loop rethrows instead of retrying.
// Without this, a `throw` inside the try is caught by that same loop's catch
// and swallowed, so an out-of-credits or already-failed job keeps polling for
// the full maxAttempts (30 minutes at the 900-attempt default) and looks
// identical to one that is still running.
function fatal(message) {
const error = new Error(message);
error.isFatal = true;
return error;
}

async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000) {
const pollUrl = `${BASE_URL}/api/v1/predictions/${requestId}/result`;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
Expand All @@ -26,14 +37,14 @@ async function pollForResult(requestId, key, maxAttempts = 900, interval = 2000)
const errText = await response.text();
if (response.status >= 500) continue;
notifyAuthRequired(response.status, errText);
throw new Error(`Poll Failed: ${response.status} - ${errText.slice(0, 100)}`);
throw fatal(`Poll Failed: ${response.status} - ${errText.slice(0, 100)}`);
}
const data = await response.json();
const status = data.status?.toLowerCase();
if (status === 'completed' || status === 'succeeded' || status === 'success') return data;
if (status === 'failed' || status === 'error') throw new Error(`Generation failed: ${data.error || 'Unknown error'}`);
if (status === 'failed' || status === 'error') throw fatal(`Generation failed: ${data.error || 'Unknown error'}`);
} catch (error) {
if (attempt === maxAttempts) throw error;
if (error.isFatal || attempt === maxAttempts) throw error;
}
}
throw new Error('Generation timed out after polling.');
Expand Down Expand Up @@ -596,14 +607,14 @@ async function pollWorkflowResult(runId, apiKey, maxAttempts = 900, interval = 2
});
if (!response.ok) {
if (response.status >= 500) continue;
throw new Error(`Poll Failed: ${response.status}`);
throw fatal(`Poll Failed: ${response.status}`);
}
const data = await response.json();
const status = data.status?.toLowerCase();
if (status === 'completed' || status === 'succeeded' || status === 'success') return data;
if (status === 'failed' || status === 'error') throw new Error(`Workflow failed: ${data.error || 'Unknown error'}`);
if (status === 'failed' || status === 'error') throw fatal(`Workflow failed: ${data.error || 'Unknown error'}`);
} catch (error) {
if (attempt === maxAttempts) throw error;
if (error.isFatal || attempt === maxAttempts) throw error;
}
}
throw new Error('Workflow timed out after polling.');
Expand Down
21 changes: 17 additions & 4 deletions src/lib/muapi.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { getModelById, getVideoModelById, getI2IModelById, getI2VModelById, getV2VModelById, getLipSyncModelById } from './models.js';

// Marks an error as terminal so the polling loop rethrows instead of retrying.
function fatal(message) {
const error = new Error(message);
error.isFatal = true;
return error;
}

export class MuapiClient {
constructor() {
// Ideally user provides this in settings
Expand Down Expand Up @@ -140,9 +147,10 @@ export class MuapiClient {
if (!response.ok) {
const errText = await response.text();
console.warn(`[Muapi] Poll error (${response.status}):`, errText);
// Continue polling on non-fatal errors
// 5xx is transient — keep polling. Anything else (401, 402,
// 404, 429) will not fix itself, so stop immediately.
if (response.status >= 500) continue;
throw new Error(`Poll Failed: ${response.status} - ${errText.slice(0, 100)}`);
throw fatal(`Poll Failed: ${response.status} - ${errText.slice(0, 100)}`);
}

const data = await response.json();
Expand All @@ -155,12 +163,17 @@ export class MuapiClient {
}

if (status === 'failed' || status === 'error') {
throw new Error(`Generation failed: ${data.error || 'Unknown error'}`);
throw fatal(`Generation failed: ${data.error || 'Unknown error'}`);
}

// Otherwise (processing, pending, etc.) keep polling
} catch (error) {
if (attempt === maxAttempts) throw error;
// Terminal conditions must not be retried. Without this the
// throws above are caught right here and the loop grinds on for
// the full maxAttempts, so an out-of-credits or already-failed
// job is indistinguishable from one still generating — up to
// 30 minutes of spinner for video, which uses 900 attempts.
if (error.isFatal || attempt === maxAttempts) throw error;
console.warn('[Muapi] Poll attempt failed, retrying...', error.message);
}
}
Expand Down
130 changes: 130 additions & 0 deletions tests/muapiPolling.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');

// src/lib/muapi.js is an ES module for the browser and pulls in models.js, so
// rather than importing it we extract the class body and evaluate just the
// polling method. That keeps the test dependency-free like the rest of the
// suite while still exercising the shipped source.
// Returns the text of the block starting at `marker`, from the marker through
// the brace that closes it. Brace-matched rather than index-guessed so the test
// keeps working as the surrounding file changes.
function extractBlock(source, marker) {
const start = source.indexOf(marker);
assert.ok(start !== -1, `not found in src/lib/muapi.js: ${marker}`);

let depth = 0;
let seenOpen = false;
for (let i = start; i < source.length; i++) {
if (source[i] === '{') {
depth++;
seenOpen = true;
} else if (source[i] === '}') {
depth--;
if (seenOpen && depth === 0) return source.slice(start, i + 1);
}
}
throw new Error(`unbalanced braces after ${marker}`);
}

function loadPollForResult() {
const source = fs.readFileSync(
path.resolve(__dirname, '..', 'src', 'lib', 'muapi.js'),
'utf8'
);

const script = `
${extractBlock(source, 'function fatal(')}
const obj = {
${extractBlock(source, 'async pollForResult(')}
};
obj.pollForResult;
`;

const context = { console: { log() {}, warn() {} }, fetch: null, setTimeout };
vm.createContext(context);
const run = vm.runInContext(script, context);
assert.equal(typeof run, 'function', 'failed to extract pollForResult');
return { run, context };
}

function respond(status, body) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
json: async () => body,
};
}

test('a 402 stops polling immediately instead of retrying to the limit', async () => {
const { run, context } = loadPollForResult();
let calls = 0;
context.fetch = async () => {
calls += 1;
return respond(402, 'insufficient credits');
};

await assert.rejects(
run.call({ baseUrl: '' }, 'req-1', 'key', 900, 0),
/Poll Failed: 402/
);
assert.equal(calls, 1, 'expected exactly one request, got ' + calls);
});

test('a failed job status stops polling immediately', async () => {
const { run, context } = loadPollForResult();
let calls = 0;
context.fetch = async () => {
calls += 1;
return respond(200, { status: 'failed', error: 'model exploded' });
};

await assert.rejects(
run.call({ baseUrl: '' }, 'req-2', 'key', 900, 0),
/Generation failed: model exploded/
);
assert.equal(calls, 1, 'expected exactly one request, got ' + calls);
});

test('5xx responses are still treated as transient and retried', async () => {
const { run, context } = loadPollForResult();
let calls = 0;
context.fetch = async () => {
calls += 1;
if (calls < 3) return respond(503, 'upstream busy');
return respond(200, { status: 'completed', outputs: ['https://example.test/a.png'] });
};

const result = await run.call({ baseUrl: '' }, 'req-3', 'key', 900, 0);
assert.equal(result.status, 'completed');
assert.equal(calls, 3);
});

test('pending statuses keep polling until the job completes', async () => {
const { run, context } = loadPollForResult();
let calls = 0;
context.fetch = async () => {
calls += 1;
if (calls < 4) return respond(200, { status: 'pending' });
return respond(200, { status: 'succeeded', outputs: ['https://example.test/b.mp4'] });
};

const result = await run.call({ baseUrl: '' }, 'req-4', 'key', 900, 0);
assert.equal(result.status, 'succeeded');
assert.equal(calls, 4);
});

test('network errors remain retryable and surface after the last attempt', async () => {
const { run, context } = loadPollForResult();
let calls = 0;
context.fetch = async () => {
calls += 1;
throw new Error('ECONNRESET');
};

await assert.rejects(run.call({ baseUrl: '' }, 'req-5', 'key', 3, 0), /ECONNRESET/);
assert.equal(calls, 3, 'transient network failures should use every attempt');
});