Guides
Five guides: idempotent submission, multi-stage pipelines, the runtime contract, versioning, and billing.
# Retries & idempotency
A lost response must never become a second run: retries repeat only failures that say
nothing about the request, and an Idempotency-Key binds
each submission to one workload.
Client and AsyncClient
retry up to max_retries times (default 2, so three
attempts) with exponential backoff. “No” means a second attempt would return
the same answer.
Retry matrix — which outcomes retry and why (9 error classes)
| Outcome | Retried | Why |
|---|---|---|
APIConnectionError |
Yes | May never have arrived. |
APITimeoutError · 408 |
Yes | No verdict; the key resolves it. |
RateLimitError · 429 |
Yes | Timing only; backoff honours .retry_after. |
APIError · 5xx |
Yes | Server fault, not your request. |
ValidationError · 400, 422 |
No | The payload is the problem. |
AuthenticationError · 401, 403 |
No | A rejected key stays rejected. |
NotFoundError · 404 |
No | The id does not resolve. |
BudgetExceededError · 402 |
No | A decision, not a fault. |
IdempotencyConflictError · 409 |
No | See below. |
CapacityUnavailableError (503); the required Idempotency-Key and per-call minting
CapacityUnavailableError (503)
is retried on the same key — an attempt that lands returns the original workload.
Exhaustion means no route satisfies the brief right now: widen
finish_by, raise budget,
or relax data_regions.
POST /v1/workloads rejects a submission without an
Idempotency-Key before any planning. Omit
idempotency_key and the SDK mints a random key per
run() call, covering only that call's retries — a
process that dies after sending and calls run() again
creates a second workload. Deduplicating across process boundaries needs a key stable
in your domain.
The key is scoped to tenant and payload — two tenants can use the same string without colliding. Within one tenant:
- Same key, same payload → the original workload, with
Idempotent-Replayed: true. Nothing new is scheduled. - Same key, different payload →
409,IdempotencyConflictError. - New key → a new workload, even for a byte-identical brief.
Replay state and 409 recovery — deriving stable keys
A replay returns the workload as it is now — an hour-old key can come back
RUNNING with accumulated
spend_usd — so branch on
wl.status or wl.is_terminal,
not a fresh ACCEPTED.
Recovering from a 409: resend the original payload with
that key to replay, or pick a new key for a genuinely new run — never mutate the
brief under the same key. Derive keys from something your system already treats as
unique (a job row id, a commit sha) and reuse them verbatim when resubmitting after a
timeout or crash: a lost response costs one extra request, not one extra workload.
Example — resubmit safely with a stable idempotency key (Python)
>>> import nodus >>> >>> def submit(client, key): ... return client.run( ... model="7B fine-tune", ... command=["python", "train.py", "--epochs", "3"], ... peak_memory_gb=40, ... expected_runtime_hours=9, ... budget=600, ... continuity="checkpointed", ... idempotency_key=key, ... ) >>> # stable in your domain — not random, not a timestamp >>> key = "train:job_8412:attempt_1" >>> with nodus.Client() as client: ... try: ... wl = submit(client, key) ... except nodus.APITimeoutError: ... # the submit may already have landed; the key resolves it ... wl = submit(client, key) ... except nodus.IdempotencyConflictError: ... # bound to a different brief — do not retry ... raise ... print(wl.id, wl.status)
Reads (get, list,
events, artifacts,
ledger) carry no key and repeating them changes nothing;
cancel is safe to repeat — a terminal workload
stays in the state it reached.
# Multi-stage workloads
Pass stages to run()
and the list compiles to a DAG: depends_on names
upstream stages, inputs reference their named outputs,
and stages with no unmet dependency run concurrently. Unknown names and cycles are
rejected with ValidationError at submission.
Each stage declares its own requirements and continuity and is routed on its own terms.
budget and finish_by
apply to the whole graph, not any one stage.
Example — submit a three-stage workload (Python)
>>> import nodus >>> >>> with nodus.Client() as client: ... wl = client.run( ... model="7B fine-tune", ... image="ghcr.io/acme/trainer:2026.07", ... budget=900, ... data_regions=["us"], ... stages=[ ... nodus.StageSpec( ... name="prepare", ... command=["python", "-m", "pipeline.prepare", "--shards", "64"], ... peak_memory_gb=16, ... expected_runtime_hours=1.5, ... continuity="restartable", ... outputs=[dict(name="dataset", path="/out/shards")], ... ), ... nodus.StageSpec( ... name="train", ... command=["python", "train.py", "--data", "/in/dataset"], ... peak_memory_gb=40, ... expected_runtime_hours=9, ... continuity="checkpointed", ... interrupt_tolerance="high", ... depends_on=["prepare"], ... inputs=[dict(source="prepare.dataset", path="/in/dataset")], ... outputs=[dict(name="weights", path="/out/ckpt")], ... ), ... nodus.StageSpec( ... name="eval", ... command=["python", "eval.py", "--weights", "/in/weights"], ... peak_memory_gb=24, ... expected_runtime_hours=2, ... continuity="restartable", ... total_units=512, ... depends_on=["train"], ... inputs=[dict(source="train.weights", path="/in/weights")], ... ), ... ], ... ) ... done = wl.wait() ... for stage in done.stages: ... print(stage.id, stage.status, stage.completed_units, "/", stage.total_units) # # stg_prepare completed None / None # stg_train completed None / None # stg_eval completed 512 / 512
All 12 stage fields
| Field | Meaning |
|---|---|
name | Unique within the workload; what depends_on and inputs reference. |
command | Argv list or string; what the stage runs. |
image | Container image; falls back to the brief's. |
compute_class | accelerator (default) or vm; vm is never shown accelerator capacity. |
peak_memory_gb | Memory high-water mark for this stage alone. |
expected_runtime_hours | Expected wall clock; feeds the graph's cost-to-completion estimate. |
continuity | checkpointed, restartable, or ephemeral; per stage, not per workload. |
interrupt_tolerance | low, medium, high: interruption before the route should change. |
total_units | Denominator for completed_units; declare when work is countable. |
depends_on | Stages that must reach COMPLETED first. |
inputs | An upstream stage's named output, plus the path to read it. |
outputs | Named paths sealed into a verified manifest on completion. |
Progress reads from workload.stages, a list of
StageRun; which stage and attempt produced which
manifest, and where the digests live, is documented at
workload.artifacts() in the
reference.
Handoffs are sealed manifests; recovery moves only the affected stage
Handoffs are manifests, not filesystems. When
prepare completes, its named outputs seal into a
manifest with a sha256 per artifact;
train materializes that manifest at
/in/dataset and checks the digests; a mismatch fails
the stage. A downstream stage never
attaches to an upstream stage's live filesystem — the machine that ran
prepare may already be reclaimed.
Recovery is per stage. If the route under
train is reclaimed, the workload moves to
RECOVERING and train
resumes from its last checkpoint on a new route; prepare
stays COMPLETED and never reruns. A
restartable stage is safe to rerun from the top, so
recovery reruns it rather than resuming inside it; declare
total_units and the rerun is observable. Either way,
only the affected stage moves.
# Runtime contract
Nodus runs your argv as a real process on the capacity it routed to, watches its exit status, and treats any non-zero exit as a failed workload. Everything the process needs to know about its lease arrives in environment variables — argv belongs to you.
All NODUS_* environment variables (7 rows)
| Variable | Meaning |
|---|---|
NODUS_WORKLOAD_IDNODUS_STAGE_IDNODUS_GENERATION | Which run, stage, attempt. A program reused across stages branches on the stage id; generation counts recoveries from 1. |
NODUS_INPUT_<name> | Local path per declared input; manifest resolved, digest checked before your process starts — a file, never a URI. |
NODUS_OUTPUT_<name> | Where to write each declared output; collected after a clean exit, sealed into the manifest downstream reads. |
NODUS_OUTPUTS | Every declared output as one JSON object — parse once instead of reading N variables. |
NODUS_PROGRESS | File to append unit progress to; the only way a restartable stage resumes mid-way. |
NODUS_TOTAL_UNITSNODUS_CURSOR_COMPLETED | How much work there is, and how much a previous generation finished. Resume at the cursor, not zero. |
NODUS_RESTOREDNODUS_RESTORED_FROM | "1" and the checkpoint key on a generation started from a checkpoint; absent on a cold start. |
HOME, TMPDIR and
PWD point inside your lease; the rest of the
environment is deny-by-default — only PATH,
LANG, LC_ALL and
TZ pass through. Anything else must come from the
image or the brief.
Example — a workload that implements the runtime contract (sh)
$ set -u # a declared input must be a readable, non-empty file [ -s "$NODUS_INPUT_dataset" ] || { echo "no dataset" >&2; exit 3; } # only your process can prove a restore carried prior local state if [ "${NODUS_RESTORED:-0}" = "1" ] && [ ! -s cursor ]; then echo "restored from $NODUS_RESTORED_FROM with no prior state" >&2 exit 4 fi # resume at the cursor, not at zero u=${NODUS_CURSOR_COMPLETED:-0} total=${NODUS_TOTAL_UNITS:-0} while [ "$u" -lt "$total" ]; do process_shard "$u" u=$((u + 1)) # absolute count first — Nodus reads the number, not the lines; # a rerun would over-report and the next generation would skip work printf '%s units of %s done\n' "$u" "$total" >> "$NODUS_PROGRESS" # write-then-rename: a snapshot never sees a partial file printf '%s\n' "$u" > cursor.tmp && mv cursor.tmp cursor done # outputs last — collected only after a clean exit cp weights.bin "$NODUS_OUTPUT_model" exit 0
Exit status is the contract. Exit 0 triggers the final snapshot that completes the stage and publishes your declared outputs; any other status fails the workload — status recorded, no final manifest. stdout and stderr are captured per generation.
Uncoordinated snapshots (write-then-rename) and pilot isolation
Snapshots are uncoordinated. Nodus archives your working directory on a cadence without pausing your process, so a snapshot can capture files never simultaneously valid. The defence is write-then-rename: write to a temporary name in the same directory, rename into place; stage multi-file state in a directory and rename it. A coordination hook is designed, not yet implemented, and will be opt-in — until then, write-then-rename is the whole mechanism.
Isolation today. During the pilot your process runs as a child of the Nodus runner on a host dedicated to one tenant — a process boundary, not a tenant boundary. The deny-by-default environment keeps Nodus credentials out and a reclaim kills the whole process group, but filesystem and user are shared with the runner. Filesystem isolation and egress control arrive with container execution; ask where your workload will land before sending anything sensitive.
# Versioning
SDK and control plane follow SemVer and version independently; the wire contract is
the boundary. Pin the SDK to a major at minimum
(nodus-sdk>=1.0,<2); the control-plane pin is the
path segment /v1, and a breaking wire change arrives as
a new segment beside the old, never as a change to an existing one.
Within a major, wire changes are additive — new fields appear on responses,
older SDKs ignore them — and a webhook handler that rejects unknown keys breaks
on a change designed to be safe. Enumerations are open sets, new
WorkloadStatus values and event types included: keep a
default branch, and prefer workload.is_terminal and
workload.succeeded over enumerating terminal statuses.
# Billing & budgets
Billing is for execution: the capacity a workload consumed, recovery included —
not seats, reservations, or capacity evaluated but unused. The unit is the hour a route
was held, at that nodus:… route's rate. Pricing
is per account during the pilot — no public price list; rates are agreed before
your first billable run.
The budget is a cap, not an estimate.
budget becomes
budget_usd and bounds the whole graph. Planning checks
it against expected cost to completion, so a brief that cannot finish inside the cap is
rejected up front with BudgetExceededError and a
402 — a decision the SDK does not retry. The cap
holds mid-run: routing reserves recovery headroom before reserving a machine, and a
resumable workload nearing the cap gets to commit a checkpoint, then settles
FAILED with an error
saying so. You pay for what was consumed; anything committed first stays in
workload.artifacts().
The monthly account cap and ledger-derived invoices
The account has its own ceiling. Set a monthly cap under Billing in
the console and a submission whose budget would take the month past it is refused
— 402,
BudgetExceededError — before anything is planned
or reserved. The error carries the cap, month-to-date spend, and the estimate
(key names); accounts start uncapped.
Invoices derive from the ledger.
GET /v1/workloads/{id}/ledger returns the entries
behind spend_usd; invoices generate from the same rows
via POST /v1/billing/invoices and deliver through
Stripe — a charge not in the ledger is not on the invoice. Billing contact:
PUT /v1/billing/profile.
Example — read spend and the ledger for a workload (Python)
>>> with nodus.Client() as client: ... wl = client.get("wl_9f3c1b2a") ... print(f"{wl.spend_usd:.2f} of {wl.budget_usd:.2f} budgeted") ... for entry in wl.ledger(): ... print(entry.type, entry.debit_usd, entry.credit_usd) 164.20 of 180.00 budgeted
Cancelling is safe at any time but does not undo work already done:
cancel lets a run commit its checkpoint before
settling, the hours up to that moment are billed, and everything after is saved.