A browser submits a generation request. The provider accepts it, but the next status request times out. Re-enabling the button is sensible UI cleanup. Refunding the attempt and automatically submitting a second job is a different decision, and may create duplicate work.
Disclosure: I maintain RetroPrompt. This article was drafted with AI assistance, checked against the current source, and its standalone example was executed locally. It describes implementation behavior, not a provider reliability claim.
Keep three outcomes
The backend needs to distinguish completed, explicitly failed, and uncertain. A terminal failure reported by the provider is evidence that the task failed. A broken connection is only evidence that this client did not obtain an answer. The provider might still be working, or its completed response might have been lost.
In this project's Worker, the image provider's task is polled on the server. The browser awaits one generation POST; it does not run its own polling interval. A confirmed terminal failure releases the reserved allowance. A polling or result-download error leaves the attempt uncertain and retains that reservation until the UTC reset. There is no automatic resubmission in those paths.
Separate UI cleanup from accounting
The frontend sets a busy flag before submitting and clears it in a finally block, then refreshes the displayed allowance. That restores interaction regardless of success or failure. It does not claim the remote job was cancelled. Likewise, stopping a timer or aborting a fetch only controls local waiting unless the provider separately supports cancellation.
This reduced example isolates the distinction. Save it as outcome.mjs and run node outcome.mjs. It makes no network requests and consumes no generation quota.
import assert from 'node:assert/strict';
async function observe(readStatus, attempts = 3) {
const state = { busy: true, reserved: true, outcome: 'uncertain' };
try {
for (let i = 0; i < attempts; i++) {
const status = await readStatus();
if (status === 'completed') {
state.outcome = 'success';
state.reserved = false; // converted to used in real accounting
break;
}
if (status === 'failed') {
state.outcome = 'failed';
state.reserved = false;
break;
}
}
} catch {
// A transport failure does not prove remote failure.
} finally {
state.busy = false;
}
return state;
}
assert.deepEqual(await observe(async () => 'failed'),
{ busy: false, reserved: false, outcome: 'failed' });
assert.deepEqual(await observe(async () => { throw Error('timeout'); }),
{ busy: false, reserved: true, outcome: 'uncertain' });
assert.equal((await observe(async () => 'pending')).outcome, 'uncertain');
assert.equal((await observe(async () => 'completed')).outcome, 'success');
console.log('4 outcome checks passed');
The booleans here demonstrate transitions; they are not a quota database. Production accounting also needs atomic reservation, persistence, and a distinction between pending and successfully used allowance. This sample omits those concerns deliberately rather than pretending that a local object prevents concurrent requests.
A poll count is not a wall-clock deadline
The actual Worker permits up to 45 polls, waits two seconds before each one, and applies a 15-second timeout to each status request. Multiplying 45 by two does not establish a 90-second total timeout: request durations also contribute. Upload, submission, and downloading the result take additional time.
If a product needs a strict overall deadline, budget elapsed time across every stage. Keep that requirement distinct from a limit on poll count. Also decide what happens to the unresolved provider task after the caller stops waiting; a deadline does not answer that accounting question.
For verification, inject a provider stub that returns terminal failure, throws during polling, or fails during download. Assert the resulting reservation as well as the HTTP response, and assert that only one submission occurred. A friendly error message alone cannot reveal whether the backend accidentally started a second paid job.
Keep the test provider isolated from live credentials and real billing. Deterministic failure responses are enough to verify these transitions; there is no need to pay for an image just to exercise an error branch.