CLI & SDK guideThe SDK
When the platform says no
Reading a refusal in each SDK — the code to branch on, whether to retry, and the hint to log.
When the platform refuses a call, the SDK raises an error carrying the platform’s own sentence and three fields beside it. Branch on the fields, never on the sentence: the wording is for a person and can change.
| Field | Means | Python / Node | Go | R |
|---|---|---|---|---|
code |
A stable name: queue_full, not_found, app_locked |
err.code |
err.Code() |
e$code |
retryable |
Whether trying again could work | err.retryable |
err.Retryable() |
e$retryable |
hint |
The next step, when there is one | err.hint (None / undefined) |
err.Hint() ("") |
e$hint (NULL) |
The status is there too (err.status, Status, e$status), and so is the
decoded body (err.body, Body, e$body), for refusals that carry figures
such as a full queue’s limit. A current install always sends a code.
The same fields are on every refusal, whether it came from your app’s own calls
or, inside a job, from the run’s calls such as run.progress() and
write_output().
Refusals the SDK makes itself
Section titled “Refusals the SDK makes itself”Some refusals never reach the platform, because the SDK can see the answer
first: a storage name nothing maps, a store that is reached over signed URLs
rather than a path, a level too low to write. They carry the same fields, with
retryable always false — a mapping or a name is wrong, and trying again
changes neither.
code |
When |
|---|---|
not_mapped |
No storage resource by that name is mapped to the app. |
ambiguous_mount |
One name is mapped twice (two folders) and no folder was named. |
not_mounted |
.path or put on a store that is not mounted here; use upload, download, list, url. |
insufficient_access |
A write through a mapping that does not allow it. |
already_exists |
An upload of a name that exists, at write. |
not_a_store |
A store-only call on a volume. |
invalid_name |
An empty name, an absolute one, or one with . or ... |
no_viewer |
viewer(headers) on a request with nobody signed in — an API key, a health check, a local server — or jobs.mine() on the app acting as itself. The hint names starting the job as the app instead. |
would_outlive_request |
wait() while serving a request, with no timeout or one past the request’s deadline. The hint is return toft.jobs.respond(run). |
wait_timeout |
wait() ran out of time before the run finished. Retryable: the run is still going. |
bad_request |
An argument the SDK rejects before sending: an email with no recipient, blank text, an empty send or session id, app data that is not an object. |
no_output |
write_output in a run that has no output location. |
no_run |
An in-job call (R’s run_progress(), run_write_output()) outside a job. |
off_platform |
A variable the platform injects is missing — the code is running outside Toft. |
In R these are toft_error conditions like the platform’s, with e$status NA.
Should I retry?
Section titled “Should I retry?”Use retryable. It is the platform’s decision, not a guess from the status: a
409 queue_full means “wait for a run to finish”, which is worth retrying, while
a 503 that means “this install is not configured for that” never succeeds on a
retry. The SDK falls back to the status (429, and any 5xx except 501) only when
the platform said nothing, such as a network failure or a proxy’s error page.
When the platform says how long to wait, the error carries it: retry_after
(Python, seconds), retryAfter (Node, seconds), RetryAfter (Go, a
time.Duration), e$retry_after (R, seconds).
The SDK retries once on its own, and only for a request that never reached the
platform’s handler, such as a rate limit at the gateway. Any other retry, a
retryable 409 included, is your code’s to make.
Log the hint. It is written for whoever reads the failure, and it is
usually the part they act on: “an administrator has to lift the lock”, “mint a
new key”.
Python
Section titled “Python”import timeimport toft
try: toft.jobs.start("reconcile", params={"month": "2026-03"})except toft.ToftError as err: log.warning("refused: %s (%s) %s", err, err.code, err.hint or "") if err.retryable: time.sleep(err.retry_after or 5) toft.jobs.start("reconcile", params={"month": "2026-03"}) else: raisetoft.QueueFull and toft.VersionConflict are subclasses, so one except toft.ToftError catches them too.
import toft from "@larchway/toft";
try { await toft.jobs.start("reconcile", { params: { month: "2026-03" } });} catch (err) { if (!(err instanceof toft.ToftError)) throw err; console.warn(`refused: ${err.message} (${err.code}) ${err.hint ?? ""}`); if (!err.retryable) throw err; await new Promise((r) => setTimeout(r, (err.retryAfter ?? 5) * 1000));}ToftError is also a named export: import { ToftError } from "@larchway/toft".
run, err := toft.Jobs.Start(ctx, "reconcile", toft.StartOptions{Params: params})var terr *toft.Errorif errors.As(err, &terr) { log.Printf("refused: %v (%s) %s", terr, terr.Code(), terr.Hint()) if terr.Retryable() { time.Sleep(max(terr.RetryAfter, 5*time.Second)) run, err = toft.Jobs.Start(ctx, "reconcile", toft.StartOptions{Params: params}) }}errors.As also reaches *toft.QueueFullError and *toft.VersionConflictError
for their extra fields.
A refusal is a condition of class toft_error, so a handler for it catches the
platform’s refusals and nothing else.
library(toft)
tryCatch( job_start("reconcile", params = list(month = "2026-03")), toft_error = function(e) { message("refused: ", conditionMessage(e), " (", e$code, ") ", e$hint %||% "") if (isTRUE(e$retryable)) { Sys.sleep(e$retry_after %||% 5) job_start("reconcile", params = list(month = "2026-03")) } else { stop(e) } })The message is the same one earlier versions raised, so code matching it keeps
working, but new code should read e$code.
The same fields everywhere
Section titled “The same fields everywhere”The platform puts error, code, retryable and, where it has one, hint in
the body of every refusal its API sends. toft reads them in its
--json errors, and a script calling the
API with curl or toft api can read them the same way.