Skip to content

Commit 419f9db

Browse files
morinokamiclaude
andauthored
fix(void): log queue handler errors instead of dropping them (#629)
## Summary When a queue handler throws, its error is never logged. The generated `__processQueue` catches it without binding it (`} catch { __ok = false; }`), so the reason is lost on every path: - Native Cloudflare consumers: the Worker's `queue()` throws a new `Error("queue: Handler failed.")`, and Workers Logs and `void project logs` show only that. - Dev: `/__void/queue` answers `{"ok":false}`, and nothing reaches the terminal. - Void Cloud: the relay gets a 200 `{"ok":false}` and retries the batch, and nothing is logged. The generated `scheduled()` already logs a failing job's error (`[void] Scheduled job … failed for cron "…"`). `__processQueue` in `packages/void/src/router/compile.ts` now does the same for queue handlers before marking the batch failed, which covers all three paths. When no handler matches the queue, `__processQueue` returned the reason only as `error`, which the native `queue()` dropped and the Void Cloud relay ignores, so it now logs that reason too. `handleQueueRelay` in `packages/void/src/cli/wrapper.ts` had the same `catch` and now logs too. The wrapper's native `queue()` already lets errors through, and the framework dev trigger already logs them (`[void] queue handler threw:`). The thrown `queue: Handler failed.`, the `{ ok: false }` result and the ack/retry decisions are unchanged. `docs/guide/queues.md` now says the handler's error is logged. ## Repro 1. `queues/fail.ts`: ```ts import { defineQueue } from 'void'; export default defineQueue(async () => { throw new Error('boom'); }); ``` 2. Start the dev server, then send it a message with the token it prints: ```sh curl -X POST http://localhost:5173/__void/queue \ -H "x-void-dev-trigger: <token>" \ -d '{"queue":"fail","messages":[{"id":"1","timestamp":0,"body":{},"attempts":1}]}' ``` - Before: the response is `{"ok":false}`, and `boom` is logged nowhere. - After: the response is unchanged, and the terminal shows `[void] Queue handler queues/fail.ts failed for queue "fail": Error: boom` with its stack. A message sent through the queue binding (Miniflare's native delivery) logs the same line. ## Tests - `packages/void/test/integration/queue-dispatch.test.ts`: the error a handler throws reaches `console.error` as the same object, with its file and queue, through `__processQueue` and through the native `queue()`. Both also log the reason for an unknown queue. - `packages/void/test/unit/wrapper.test.ts`: the same for the wrapper's HTTP relay. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0f88c87 commit 419f9db

5 files changed

Lines changed: 72 additions & 10 deletions

File tree

‎docs/guide/queues.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export default defineQueue<Message>(async (batch, env) => {
7676
- `batch.ackAll()`: acknowledge all messages in the batch
7777
- `batch.retryAll(options?)`: retry all messages in the batch
7878

79-
The first call wins for each message. Calling `ack()` after `retry()`, or the reverse, does nothing. Messages with no explicit decision are acknowledged if the handler succeeds, or retried if it throws.
79+
The first call wins for each message. Calling `ack()` after `retry()`, or the reverse, does nothing. Messages with no explicit decision are acknowledged if the handler succeeds, or retried if it throws. The handler's error is logged, so you don't need to catch errors just to log them.
8080

8181
The `timestamp` field on each message is a `Date` object.
8282

‎packages/void/src/cli/wrapper.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,12 @@ export async function handleQueueRelay(payload, env, ctx) {
903903
retryAll(options) { for (const message of messages) message.retry(options); },
904904
};
905905
let ok = true;
906-
try { await handleQueue(batch, env, ctx); } catch { ok = false; }
906+
try {
907+
await handleQueue(batch, env, ctx);
908+
} catch (error) {
909+
console.error(\`[void] Queue handler failed for queue "\${payload.queue}":\`, error);
910+
ok = false;
911+
}
907912
return { ok, ...(decisions.size ? { decisions: Object.fromEntries(decisions) } : {}) };
908913
}`);
909914
}

‎packages/void/src/router/compile.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,15 +553,23 @@ export function compileRouteTable(
553553
),
554554
);
555555
lines.push('');
556+
const queueLabels = scan.queues.map((queue) =>
557+
relative(options.projectRoot, `${queuesDir}/${queue.filePath}`),
558+
);
559+
lines.push(`const __queueLabels = ${JSON.stringify(queueLabels)};`);
556560
// Queue dispatch as a standalone function — called directly by native queue handler
557561
// and via the authenticated HTTP route. No global mutable state needed.
562+
// It logs why a batch failed: the native handler throws only "queue: Handler failed.",
563+
// the HTTP route returns no handler error, and the Void Cloud relay ignores `error`.
558564
lines.push('export async function __processQueue(payload, env) {');
559565
lines.push(' const queueSuffix = payload.queue;');
560566
lines.push(' const __queueIndex = __resolveQueueIndex(queueSuffix);');
561567
lines.push(' if (__queueIndex === -1) {');
562568
lines.push(
563-
' return { ok: false, error: `No unambiguous queue handler matches "${String(queueSuffix)}"` };',
569+
' const __error = `No unambiguous queue handler matches "${String(queueSuffix)}"`;',
564570
);
571+
lines.push(' console.error(`[void] ${__error}`);');
572+
lines.push(' return { ok: false, error: __error };');
565573
lines.push(' }');
566574
lines.push(' const __decisions = new Map();');
567575
lines.push(' function __wrapMsg(m) {');
@@ -599,7 +607,12 @@ export function compileRouteTable(
599607
lines.push(' }');
600608
}
601609
lines.push(' }');
602-
lines.push(' } catch { __ok = false; }');
610+
lines.push(' } catch (error) {');
611+
lines.push(
612+
' console.error(`[void] Queue handler ${__queueLabels[__queueIndex]} failed for queue "${String(queueSuffix)}":`, error);',
613+
);
614+
lines.push(' __ok = false;');
615+
lines.push(' }');
603616
lines.push(
604617
' return { ok: __ok, ...(__decisions.size > 0 ? { decisions: Object.fromEntries(__decisions) } : {}) };',
605618
);

‎packages/void/test/integration/queue-dispatch.test.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
33
import { join } from 'node:path';
44
import { pathToFileURL } from 'node:url';
55
import { build } from 'vite';
6-
import { afterAll, beforeAll, describe, expect, it, vi } from 'vite-plus/test';
6+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vite-plus/test';
77
import { compileRouteTable, compileWorkerEntry } from '../../src/router/compile.ts';
88
import type { ScanResult } from '../../src/router/scan.ts';
99

@@ -37,7 +37,7 @@ function queueHandlerSource(handler: string): string {
3737
attempts: batch.messages.map(message => message.attempts),
3838
dates: batch.messages.every(message => message.timestamp instanceof Date),
3939
});
40-
if (env.fail === ${JSON.stringify(handler)}) throw new Error("handler failed");
40+
if (env.fail === ${JSON.stringify(handler)}) throw env.failure;
4141
batch.messages[0]?.ack();
4242
batch.messages[1]?.retry({ delaySeconds: 9 });
4343
}\n`;
@@ -159,6 +159,10 @@ export { __processQueue } from "virtual:void-routes";\n`,
159159
rmSync(temporary, { force: true, recursive: true });
160160
});
161161

