Can you create infinite workflows with hooks and for await?
you can create infinite workflows, using hooks + for await ..., right?
is that pattern recommended? it's pretty cool, the only thing i worry about is related to error handling and retries: if a step fails, it can't be skipped, right?
Now I have a comprehensive understanding to answer your question.
##Answer
Yes! You can absolutely create "infinite" workflows using hooks + for await...of. It's a documented and supported pattern:
export async function infiniteWorkflow() {
"use workflow";
const hook = createHook<{ message: string }>();
for await (const payload of hook) {
// This loop runs indefinitely, receiving events one by one
await processMessage(payload.message);
if (payload.message === "stop") {
break; // Only way to exit
}
}
}
The hook implements AsyncIterable<T>, and the async iterator is literally:
// packages/core/src/workflow/hook.ts:177-181
async *[Symbol.asyncIterator]() {
while (true) {
yield await this;
}
}
###Regarding error handling and retries:
You're right to be concerned! Here's what happens:
-
Steps inside the loop have retries - By default, steps retry 3 times. You can customize with
stepFn.maxRetries = N. -
If a step fails after all retries are exhausted, it throws a
FatalErrorwhich bubbles up and fails the entire workflow (seepackages/core/src/step.ts:112-137). -
You CANNOT skip a failed step - When
step_failedis recorded, the step consumer rejects with aFatalError, and the workflow fails.
###Strategies for handling this:
1. Use try/catch within the loop:
for await (const payload of hook) {
try {
await processMessage(payload.message);
} catch (e) {
// Log and continue to next message
console.error("Failed to process:", e);
}
}
2. Use FatalError for known unrecoverable errors:
This still fails the workflow, but skips retries.
3. Implement your own retry/skip logic in the step:
async function processMessage(msg: string) {
"use step";
try {
return await doWork(msg);
} catch (e) {
// Return a failure marker instead of throwing
return { success: false, error: String(e) };
}
}
4. Use the rollback pattern (from the docs) if you need compensating actions when failures occur.
###Bottom line:
The pattern is cool and supported, but you need to handle errors at the application level if you want the loop to continue after a failure. The workflow engine itself doesn't have a "skip failed step" mechanism - a failed step (after retries) will always fail the workflow.
what about this?
export async function infiniteWorkflow() {
"use workflow";
const hook = createHook<{ message: string }>();
for await (const payload of hook) {
// This loop runs indefinitely, receiving events one by one
await processMessage(payload.message).catch(e => {
if (e instanceof FatalError) {
return
} else {
throw e
}
});
if (payload.message === "stop") {
break; // Only way to exit
}
}
}
Great question! This pattern won't work as you might expect.
The issue is that when a step fails (after retries are exhausted), the FatalError is thrown during replay when the workflow re-executes and encounters the step_failed event in the event log.
Let me show you the relevant code:
// packages/core/src/step.ts:112-137
if (event.eventType === 'step_failed') {
// Terminal state - we can remove the invocationQueue item
ctx.invocationsQueue.delete(event.correlationId);
// Step failed - bubble up to workflow
setTimeout(() => {
// ...
const error = new FatalError(errorMessage);
if (errorStack) {
error.stack = errorStack;
}
reject(error); // <-- This rejects the step's promise
}, 0);
return EventConsumerResult.Finished;
}
So your .catch() will catch the FatalError - that part works! The workflow will continue to the next iteration.
BUT here's the problem: the step_failed event is permanently recorded in the event log. Every time the workflow replays (which happens on every resume), it will:
- Hit the same
step_failedevent - Throw the same
FatalError - Your
.catch()catches it and returns - Continue to the next iteration
So actually... this might work! Let me reconsider.
The workflow will:
- Process
hook_receivedevent #1 - See
step_failedfor that step → catch it, continue - Process
hook_receivedevent #2 - Run the step (or replay its result)
- etc.
The key insight is that each iteration of the loop creates a new step with a new correlationId. The failed step is a distinct entity from subsequent steps.
One caveat: Make sure your catch logic is correct. You're currently returning undefined when catching FatalError, which means processMessage() resolves to undefined instead of failing. If your workflow logic depends on the return value, you'll need to handle that.
for await (const payload of hook) {
const result = await processMessage(payload.message).catch(e => {
if (FatalError.is(e)) { // Use the static method for proper instanceof checks
console.log("Step failed permanently, skipping:", e.message);
return null; // Explicit "failed" marker
}
throw e;
});
if (result === null) {
continue; // Skip to next message
}
// Use result...
}
So yes, this pattern should work for building resilient infinite workflows! 👍
END OF POST