Skip to content

Errors

from tileward import errors

try:
    tw.chat.say("...")
except errors.InsufficientBalanceError:   # 402 — top up; calls resume immediately
    ...
except errors.GuardRefusal as exc:        # governance blocked it
    ...
except errors.APIError as exc:            # exc.status, exc.code, exc.request_id
    ...

A refusal is not an error

A governed refusal arrives as an ordinary completion, with id chatcmpl-governed and finish_reason: "content_filter". The guard's read of the prompt is billed, and usage reports it as prompt_tokens; completion_tokens is 0, because the model never ran.

chat.completions.create passes it through, because that is what the API returned and a caller metering responses needs to see it. chat.say raises GuardRefusal, because it promised text back and returning an empty string would hide the reason.

A model that declines also finishes with content_filter, so the finish reason alone does not say the guard refused. The id does:

from tileward.resources.chat import refusal_of, refused_by_gate

completion = tw.chat.completions.create("...")
if refused_by_gate(completion):
    ...        # the guard refused; the model never ran
elif refusal_of(completion):
    ...        # the model declined

refused_by_gate takes a chunk of a stream as well; every chunk of a guard refusal carries the id. GuardRefusal is raised for both kinds. Its .completion carries the whole completion, so a handler can read the usage block and the refusal text, and pass it to refused_by_gate.

The exception tree

TilewardError
├── ConfigError            not configured well enough to make the call
├── ConnectionError_       no answer at all: DNS, TLS, connect or read timeout
├── GuardRefusal           refused by governance
└── APIError               the API answered, and the answer was an error
    ├── AuthenticationError      401
    ├── InsufficientBalanceError 402
    ├── PermissionError_         403
    ├── NotFoundError            404
    ├── RateLimitError           429
    └── ServerError              5xx

Every APIError carries .status, .code, .type, .body and .request_id. Quote the request id when reporting a problem — it identifies the exact call in the audit trail.

ConnectionError_ and PermissionError_ carry trailing underscores so they do not shadow the builtins of the same name. PermissionError_ is reachable as errors.PermissionError_; it is not re-exported at the top level for that reason.

What each one usually means

Exception Usually
ConfigError no key, or a call that needs a session and has only a key
AuthenticationError the key or session is missing, malformed, revoked, or expired
InsufficientBalanceError the prepaid balance is exhausted; top up and continue
PermissionError_ authenticated, but not entitled on this plan
NotFoundError a model id that is not served — check tw.models.list()
RateLimitError back off; the client already retries these
ServerError includes a 502 from an upstream model, which is transient
ConnectionError_ the request never got an answer

NotFoundError from models.retrieve names what is served in its message, which is the answer to the question you were about to ask.

A 404 from api.tileward.com on a non-/v1 path is not a missing endpoint — that host serves /v1 only. See Hosts.

Retries

Connection failures, 429 and 5xx are retried twice by default, with exponential backoff and full jitter, floored by any Retry-After the server sent. Full jitter rather than a fixed doubling: a fleet that all retries at exactly 1s, 2s, 4s re-creates the burst that caused the 429.

A stream is not retried once bytes have arrived.

Tileward(max_retries=0)          # off

From the command line

Errors become a one-line message on stderr and an exit code:

Exception Exit code
GuardRefusal 4
InsufficientBalanceError 5
AuthenticationError, ConfigError 3
any other TilewardError 1
bad usage 2
Ctrl-C 130

The full table, and what to do with it in CI, is in Exit codes.