shiftlabs Start free

Workflows

Each workflow is one TypeScript or Python module.

The entry

workflow<Payload, Result>(fn) takes the type of the webhook payload and of the value fn returns. fn receives the run context.

refund-check
import { workflow } from "shiftlabs";

export default workflow<{ id: string }>(async ({ payload, step, secrets, log }) => {
  const charge = await step.run("fetch charge", () =>
    fetch(`https://api.stripe.com/v1/charges/${payload.id}`, {
      headers: { authorization: `Bearer ${secrets.STRIPE_KEY}` },
    }).then((r) => r.json()),
  );
  log.info("charge loaded", { id: charge.id });
  await step.sleep("cool-off", "10 minutes");
  await step.run(
    "notify",
    { retries: { limit: 3, delay: "30 seconds", backoff: "exponential" } },
    () =>
      fetch("https://hooks.slack.com/services/T0/B0/x", {
        method: "POST",
        body: JSON.stringify({ text: charge.id }),
      }),
  );
});

In TypeScript, the module's default export must be workflow(…). In Python, exactly one function must be decorated with @workflow. Otherwise the deploy fails with bundle failed and a hint. The deploy response also lists the secret names the code reads and warns about any that are not set.

Every deploy includes the shiftlabs module. You don't need to bundle it yourself. Requests made with the global fetch go through the egress gateway, which replaces stand-ins and applies connections, limits and attribution.

Python packages

List packages at the top of the file. They're built once, when you deploy.

pythonrefund_check.py
# /// script
# dependencies = ["httpx", "numpy>=2"]
# ///
import httpx
import numpy as np
from shiftlabs import workflow


@workflow
async def refund_check(ctx):
    async def fetch_charge():
        async with httpx.AsyncClient() as http:
            r = await http.get(f"https://api.stripe.com/v1/charges/{ctx.payload['id']}",
                               headers={"authorization": f"Bearer {ctx.secrets.STRIPE_KEY}"})
            return r.json()

    charge = await ctx.step.run("fetch charge", fetch_charge)
    return {"cents": int(np.round(charge["amount"]))}

If the same set of packages has been built before, the deploy goes live right away and the response lists the exact versions under packages. A new set returns 202 with a deployId. The version goes live when the build finishes, usually within a minute. GET /v1/deploys/:id shows building, live or failed with the reason.

Pure-Python packages work as published. Compiled ones, such as numpy, need a build for our Python runtime. Without one, the deploy fails with the reason. Each version keeps the packages it was deployed with. To get the newest versions, deploy with ?upgrade=1. Use async HTTP clients such as httpx's AsyncClient.

Run context

Your function receives one object. Destructure what you need.

Durable steps

A duration is a number of milliseconds or a string such as "30 seconds" or "10 minutes", with units from second to year.

7d10ce5b · refund-check

Completed
Version
1d9a281e · rev A
Dispatches
2
Duration
10 min 2 s
  1. queued21:30:14.227
  2. started989 ms21:30:15.216
  3. fetch charge212 ms21:30:15.301 · result stored
  4. cool-off10 min21:30:15.514
  5. notify95 ms21:40:15.902 · same version
  6. completed21:40:16.010

Rules that keep runs correct

  • Code outside step.run runs again each time the run resumes, after a sleep or a retry. Only step results are stored. Put side effects inside steps.
  • A run stays on the version that was current when its webhook arrived, even after a redeploy.
  • A run may be dispatched by the engine at most 50 times and live at most 168 hours. Each invocation gets 2,000 ms of CPU and 50 subrequests. The full list is under Limits.