HarborHarbor
DocumentationGuidesPlugins
Recipes

Multi-step with retries

step.do + step.sleep for durable, retryable, time-paced pipelines. (Beta)

Beta

Uses the Workflows lane atoms (step.do, step.sleep). Part of the Functions/Workflows beta.

When you have a pipeline that:

  • exceeds 30 s wall time, or
  • needs to retry individual steps with backoff, or
  • needs cooldown between batches,

…write step.* atoms. Harbor auto-routes the run to the durable lane (Cloudflare Workflows under the hood). No flag, no separate command.

Example: re-deploy with rolling restarts

rolling-deploy.ts

defineJob({
  name: "rolling-deploy",
  handler: async (ctx, input: { environments: string[] }) => {
    const results: Array<{ env: string; ok: boolean }> = [];

    for (const env of input.environments) {
      // step.do checkpoints per attempt; transient failure replays only this step
      const deploy = await step.do(\`deploy:\${env}\`, async () => {
        return ctx.plugins.cloudflareMcp.deploy({ env });
      });

      // step.sleep parks the run cheaply between batches
      await step.sleep("cool-down", "10m");

      const health = await step.do(\`health:\${env}\`, async () => {
        return ctx.plugins.cloudflareMcp.health({ env });
      });

      results.push({ env, ok: health.status === "ok" });

      // Bail out at first bad environment
      if (!health.ok) break;
    }

    return { results };
  },
});

What each atom buys you

AtomWhat it doesWhen to use
step.do(name, fn)Run fn with per-step checkpoint; on failure, only fn retriesAnything that hits a rate-limited or flaky API
step.sleep(name, duration)Park the run for duration without burning CPUCooldowns between batches, scheduled polling
step.waitForEvent(name, opts)Park the run until POST /runs/events arrivesOAuth approval, manual gate, async upstream event

duration accepts '1m', '10m', '1h', '2d', etc. Maximum park time is plan-bound — see Limits.

Idempotency rules

Inside step.do:

  • Use only deterministic inputs. The engine may replay fn after a partial failure; randomness or Date.now() will produce different results.
  • Side effects must be idempotent. Idempotency keys, conditional inserts, "create if absent" patterns. Otherwise expect duplicate writes.

Outside step.do (top-level handler code) is not retried per step — that's just the orchestrator. So:

const id = crypto.randomUUID();                                   // ✓ runs once
await step.do("create", () => orbit.db.tickets.insert({ id, ... })); // ✓ deterministic input

Where to go next