Errors

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:

FieldWhat it holds
statusThe HTTP status
codeA name derived from the HTTP status: unauthorized, forbidden, not_found, rate_limited, server_error, or http_error for any other status
messageThe 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
grpcCodeThe gRPC code name from the body (failed_precondition, resource_exhausted, …); absent when the body had none
retryAfterSeconds from the Retry-After header, when the response sent one
detailsThe 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

HTTPSDK codegrpcCodeMeaning & typical cause
400http_errorinvalid_argument, failed_preconditionInvalid 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)
401unauthorizedunauthenticatedMissing/invalid/revoked x-api-key (invalid or missing api key). Check the key
403forbiddenpermission_deniedThe 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
404not_foundnot_foundUnknown id/path — or a resource owned by a different account (we return 404, not 403, so foreign ids don't leak existence)
409http_erroralready_exists, abortedConflict — 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
429rate_limitedresource_exhaustedRate limit exceeded, or the node/region is at capacity / low on disk, or an account limit was hit (see below)
5xxserver_errorinternal, unavailable, deadline_exceeded, unimplementedServer-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):

MessageWhenRetry?
node at capacityThe node/region has no free slot for this RAM tierNot in a loop — try the other region, or later
node is low on diskNot enough free disk to admit the server right nowLater
server limit reached (N)You've hit the max servers per account — delete one or contact supportNo: it doesn't clear by itself
api key limit reached (max 25 per account); revoke an unused key firstThe account already has 25 unrevoked keys — revoke one firstNo: it doesn't clear by itself

And as 400 (precondition not met):

MessageWhen
server is over its disk quota — delete files, it's rechecked every few minutesThe server exceeded its disk quota; free space and it's re-admitted automatically
top up your wallet to start this serverWallet balance is ≤ 0
verify your email to start this serverEmail verification is required and pending
monthly spend cap reachedThe 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) and DELETE are idempotent — safe to retry freely.
  • Lifecycle calls (start/stop/restart) converge on a target state; treat state from a follow-up GET as 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.