SDK reference
Every object the Python SDK exposes. New here? Start with the quick start.
# Client
The synchronous entry point. It holds credentials, a base URL, and a pooled HTTP transport, and every other object in the SDK is reached through it.
>>> nodus.Client(api_key=None, *, base_url=None, timeout=30.0, max_retries=2)
| Parameter | Default | Meaning |
|---|---|---|
api_key |
$NODUS_API_KEY |
Tenant credential. Sent as Authorization: Bearer <api_key>. Missing or unreadable at construction time raises ConfigurationError. |
base_url |
$NODUS_BASE_URL, then https://api.nodus.run |
Control plane to talk to. Nothing in the SDK is bound to a host, so the same code runs against a control plane you operate. |
timeout |
30.0 |
Per-request deadline in seconds. Applies to each attempt, not to the sum of retries. Exceeding it raises APITimeoutError. |
max_retries |
2 |
Retries for transient failures only — network errors, 408, 429, and 5xx. Other 4xx responses are returned to you on the first attempt because retrying a rejected payload cannot change the answer. |
One client per process is enough. The transport pools connections, so a shared instance amortizes TLS handshakes across every call and a client per request adds one to each submission. The client is thread-safe. The workload handles it returns are not.
| Method | Returns | Notes |
|---|---|---|
client.run(**brief) |
Workload |
Submit a brief — requirements and outcomes, never a machine. Returns as soon as the workload is accepted; it is not yet placed. |
client.get(workload_id) |
Workload |
Fetch a fresh handle for an existing workload. |
client.list(limit=50, offset=0, status=None) |
list[Workload] |
One page, newest first. status takes a WorkloadStatus, its wire string, a list of either, or one of the presets "active" and "terminal". |
client.iter_workloads(page_size=50) |
Iterator[Workload] |
Pages lazily so you never hold the whole history in memory. |
client.cancel(workload_id) |
None |
Request a safe stop. Cancellation is a request, not a kill: work in flight is allowed to commit its checkpoint before the status settles on CANCELLED. |
client.healthz() |
dict |
Unauthenticated liveness probe for the configured base_url. |
The client is a context manager: exiting closes the transport and releases pooled sockets. Use it for short-lived processes. Long-lived services should keep a module-level client instead.
>>> import nodus >>> # Explicit configuration; omit any argument to read it from the environment. >>> client = nodus.Client( ... api_key="nk_live_…", ... base_url="https://api.nodus.run", ... timeout=30.0, ... max_retries=2, ... ) >>> client.healthz() {'status': 'ok'} >>> # Context-managed form closes the transport on exit. >>> with nodus.Client() as client: ... for wl in client.iter_workloads(page_size=100): ... print(wl.id, wl.status, wl.spend_usd)
# Workload
The handle returned by client.run() and
client.get(): one brief's lifecycle state, chosen route,
spend, and output.
Handles are mutable. refresh() and
wait() update in place and return the same instance, so
reads after either call cost nothing. That also makes a handle unsafe to share across
threads or tasks. Give each its own from
client.get().
| Attribute | Type | Meaning |
|---|---|---|
id | str | Stable identifier, wl_…. |
status | WorkloadStatus | Current lifecycle state. See Types. |
route | Route | None | Chosen route. None until planning resolves. |
spend_usd | float | Committed spend so far, including recovery already performed. |
budget_usd | float | The ceiling from the brief. Cost to completion is planned against this, not against an hourly rate. |
created_at | datetime | Acceptance time, timezone-aware UTC. |
updated_at | datetime | Last state transition. |
stages | list[StageRun] | One entry per stage. A single-stage brief has exactly one. |
error | str | None | Terminal failure reason; None otherwise. |
is_terminal | bool | True once the status can no longer change. |
succeeded | bool | True only for COMPLETED. Check this rather than is_terminal, which is also true for failure and cancellation. |
| Method | Returns | Notes |
|---|---|---|
workload.refresh() |
Workload |
One read; updates this instance in place. |
workload.wait(poll_seconds=2.0, timeout_seconds=None) |
Workload |
Poll until is_terminal. Raises APITimeoutError if timeout_seconds elapses first; the workload keeps running, since a client-side deadline is not a cancellation. |
workload.events(after=0) |
list[Event] |
Ordered lifecycle events. Pass the last sequence you saw as after to read only what is new. |
workload.stream_events() |
Iterator[Event] |
Blocking iterator that yields events as they occur and stops at the terminal event. |
workload.artifacts() |
list[Artifact] |
Checkpoint and output manifests, each with name, uri, sha256, bytes, stage_id, and verified. |
workload.ledger() |
Ledger |
entries (id, entry type, debit, credit, currency, evidence, timestamp) plus a settlement with status and total. This is what a spend number is defensible against. |
workload.cancel() |
None |
Same safe stop as client.cancel(id). |
Each StageRun carries id,
status, continuity_mode,
completed_units, total_units,
and latest_manifest. Progress counts in units the stage
defines, so a reclaim that resumes from the last manifest reads as work retained, not
work lost.
>>> import nodus >>> >>> with nodus.Client() as client: ... wl = client.run( ... model="7B fine-tune", ... command=["python", "train.py", "--epochs", "3"], ... peak_memory_gb=38, ... expected_runtime_hours=6, ... budget=180.00, ... finish_by="2026-07-29T08:00:00Z", ... continuity="checkpointed", ... interrupt_tolerance="high", ... data_regions=["us"], ... ) ... wl.wait(poll_seconds=5.0, timeout_seconds=8 * 3600) ... ... # wait() mutated wl in place, so these reads cost nothing. ... print(wl.status, wl.succeeded) ... print(wl.route.sku, wl.route.compute_class) ... print(f"{wl.spend_usd:.2f} of {wl.budget_usd:.2f} budgeted") ... for art in wl.artifacts(): ... print(art.name, art.sha256[:12], art.verified) WorkloadStatus.COMPLETED True nodus:a100-40-us-east accelerator 164.20 of 180.00 budgeted final.safetensors 9f2c1ab77d40 True
That brief names the work and its constraints: memory high-water mark, runtime, budget, deadline, continuity, residency. It names no machine, and the result reports a catalog route. That is the whole interface. You state what has to be true when the work finishes; Nodus is accountable for making it true.
# Route
The placement decision, expressed entirely in the Nodus catalog.
None through
ACCEPTED and
PLANNING, set from
RESERVING onward.
| Attribute | Type | Meaning |
|---|---|---|
sku | str | Catalog identifier, always nodus:… — for example nodus:a100-40-us-east. |
compute_class | str | "accelerator" or "vm". |
fit_class | str | The capability class the brief was fitted to, such as a100-40. It describes a capability envelope — memory, interconnect, throughput — not a vendor part number. |
region | str | Region the work runs in, consistent with the data_regions constraint on the brief. |
price_usd_hour | float | Rate for the route, in USD per hour. |
expected_cost_usd | float | Cost to completion: the run plus expected recovery. |
expected_hours | float | Expected wall-clock hours of successful execution. |
interruptible | bool | Whether the route can be reclaimed underneath the workload. |
Cost to completion is the only cost that matters. An hourly rate prices
an hour, not a finished job. Some fraction of interruptible runs get reclaimed, then
re-provisioned and resumed, and that time bills like any other.
expected_cost_usd is
price_usd_hour ×
expected_hours plus expected recovery, so it can exceed
the naive product of the two. Comparing routes on rate alone picks the wrong one. Your
budget is checked against this number, never against a
plan that only fits if nothing goes wrong.
The customer surface carries no supplier identity. No supplier field on
Route, on Workload, or
anywhere in the API. That is the product contract, not a gap in the schema. Nodus picks
where the work runs, moves it when capacity is reclaimed, and stays accountable for the
deadline and the budget across every move. You get a catalog route and an outcome. The
machinery underneath is ours to operate and ours to be wrong about.
# AsyncClient
nodus.AsyncClient takes the same constructor arguments
and mirrors every method on Client,
awaited instead of blocking: run,
get, list,
cancel, and healthz are
coroutines, iter_workloads is an async iterator, and the
client is an async context manager.
>>> nodus.AsyncClient(api_key=None, *, base_url=None, timeout=30.0, max_retries=2)
Calls return an AsyncWorkload: the same attributes as
Workload, same types, same
in-place mutation, with awaitable methods.
await wl.refresh(),
await wl.wait(),
await wl.events(),
await wl.artifacts(),
await wl.ledger(),
await wl.cancel(), and
async for event in wl.stream_events(). Because
wait() yields between polls, thousands of concurrent
waits cost a task each rather than a thread each.
# fanout.py import asyncio import nodus async def sweep(client, lr: float) -> nodus.AsyncWorkload: wl = await client.run( model=f"7B sweep lr={lr}", command=["python", "train.py", "--lr", str(lr)], peak_memory_gb=38, expected_runtime_hours=4, budget=120.00, continuity="checkpointed", interrupt_tolerance="high", ) await wl.wait(poll_seconds=5.0) return wl async def main() -> None: async with nodus.AsyncClient() as client: runs = await asyncio.gather( *(sweep(client, lr) for lr in (1e-5, 3e-5, 1e-4)), ) for wl in runs: # route is a Nodus catalog SKU, e.g. nodus:a100-40-us-east print(wl.id, wl.status, wl.route.sku, f"${wl.spend_usd:.2f}") asyncio.run(main())
# Types
Enums compare equal to their wire strings, so
wl.status == "running" works without importing anything.
Fields taking an enum also accept the string.
ComputeClass — what the work was fitted to.
| Member | Wire value | Meaning |
|---|---|---|
ComputeClass.ACCELERATOR | "accelerator" | Anything with a device-memory high-water mark: training, fine-tuning, batch inference. |
ComputeClass.VM | "vm" | Plain CPU and memory: data prep, eval harnesses, ETL, scoring. |
Compute class is an output of fitting the brief, not something you declare. A brief that needs no accelerator routes to a VM class through identical planning, pricing, recovery, and settlement. Multi-stage briefs commonly mix both.
ContinuityMode — what must survive an interruption.
The most consequential field in a brief: it decides what recovery may do.
| Member | Wire value | Meaning |
|---|---|---|
ContinuityMode.CHECKPOINTED | "checkpointed" | Default. State commits to verified manifests as work proceeds. A reclaim resumes from the last one and repeats only the work since. |
ContinuityMode.RESTARTABLE | "restartable" | No intermediate state worth keeping. A reclaim reruns the stage, which beats checkpointing when runs are short. |
ContinuityMode.EPHEMERAL | "ephemeral" | Not worth resuming or repeating. A reclaim ends the stage. |
InterruptTolerance — how much interruption the
outcome absorbs. Higher widens the feasible routes and lowers cost to completion; lower
narrows and raises it.
| Member | Wire value | Meaning |
|---|---|---|
InterruptTolerance.LOW | "low" | Prefer routes that are not reclaimed. Deadlines with no slack. |
InterruptTolerance.MEDIUM | "medium" | Interruption acceptable if the deadline holds. |
InterruptTolerance.HIGH | "high" | Interruption routine. With CHECKPOINTED, the lowest cost to completion. |
WorkloadStatus — the lifecycle, in order. A workload
can bounce between RECOVERING and
RUNNING any number of times before settling.
| Member | Wire value | Meaning |
|---|---|---|
WorkloadStatus.ACCEPTED | "accepted" | Brief validated and durable. No route yet. |
WorkloadStatus.PLANNING | "planning" | Fitting requirements to a capability class and pricing completion against budget and deadline. |
WorkloadStatus.RESERVING | "reserving" | Holding capacity. route is populated from here on. |
WorkloadStatus.PROVISIONING | "provisioning" | Environment building, inputs staging. |
WorkloadStatus.RUNNING | "running" | Executing. spend_usd and stage progress advance. |
WorkloadStatus.RECOVERING | "recovering" | Capacity reclaimed or environment failed. Nodus is re-placing the work under its continuity mode. Not an error, not your move. |
WorkloadStatus.COMPLETED | "completed" | Terminal. Outcome produced, artifacts verified. The only state where succeeded is true. |
WorkloadStatus.FAILED | "failed" | Terminal. Recovery could not deliver within the brief; error says why. |
WorkloadStatus.CANCELLED | "cancelled" | Terminal. Stopped on request, after in-flight work could commit. |
COMPLETED, FAILED, and
CANCELLED are the terminal states: the exact set where
is_terminal is true and
wait() returns. Everything else can still change, so
never read RECOVERING as failure.
The SDK also exports the brief models: Requirements,
Outcome, Continuity,
Policy, StageSpec. Pass
keyword arguments to run() and never touch them, or
construct them directly when generating briefs programmatically and you want validation
before the call.
Event. workload.events() and
stream_events() yield these.
| Attribute | Type | Meaning |
|---|---|---|
seq | int | Monotonic per workload. Pass the last one you saw as after to resume without re-reading. |
id | str | Stable event identifier. Dedupe on it if you process the same workload from more than one place. |
type | str | Lifecycle transitions and durability milestones — workload.running, checkpoint.committed, workload.completed. |
payload | dict | Event-specific detail. Treat unknown keys as additive. |
created_at | datetime | When the control plane recorded it. |
# Errors
Everything the SDK raises inherits nodus.NodusError, so
one except nodus.NodusError catches all of it and nothing
else. Each carries a request_id where the control plane
returned one. Quote it in support requests.
| Class | HTTP | When |
|---|---|---|
NodusError | — | Base class for everything below. |
ConfigurationError | — | Before any network call: no key resolved, malformed base URL, contradictory arguments. Nothing was sent, so nothing was charged. |
AuthenticationError | 401 / 403 | Key missing, unknown, revoked, or expired. Never retry. |
NotFoundError | 404 | No such workload for this tenant. Another tenant's id reads as absent, not forbidden. |
ValidationError | 400 / 422 | Brief rejected. .code and .message identify the field. |
IdempotencyConflictError | 409 | Same key, different payload. Change the key, or resend the original to replay it. |
RateLimitError | 429 | Too many requests. .retry_after gives the seconds. |
BudgetExceededError | 402 | Would breach a cap on the key. .payload carries the cap, month-to-date, and estimate. |
CapacityUnavailableError | 503 | No feasible route right now for this combination of requirements, deadline, residency, and budget. Retryable; likelier with a wider brief. |
SignatureError | 401 | Signature rejected. Usually a stale timestamp, a re-serialized body, or the wrong secret. |
APIError | other 4xx / 5xx | Any response with no more specific class. |
APIConnectionError | — | Never reached the control plane. Retried up to max_retries. |
APITimeoutError | — | A deadline elapsed: per-request timeout, or timeout_seconds on wait(). The workload keeps running either way. |
When writing a handler, the only distinction that matters is whether the condition can change on its own. Rate limits and capacity clear with time. Budget caps clear only if you ask for less. Authentication and validation failures never clear.
# submit.py import time import nodus def submit(client, brief: dict, attempts: int = 6) -> nodus.Workload: brief = dict(brief) for _ in range(attempts): try: return client.run(**brief) except nodus.AuthenticationError: # A rejected credential never becomes valid. Fail loudly. raise except nodus.ValidationError as exc: # The brief is wrong, not unlucky. Retrying resends the same brief. raise RuntimeError(f"bad brief [{exc.code}] {exc.message}") from exc except nodus.RateLimitError as exc: time.sleep(exc.retry_after) except nodus.BudgetExceededError as exc: cap = exc.payload["monthly_spend_cap_usd"] spent = exc.payload["month_to_date_usd"] needed = exc.payload["estimated_cost_usd"] headroom = cap - spent if needed > headroom: # Nothing fits under this key this month. Escalate. raise # Resubmit against real headroom rather than the asked-for ceiling. brief["budget"] = headroom except nodus.CapacityUnavailableError: # Retryable. Widen the brief so more routes become feasible: # accept interruption, and give up the hard deadline. brief["interrupt_tolerance"] = "high" brief["continuity"] = "checkpointed" brief.pop("finish_by", None) time.sleep(30) raise RuntimeError("no feasible route after widening the brief")
Widening works because feasibility is a function of the brief, not of capacity alone.
Raising interrupt_tolerance and dropping
finish_by admits interruptible routes the deadline had
excluded, and continuity="checkpointed" bounds the
trade: a reclaim costs the work since the last checkpoint, not the run. Relaxing
data_regions would widen it further. The handler leaves
residency alone because it is a compliance constraint, not a preference.
Pass your own key when you retry across calls. The SDK mints a fresh
Idempotency-Key per call to
run(), covering that call's internal retries and nothing
more. The loop above calls run() again, so it gets a new
key and can create a second paid workload if the first submission landed and only the
response was lost. Derive a key from the thing you are running, and keep it stable across
retries of the same submission:
workload = client.run(**brief, idempotency_key=f"nightly-eval-{run_date}")
Change the key whenever the payload changes. Reusing one with a different brief raises
IdempotencyConflictError, which is the system telling you
two intents were given the same name. Full rule in
retries & idempotency.