Skip to main content
Node execution happens on queues. Queues retry. A handler will therefore be run again for work it has already done — after a timeout, a worker restart, or a transient provider failure — and it must be written so that the second run is harmless.
Every handler must be safe to retry. This is not a quality goal; it is part of the contract.

State changes are usually fine

Writing flow.answer twice with the same value costs nothing. Pure state transitions are naturally idempotent, and most handlers need no further thought.

External side effects are not

The moment a handler sends a message, calls an API, charges something, or creates a record in another system, a retry becomes visible to someone. Those effects need an idempotency marker, or an equivalent guard, so that the second attempt recognises the first. The usual shapes: A deterministic key. Derive an identifier from facts that do not change between attempts — the session, the node, and the thing being acted on — and let the receiving system reject the duplicate. The webhook gateway does exactly this with keys such as tg:{channel}:{body.update_id}. A recorded marker. Write “this was done” as part of the same transaction as the effect, and check it on entry. This works when the effect is local. A provider-supplied idempotency key. Most payment and messaging APIs accept one. Use it rather than inventing a guard around a call that already supports it.

What not to rely on

Do not rely on “this rarely happens”. Retries are routine, not exceptional. Do not rely on checking whether the effect appears to have happened by reading back from the external system — between the read and the write, the first attempt may still be in flight. Do not use a timestamp or a random value in the key. A retry generates a new one, which is precisely the case the key exists to catch.

Waiting is not failing

A handler that needs to pause returns NodeExecutionResult::waiting() or ::delayed(). That is a normal outcome, not an error. Reach for it instead of throwing when the flow simply is not ready to continue. waiting() and plain delayed() behave the same: the contact’s next message runs the handler again, so mark the work it already did (see above) so that run does not repeat it. delayed(resumeAt: $when) runs the handler again too — at $when, with NodeExecutionContext::$resumedAfterDelay set — but retries are still in play: the queued wake-up is safe to fire more than once for the same resumeAt (a duplicated or re-scheduled job), and a message arriving right around $when can also land the node back here through the ordinary routing path. Guard any external side effect the same way you would for a first-run handler — the resumedAfterDelay run is not exempt from this page’s rules.