Errors
The public API is REST over JSON. A failed call comes back as a standard HTTP status code with a body that says, in the server's own words, what went wrong. The API routes return the gRPC status behind the refusal:
{ "code": 9, "message": "top up your wallet to start this server", "details": [] }That code is the numeric gRPC status code (9 is FAILED_PRECONDITION), not the HTTP status. The
unauthenticated onboarding endpoints (/v1/public/signup, /v1/public/login, /v1/public/device/*)
answer {"error": "…"} instead, and the live log stream refuses with one line of
plain text.
What the SDK, CLI and MCP server show
TrueTickClient and the standalone auth helpers (signup, login, deviceStart, devicePoll) turn
every non-2xx into a typed TrueTickError:
| Field | What it holds |
|---|---|
status | The HTTP status |
code | A name derived from the HTTP status: unauthorized, forbidden, not_found, rate_limited, server_error, or http_error for any other status |
message | The server's own text when the body carried one (node at capacity); otherwise a generic sentence for the status. A plain-text body that only repeats the status word (the log stream's forbidden) counts as none |
grpcCode | The gRPC code name from the body (failed_precondition, resource_exhausted, …); absent when the body had none |
retryAfter | Seconds from the Retry-After header, when the response sent one |
details | The status body's details array, as sent |
The Sign in with TrueTick helpers throw TrueTickError too, with
codes of their own and never a grpcCode or retryAfter. AppClient uses unauthenticated (401),
permission_denied (403), rate_limited (429), unavailable (5xx), http_<status> for any other
status, and bad_response for a 2xx body that isn't JSON. signInWithDevice uses the error text the
device endpoints sent (access_denied, expired_token, invalid_client, too many requests, …),
bad_response for a body that isn't JSON, or aborted.
The CLI prints Error (failed_precondition): top up your wallet to start this server,
and the MCP server gives the agent the same facts as the tool error's text:
top up your wallet to start this server (HTTP 400, failed_precondition). When the body names no gRPC
code (the onboarding endpoints, the log stream), the CLI labels the error with the SDK code instead,
except on a 429: that prints the message alone (Error: api key limit reached (max 25 per account); revoke an unused key first), because the status can't tell a rate limit from a limit that doesn't clear
by itself.
This section describes @truetick/sdk 0.3.0 and later, and @truetick/cli and @truetick/mcp 0.2.0 and
later. In SDK 0.2.0 and in 0.1.x of all three, TrueTickClient's message is a generic sentence for the
HTTP status, never the server's words (0.2.0's AppClient already passes the server's text through);
grpcCode, retryAfter and details don't exist; the auth helpers throw a plain Error; the CLI
prints an API refusal as Error (<code>): <that sentence>; and the MCP server's tool error is a
sentence from the status alone.
Status codes
| HTTP | SDK code | grpcCode | Meaning & typical cause |
|---|---|---|---|
400 | http_error | invalid_argument, failed_precondition | Invalid argument — malformed body/params. Or a precondition isn't met (e.g. start blocked on zero balance or unverified email, or a server over its disk quota) |
401 | unauthorized | unauthenticated | Missing/invalid/revoked x-api-key (invalid or missing api key). Check the key |
403 | forbidden | permission_denied | The key lacks the scope for this operation (api key not authorized for this operation), or the request names an account the key isn't bound to (account mismatch). See scopes |
404 | not_found | not_found | Unknown id/path — or a resource owned by a different account (we return 404, not 403, so foreign ids don't leak existence) |
409 | http_error | already_exists, aborted | Conflict — e.g. that name is taken when creating a server, or an install for this server is already in progress when retrying a failed modpack install |
429 | rate_limited | resource_exhausted | Rate limit exceeded, or the node/region is at capacity / low on disk, or an account limit was hit (see below) |
5xx | server_error | internal, unavailable, deadline_exceeded, unimplemented | Server-side failure. A 501 (unimplemented) doesn't clear on a retry |
A 429 alone doesn't tell a rate limit from a capacity refusal or an account limit: on the API routes
all three are resource_exhausted. message tells them apart — the sections below list what each one
says.
At capacity and quota
Because TrueTick doesn't oversell, the platform will honestly refuse work it can't do well, rather
than degrading every server on a box. These show up as 429 (Too Many Requests):
| Message | When | Retry? |
|---|---|---|
node at capacity | The node/region has no free slot for this RAM tier | Not in a loop — try the other region, or later |
node is low on disk | Not enough free disk to admit the server right now | Later |
server limit reached (N) | You've hit the max servers per account — delete one or contact support | No: it doesn't clear by itself |
api key limit reached (max 25 per account); revoke an unused key first | The account already has 25 unrevoked keys — revoke one first | No: it doesn't clear by itself |
And as 400 (precondition not met):
| Message | When |
|---|---|
server is over its disk quota — delete files, it's rechecked every few minutes | The server exceeded its disk quota; free space and it's re-admitted automatically |
top up your wallet to start this server | Wallet balance is ≤ 0 |
verify your email to start this server | Email verification is required and pending |
monthly spend cap reached | The account's metered spend this month has reached the monthly cap its owner set in the panel |
At-capacity is the honesty moat in action. A truthful "no room right now" is the price of
guaranteeing every placed server its full RAM and CPU. Query /v1/regions before
placing, and handle the 429 by falling back to another region or backing off.
Rate limits
The public API is rate-limited per key at 120 requests/minute (token bucket, burst 120).
Over-limit requests get 429 with the message rate limit exceeded.
Retry only the refusals that can clear up on their own: the rate limit and a 5xx other than 501. A
capacity refusal or an account limit is a 429 too, and retrying it in a loop only spends your rate
limit. Back off exponentially, and wait longer when a Retry-After header asks for longer:
import { TrueTickError } from "@truetick/sdk";
// The rate limit and a capacity refusal are both 429 + resource_exhausted;
// the message tells them apart. A 501 (unimplemented) never clears.
function isTransient(e: unknown): e is TrueTickError {
return e instanceof TrueTickError && ((e.status >= 500 && e.status !== 501) || e.message === "rate limit exceeded");
}
async function withRetry<T>(fn: () => Promise<T>, tries = 5): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e) {
if (isTransient(e) && i < tries - 1) {
// 0.5s, 1s, 2s, 4s…, or as long as Retry-After asks when that is longer
const waitMs = Math.max((e.retryAfter ?? 0) * 1000, 2 ** i * 500);
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw e;
}
}
}Today only the onboarding endpoints send Retry-After. It counts down: the seconds until the limit
that refused the call resets, rounded up and at least 1. That is at most 3600 for signup, device
start and device poll, whose limit windows are one hour, and at most 600 for login, whose limit
window is ten minutes. The API routes don't send it, so retryAfter is absent there and the backoff
applies.
The recipe needs @truetick/sdk 0.3.0 or later: 0.1.x and 0.2.0 have no retryAfter, and their message
never reads rate limit exceeded, so isTransient would never retry the rate limit.
Live log streams have separate concurrency caps (3 per key, 10 per account),
also surfaced as 429 (too many concurrent log streams for this key / … for this account). Those
clear when one of your open streams ends, not on a backoff timer.
Retries & idempotency
There is no idempotency-key header on the public API — design retries around each operation's natural semantics:
- Reads (
GET) andDELETEare idempotent — safe to retry freely. - Lifecycle calls (
start/stop/restart) converge on a target state; treatstatefrom a follow-upGETas the source of truth rather than assuming a single call "took." - Create is not idempotent: a server id derives from the name, and creating one that already exists conflicts. Guard against duplicate creates on your side (check existence, or use a unique name per attempt — handy for ephemeral CI servers).
- Top-ups are deduplicated server-side (the payment webhook is idempotent), so a retried checkout won't double-credit your wallet.
For a 5xx other than 501, or the rate limit, retry with backoff (as above). For a 401, 403, 404,
a 400 invalid_argument or a 409 already_exists, fix the request — retrying it as-is gets the same
answer, and so does a 501. A 400 failed_precondition clears once what it names changes: a top-up, a
verified email, a raised spend cap, or deleted files on a server over its disk quota. A 409 aborted
(an install for this server is already in progress) clears by itself when that install finishes. A
capacity 429 can clear, but on the scale of other servers stopping, not of a backoff loop: fall back
to another region. An account-limit 429 clears only once you delete a server or revoke a key.
Parsing note
Large integers (ramMb, balanceMicros, sizeBytes) are JSON-encoded as strings. The SDK coerces
them to numbers; in raw HTTP, wrap with Number(...) before doing math.