A little elegant state machine with Async Generators
2 February 2018
Today at work I made this up:
async function* init_process (steps) {
for (let step of steps) {
while (true) {
try {
await step.run()
break
} catch (error) {
handle_error({ step, error })
yield
}
}
}
}
What this does is it takes a list of steps
, which are async tasks (in our
case a request and some processing), runs through them, and if there is an
error at some point it hands back to the caller… and then the caller can
choose to retry the failed step and go on.
All in 10 lines of code.
Beyond brevity, what I like about this code is that as long as you know the
behaviour of an async generator, of break
inside a loop, of a try
-catch
—
which are all, to the possible exception of the async generator, fairly
elemental language structures — you can understand what this little machine
does simply by running through it line by line, iteration by iteration.
Here’s how you’d use this:
// load the steps, do some prep work...
// Prepare the little machine
const process = init_process(steps)
// Hook up the retry button
$('.retry-button').click(() => process.next())
// Start it up
process.next()
And that’s it!
Let’s run through this a bit:
-
async function* init_process (steps) {
This is an Async Generator that takes a list of
steps
. Generators, and Async Generators, gets their arguments and then start frozen. They don’t do any processing until you first call.next()
.An Async Generator is just a Generator! All it does special is that you can use
await
inside it and if you want the results of what ityield
s, you have to await those. (But we don’t use that here so you don’t even need to keep that in mind.) There’s no extra magic. -
for (let step of steps) {
We’re going to iterate through all the steps, one at a time.
-
while (true) {
This is the first “aha!” moment. To make it possible to retry the current, failed, step, we start an infinite loop. If we have a success, we can break out of it, dropping back into… the
for
loop, and thus continuing onto the next step. If we have a failure, we don’t break out, and thewhile
loop will naturally start that step over. -
try { await step.run(); break
We
try
thestep.run()
, and then webreak
. Because of the way exceptions work,break
will only run if nothing was thrown. That is, ifstep.run()
ended successfully. -
catch (error) { handle_error({ step, error })
We want to immediately handle the error. We could
yield
the error and let the caller handle it, but this way there’s no need for an extra wrapping function: we can just callprocess.next()
to start and resume the machine, without needing to care about its output. -
yield
The piece of magic that brings it all together. If and when we get to that, we freeze the generator state and hand back execution to the caller. It’s now up to it to tell the little machine to continue, and it can do that at any time. There’s no need for complex state management, of preserving and restoring progress: the language itself is taking care of it.
-
Outside:
process.next()
(the first time)Recall that the Generator starts frozen (see 1). The first thing we do is call
next()
, and that unfreezes the machine. It starts processing steps, and eventually will either get to the end, or stop at an error. -
To retry:
process.next()
When we hit a snag,
handle_error()
does its job of telling the user and figuring out problems… and then it can choose to display a retry button. Or maybe it will want to automatically retry a step if it deems it safe to do so. Or maybe the error was very bad, and it just wants to abort. It can do all these things, and it can take its time: the little machine will wait patiently until it’s told to get going again.
And that’s all there is to it!