@speqkit/plugin-loop
v0.9.0
Published
Control flow as a plugin: loop, repeat, retry. Nothing of this lives in the kernel.
Readme
@speqkit/plugin-loop
loop and retry. Control flow, contributed rather than built in.
# speq.yaml
plugins:
- loopsteps:
- id: seed
type: loop
over: ["ada", "grace", "katherine"]
as: user
steps:
- type: http
method: POST
url: /users
body: { name: "${user}" }
- id: settle
type: retry
attempts: 5
delayMs: 300
steps:
- id: status
type: http
url: /jobs/${seed.iterations}loop
over (a list) or times (a count) — one or the other. Each iteration runs
the nested steps in a child variable scope holding ${item} and
${itemIndex}, renamed by as. A failing iteration stops the loop.
Returns { iterations, completed, results }, where results is the step
records of every iteration.
retry
attempts (default 3) and delayMs (default 250). Nested steps run until
none of them fails; ${attempt} is visible inside. If every attempt fails the
step throws with the last real message, rather than reporting a bare count.
It ends the way its last attempt ended. An attempt whose assertions said no
makes the step failed; an attempt that could not run at all — connection
refused, a step type nothing defines — makes it error. The distinction is the
line between fixing the code and fixing the stand, and it used to be lost here:
a page that answered 200 twenty times and never carried the value being
waited for was reported as the environment being broken, which sends the reader
to look at the network. Saying failed at all is StepFailure, added to the
contract in @speqkit/plugin-api 0.15.0 for exactly this; loop and use
draw the same line.
The delay honours exec.signal, so a test that times out mid-backoff aborts
instead of sleeping to the end.
wait
ms, and nothing else. It does nothing for that long, answering the abort
signal rather than holding the process — a run being torn down should not have
to sit out somebody's ms: 30000.
retry is the right answer nearly every time: it asks again until the answer
changes and stops the moment it does, where a wait costs its full length on
every run. This is for the case where there is nothing to ask — a webhook a
queue will deliver, a token that starts working a second from now, a rate
limiter that has to be let go of. Before it, the only way to write a pause was
a plugin of your own, and every project that needed one wrote it.
A wait longer than the step's timeout would abort mid-sleep and report
step-timeout, which reads like the system under test being slow. So it is a
diagnostic before the run instead, naming the timeout: to write beside it.
Why this package exists
It was the first half of the architecture gate: the test of whether a plugin author is boxed in.
A loop is not a protocol client. It wraps other steps, which is the one thing
a naive "step type" contract cannot express — and if the kernel had needed a
change to allow it, then if, retry and try/catch would all have been
kernel features too, and "everything is a plugin" would have been a slogan.
parallel is not in that list. Every construct here runs its children one at a
time, and runSteps refuses a second call beside one still running: a test is
the unit speq runs atomically. Concurrency is between suites.
It works because the executor is re-entrant and handed to plugins as
ctx.runSteps(steps, { vars, label }). Both step types here are that call plus
a policy. The kernel was not modified once.
MIT.
