Side-effect safety
Mark the steps that change something, checkpoint after them, and send an idempotency key, so an order is never placed twice.
A side effect is a step the world remembers: an order placed, a message sent, a form submitted. Kaabist cannot undo those, so it guards each one with a fence, refuses to repeat it, and parks the job for a person whenever it cannot tell whether the step happened.
#When to use it
Mark a step side_effect: true whenever running it twice would do harm: a submit that creates
something, a button that sends, pays, books or deletes. Pair it with an Idempotency-Key on the
request that creates the job whenever your own code may retry that request.
A program that only reads pages (goto and extract) needs none of this. Do not mark a step as a
side effect "to be safe": a fenced step cannot be retried, cannot be optional, and turns an
interruption into a parked job that a person has to resolve.
#What side_effect: true changes
#The fence
Before a side-effect step acts, Kaabist records that the step is about to execute. After the
step's expect holds, it records that the step executed. The next checkpoint closes the fence.
If the run is interrupted between those two records (the worker restarts, the browser crashes, the
connection drops while the site is still answering), nobody can know whether the action went
through. Kaabist never guesses: instead of running the step again, it parks the job in
needs_attention with the reason side_effect_ambiguous, and a person decides.
While a fence is open, the job cannot be canceled: POST /v1/jobs/{id}/cancel answers
409 fence_open.
#No retries, no optional
A side-effect step may not carry retry or optional: true. Both would let the engine run the
action again, or carry on as if it had not happened, so lint refuses them with
E011 and the program cannot be published. If the submit is flaky,
make the step's expect precise and let the job park instead.
#A checkpoint after every side effect
End every side effect with a checkpoint before the next one. Without it, lint warns with
W004: the job has no safe place to restart from after the action, and a
later problem could only be resolved by rewinding past it, which Kaabist refuses.
#Resuming a parked job
A parked job restarts from a checkpoint (see human-in-the-loop), and the fences decide where it may restart:
retry_frommay name only a checkpoint after the last side effect that executed. A checkpoint at or before it would place the order again, so the request is refused with409 fence_open.- An open fence (the step was dispatched but never confirmed) blocks every restart at or before
that step: a resume that would start there parks the job
side_effect_ambiguousagain instead of touching the site. skip_stepon aside_effect_ambiguouspark restarts after the fenced step: you are telling Kaabist that the action went through.abortcancels the job.
A run that replays from an earlier checkpoint, for example because the checkpoint's page no longer opens, never replays across an executed side effect. The data extracted before the park stays in the result.
To resolve side_effect_ambiguous, check the site first. If the action went through, skip_step
tells Kaabist so and the job carries on after the fenced step. If it did not, abort the job and
create a new one, with a new idempotency key: within 24 hours the old key returns the aborted job.
#Dry runs
dry_run: true on job creation runs the program until the first side-effect step and stops there.
The job ends succeeded with meta.dry_run = true and nothing changed on the site. The program builder's
Dry run does the same.
#Idempotency keys
A fence protects the steps inside a job. An idempotency key protects the request that creates the job: a client that times out and tries again must not start a second job that places a second order.
Send Idempotency-Key: <string of at most 255 characters> on POST /v1/jobs or
POST /v1/jobs/batch. For 24 hours, per organisation:
- the same key with the same body returns the original response, marked
Idempotent-Replayed: true, and queues nothing; - the same key with a different body is refused with
409 idempotency_conflict.
Use one key per thing you mean to do (one order, one message) and store it with that thing on your
side, so a retry after a crash or a deploy sends the same key. The SDKs send a fresh key on every
jobs.create and jobs.batch when you give none, which makes their own automatic retries safe;
pass your own key to make your retries safe too.
#Worked example
Take an order form at https://checkout.example.com that places orders through a POST /orders
that is not idempotent: every submission is a new order. The program fills the order form, fences the submit, checkpoints
right after it, and reads the confirmation page:
const definition = {
kaab: "1",
name,
inputs: { sku: "string", qty: { type: "number", default: 1, min: 1, max: 40 } },
options: { max_tier: "browser" },
steps: [
{ goto: `${site}/orders/new`, expect: { visible: "#order-form" } },
{ fill: "input[name=sku]", value: "{{inputs.sku}}" },
{ fill: "input[name=qty]", value: "{{inputs.qty}}" },
{
// Never retried or replayed blindly: if the run is cut here, the job parks instead.
click: "#order-form button[type=submit]",
side_effect: true,
expect: { url: "**/orders/*" },
},
// Anything after this resumes from here, not from the start: the order is not placed again.
{ checkpoint: "order-placed" },
{
extract: { order: "h1", sku: "#order-sku", qty: { selector: "#order-qty", type: "integer" } },
},
],
};
await kaab.programs.create({ name, definition, publish: true });
The click on the submit button carries side_effect: true and an expect on the order URL, so the
fence closes only once the confirmation page has loaded. The order-placed checkpoint is where any
later restart begins: a problem while reading the confirmation never places the order again.
Creating the job with an idempotency key makes the request itself safe to repeat:
// One key per order you mean to place; store it with the order on your side.
const idempotencyKey = `order-${name}`;
const request = { program: name, inputs: { sku: "HP-1037", qty: 2 } };
const first = await kaab.jobs.create({ ...request, idempotencyKey });
// A retry (after a timeout, a crash, a deploy) sends the same key and the same body…
const again = await kaab.jobs.create({ ...request, idempotencyKey });
// …and gets the same job back: nothing new is queued, no second order.
console.log(`${first.id} === ${again.id}: ${first.id === again.id}`);
const done = await kaab.jobs.wait(first.id, { timeoutMs: 120_000 });
console.log(`${done.id}: ${done.status} ${JSON.stringify(done.data)}`);
The second jobs.create sends the same key and the same body, gets the first job back, and the
site receives one order, not two. A dry_run of the same program fills the form and stops before
the submit, and the site receives no order at all.
#Pitfalls
- An
expectthat is too loose. The fence closes when the step'sexpectholds. Anexpectthat holds before the site has answered (a spinner, the same URL) closes it too early; one that never holds parks the job even though the order went through. Wait for the page or element that only exists after success. - Several side effects without checkpoints between them. Lint warns (W004), and an interruption after the second one leaves no restart point between the two.
- A new idempotency key per retry. A key generated inside the retry loop protects nothing. Make it once, from your own record of the order.
- Reusing a key for different work. Within 24 hours the same key with a different body is
409 idempotency_conflict, not a new job. - Expecting cancel to work at any moment. Inside an open fence it is refused with
409 fence_open; cancel again once the step has finished.
#Reference
- Steps: common keys and the
checkpointaction - Lint E011 and W004
- Create a job, resume a job, cancel a job
- Attention reasons and error codes