162+
afterEach(() => {
163+
vi.restoreAllMocks();
164+
});
165+
162166
it.each([
163167
['emails', 'emails'],
164168
['order-emails', 'order-emails'],
@@ -179,7 +183,8 @@ export { __processQueue } from "virtual:void-routes";\n`,
179183
expect(env.calls).toEqual([{ handler: expectedHandler, queue, attempts: [2, 3], dates: true }]);
180184
});
181185

182-
it('returns failure without dispatching an unknown logical or physical queue', async () => {
186+
it('logs and returns failure without dispatching an unknown logical or physical queue', async () => {
187+
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
183188
for (const queue of ['unknown', 'project-unknown']) {
184189
const env = { calls: [] as Array<Record<string, unknown>> };
185190
const result = await generated.__processQueue(payload(queue), env);
@@ -190,6 +195,27 @@ export { __processQueue } from "virtual:void-routes";\n`,
190195
});
191196
expect(env.calls).toEqual([]);
192197
}
198+
expect(errorLog.mock.calls).toEqual([
199+
['[void] No unambiguous queue handler matches "unknown"'],
200+
['[void] No unambiguous queue handler matches "project-unknown"'],
201+
]);
202+
});
203+
204+
it('logs a failed handler with its file and queue and returns failure', async () => {
205+
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
206+
const failure = new Error('handler failed');
207+
const env = { calls: [] as Array<Record<string, unknown>>, fail: 'order-emails', failure };
208+
const result = await generated.__processQueue(payload('project-order-emails'), env);
209+
210+
expect(result).toEqual({ ok: false });
211+
expect(env.calls).toEqual([
212+
{ handler: 'order-emails', queue: 'project-order-emails', attempts: [2, 3], dates: true },
213+
]);
214+
expect(errorLog.mock.calls).toHaveLength(1);
215+
expect(errorLog.mock.calls[0][0]).toBe(
216+
'[void] Queue handler queues/order-emails.ts failed for queue "project-order-emails":',
217+
);
218+
expect(errorLog.mock.calls[0][1]).toBe(failure);
193219
});
194220

