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.
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 }),
}),
);
});
import json
from workers import fetch
from shiftlabs import workflow
@workflow
async def refund_check(ctx):
async def fetch_charge():
r = await fetch(f"https://api.stripe.com/v1/charges/{ctx.payload['id']}",
headers={"authorization": f"Bearer {ctx.secrets.STRIPE_KEY}"})
return await r.json()
charge = await ctx.step.run("fetch charge", fetch_charge)
ctx.log.info("charge loaded", {"id": charge["id"]})
await ctx.step.sleep("cool-off", "10 minutes")
async def notify():
await fetch("https://hooks.slack.com/services/T0/B0/x", method="POST", body=json.dumps({"text": charge["id"]}))
await ctx.step.run("notify", notify, retries={"limit": 3, "delay": "30 seconds", "backoff": "exponential"})
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.
# /// 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.
payloadThe webhook body: parsed JSON when the request is sent as
application/json, the raw text otherwise.stepDurable steps and sleeps. See Durable steps.
varsVars as
vars.NAME: the workspace value, overridden per workflow. A name that is not set reads asundefined. See Vars.secretssecrets.NAMEfor each secret with hosts. It's a stand-in string that the gateway replaces with the real value on requests to those hosts. An unknown name throws. See Secrets and connections.reveal(secret)The value of a secret marked
readable. Every load is audited. Wherever the value shows up in logs, in the run's output or in an error, it is replaced by[redacted].sign(secret, data, "hex" | "base64")HMAC-SHA256 of
datawith a secret markedhmac. Returns only the signature (hex by default). The secret never enters your code.loglog.info(message, fields?), anddebug,warn,error: one log line each, shown with the run.runIdThis run's id.
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.
step.run(name, fn)
step.run(name, config, fn)Runs
fnonce and stores its result. When the run resumes, the step returns the stored result instead of running again.step.sleep(name, duration)Durable sleep. The run resumes after
duration, on the same version.step.sleepUntil(name, until)Durable sleep until a
Dateor an epoch-milliseconds number.
7d10ce5b · refund-check
Completed- Version
1d9a281e· rev A- Dispatches
- 2
- Duration
- 10 min 2 s
- queued21:30:14.227
- started989 ms21:30:15.216
- fetch charge212 ms21:30:15.301 · result stored
- cool-off10 min21:30:15.514
- notify95 ms21:40:15.902 · same version
- completed21:40:16.010
retries.limitHow many times to retry a failing step.
retries.delayA duration between attempts.
retries.backoff"constant","linear"or"exponential".timeoutA duration after which an attempt fails.
Rules that keep runs correct
- Code outside
step.runruns 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.