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)
OutcomeRetriedWhy
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)
idempotency · python ~/your-app
>>> 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)
stages · python ~/your-app
>>> 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
FieldMeaning
nameUnique within the workload; what depends_on and inputs reference.
commandArgv list or string; what the stage runs.
imageContainer image; falls back to the brief's.
compute_classaccelerator (default) or vm; vm is never shown accelerator capacity.
peak_memory_gbMemory high-water mark for this stage alone.
expected_runtime_hoursExpected wall clock; feeds the graph's cost-to-completion estimate.
continuitycheckpointed, restartable, or ephemeral; per stage, not per workload.
interrupt_tolerancelow, medium, high: interruption before the route should change.
total_unitsDenominator for completed_units; declare when work is countable.
depends_onStages that must reach COMPLETED first.
inputsAn upstream stage's named output, plus the path to read it.
outputsNamed 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)
VariableMeaning
NODUS_WORKLOAD_ID
NODUS_STAGE_ID
NODUS_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_OUTPUTSEvery declared output as one JSON object — parse once instead of reading N variables.
NODUS_PROGRESSFile to append unit progress to; the only way a restartable stage resumes mid-way.
NODUS_TOTAL_UNITS
NODUS_CURSOR_COMPLETED
How much work there is, and how much a previous generation finished. Resume at the cursor, not zero.
NODUS_RESTORED
NODUS_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)
runtime contract · sh inside the workload
$ 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)
billing · python ~/your-app
>>> 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.