195221
it.each(['emails', 'order-emails'])(
@@ -219,6 +245,8 @@ export { __processQueue } from "virtual:void-routes";\n`,
219245
);
220246

221247
it('makes native unknown queues and handler failures retry by rejecting the event', async () => {
248+
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
249+
const failure = new Error('handler failed');
222250
const makeNativeBatch = (queue: string) => {
223251
const firstAck = vi.fn();
224252
const firstRetry = vi.fn();
@@ -270,6 +298,7 @@ export { __processQueue } from "virtual:void-routes";\n`,
270298
expect(nativeSuccess.firstRetry).not.toHaveBeenCalled();
271299
expect(nativeSuccess.secondAck).not.toHaveBeenCalled();
272300
expect(nativeSuccess.secondRetry).toHaveBeenCalledWith({ delaySeconds: 9 });
301+
expect(errorLog).not.toHaveBeenCalled();
273302

274303
const nativeUnknown = makeNativeBatch('project-unknown');
275304

@@ -285,9 +314,17 @@ export { __processQueue } from "virtual:void-routes";\n`,
285314
await expect(
286315
generated.default.queue(
287316
nativeFailure.batch,
288-
{ calls: [], fail: 'order-emails' },
317+
{ calls: [], fail: 'order-emails', failure },
289318
{} as ExecutionContext,
290319
),
291320
).rejects.toThrow('queue: Handler failed');
321+
expect(errorLog.mock.calls).toHaveLength(2);
322+
expect(errorLog.mock.calls[0]).toEqual([
323+
'[void] No unambiguous queue handler matches "project-unknown"',
324+
]);
325+
expect(errorLog.mock.calls[1][0]).toBe(
326+
'[void] Queue handler queues/order-emails.ts failed for queue "project-order-emails":',
327+
);
328+
expect(errorLog.mock.calls[1][1]).toBe(failure);
292329
});
293330
});

‎packages/void/test/unit/wrapper.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ describe('generated wrapper queue dispatch', () => {
387387
for (const root of roots.splice(0, roots.length)) {
388388
rmSync(root, { recursive: true, force: true });
389389
}
390+
vi.restoreAllMocks();
390391
});
391392

392393
async function createRelayWorker() {
@@ -402,7 +403,7 @@ describe('generated wrapper queue dispatch', () => {
402403
`export default function(batch, env) {
403404
env.calls.push(batch.messages[0].timestamp.getTime());
404405
batch.messages[0].ack();
405-
if (env.fail) throw new Error('after first message');
406+
if (env.fail) throw env.fail;
406407
batch.messages[0].retry();
407408
batch.messages[1].retry({ delaySeconds: 9 });
408409
batch.ackAll();
@@ -435,6 +436,7 @@ describe('generated wrapper queue dispatch', () => {
435436

436437
it('delivers managed HTTP batches and returns first-wins acknowledgment decisions', async () => {
437438
const worker = await createRelayWorker();
439+
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {});
438440
const env = { __VOID_PROXY_TOKEN: 'token', calls: [] as Array<number> };
439441
const response = await worker.fetch(relayRequest('token'), env, {});
440442
expect(response.status).toBe(200);
@@ -446,8 +448,13 @@ describe('generated wrapper queue dispatch', () => {
446448
},
447449
});
448450
expect(env.calls).toEqual([Date.parse('2026-01-01T00:00:00Z')]);
449-
const failed = await worker.fetch(relayRequest('token'), { ...env, fail: true }, {});
451+
expect(errorLog).not.toHaveBeenCalled();
452+
const failure = new Error('after first message');
453+
const failed = await worker.fetch(relayRequest('token'), { ...env, fail: failure }, {});
450454
expect(await failed.json()).toEqual({ ok: false, decisions: { one: { action: 'ack' } } });
455+
expect(errorLog.mock.calls).toHaveLength(1);
456+
expect(errorLog.mock.calls[0][0]).toBe('[void] Queue handler failed for queue "emails":');
457+
expect(errorLog.mock.calls[0][1]).toBe(failure);
451458
expect((await worker.fetch(new Request('https://worker/__void/ready'), env, {})).status).toBe(
452459
200,
453460
);

0 commit comments

Comments
 (0)