Guides
Activities & retries
An activity is a function that runs outside the sandbox and does the work a workflow must not do itself: the HTTP request, the database write, the payment charge. This guide covers how to implement one, and how activities fail, retry, and recover.
The activity boundary
Inside a workflow, activity(name, input) schedules work for the runtime
to execute outside the deterministic sandbox. The result is recorded in
history; on replay
the recorded result is fed back rather than re-executing the side effect.
const charged = await activity("charge_card", order.total);
The activity function itself lives in a worker: a separate process that
registers for the activity’s task type and runs the function on each
dispatch. In TypeScript that worker is HopskipWorker from
sdk/ts/hopskip-worker; in Rust it is a binary built on the
core/hopskip-worker helpers (sdk/rust/dogfood-worker is a complete
example); in Haskell it is runWorker from sdk/hs/hopskip-worker.
import { HopskipWorker } from "sdk/ts/hopskip-worker";
const worker = new HopskipWorker("127.0.0.1:50051", {
workerId: "billing-worker",
token: process.env.HOPSKIP_TOKEN,
});
worker.registerActivity("hopskip:activity.charge_card@1", async (payload) => {
const amount = decodeI64(payload);
return encodeI64(await billing.charge(amount));
});
await worker.start();
The handler receives the dispatch’s payload bytes and returns the result
bytes; encodings are yours to choose. hop dev also serves activities:
anything deployed into its registry under its own task type runs
alongside the workflows that call it.
Retries are policy
You do not write retry loops. Retry classification, backoff, jitter, and dead-letter routing are declarative policy evaluated by Core, applied uniformly and changeable without redeploying workflows:
activities:
charge_card:
retry:
max_attempts: 5
backoff: exponential
initial: 200ms
max: 30s
jitter: full
deadline: 10s
retryable: [Timeout, Unavailable, Throttled]
non_retryable: [InvalidCard, Declined]
A Declined result stops immediately; a Throttled result backs off and
retries. Because this is configuration, you tune it in production without
a build.
Deadlines and heartbeats
Long-running activities heartbeat so Core can distinguish “still working”
from “stuck.” You never write heartbeat(): heartbeats are injected as
host calls at loop boundaries (or via a background fiber for a single long
I/O call). Source maps keep stack traces pointing at your native code.
activities:
transcode_video:
deadline: 30m
heartbeat_timeout: 60s # no heartbeat in 60s → reschedule
The failure envelope
When an activity fails in a way the workflow should see, the error reaches the guest as a structured, guest-visible failure envelope, not an opaque string. Your workflow can branch on it with ordinary control flow:
from hopskip import activity, ActivityError
async def checkout(order):
try:
await activity("charge_card", order.total)
except ActivityError as err:
if err.class_ == "Declined":
await activity("notify_declined", order.customer)
return "payment_failed"
raise # let retryable failures follow Core's retry policy
The envelope carries the error class, message, and provenance, so a workflow can make a durable decision based on why an activity failed.
Which version you call
activity("charge_card", …) above names an activity but not a version,
and deployed activities are versioned:
hopskip:activity.charge_card@1, hopskip:activity.charge_card@2. Under
a rolling deploy both are live at once, which is the ordinary steady
state, not an edge case.
Nothing resolves that for you. Resolving a bare name to whichever version happens to be deployed would make every deploy silently redirect existing callers, with no diff to review and no way to tell from a call site which version it reaches. So a call site names its own version, and the way to write one is to import a generated binding:
hop typegen ts --out src/hopskip-activities.d.ts
hop typegen go --out internal/hopskipdeployed/deployed.go
hop typegen py --out workflows/hopskip_deployed.py
hop typegen rust --out src/hopskip_deployed.rs
hop typegen hs --out src/Hopskip/Deployed.hs
import { chargeCardV2 } from "hopskip:activities";
const charged = await activity(chargeCardV2, order.total);
The binding is the call site: the one place that knows which version you
meant. Deploying @3 adds a binding beside the others and redirects
nothing you already wrote; moving to it is an edit, with a commit and a
review attached. The version suffix appears only when two versions are
deployed together, so an ordinary project reads as ordinary names
(chargeCard, not chargeCardV1).
If you compute an activity name at runtime rather than importing it, declare what it can resolve to. A component that dispatches an undeclared bare name is refused at the call site, naming what would have worked. That refusal is what prevents the worst failure this system could have: a dispatch accepted, durably recorded under a task type nothing serves, and waited on forever.
See the CLI reference
for --check, --namespace and --plan.
Idempotency
Idempotency is a first-class declarative primitive, not universal transparent memoization. Attach a key and Core dedupes, so a retried or double-submitted activity commits exactly once:
await activity("charge_card", order.total, { idempotencyKey: order.id });
Hopskip deliberately rejects intercepting arbitrary network traffic to auto-memoize side effects, because that cannot distinguish “committed” from “rolled back.” Declaring the key makes the intent explicit and correct.
Polyglot activities
A workflow and its activities do not have to share a language. The dispatch carries opaque payload bytes between them, and each side chooses its own encoding, so a TypeScript workflow can call an activity implemented in Rust or Haskell (or one per language, where one fits the task better). Every activity worker, whatever language it is written in, speaks the same dispatch stream.
Dead-letter and triage
An activity that exhausts its retry policy routes to a dead-letter queue per Core policy, where an operator can inspect, edit, and replay it, or fork the workflow to reproduce the failure. See Memory snapshots.