Error catalogue
Every failure irtio names has a stable E_* code. The 47 protocol codes also have stable numeric
codes that will never be renumbered, so a client and a server built months apart still agree on
what went wrong.
Where you see an error depends on which side raised it:
- Fatal server errors reject the
joinRoomorjoinRelaypromise if the room was never joined, or close the connection and fireroom.on('error', ...)withfatal: true. - Non-fatal server errors fire
room.on('error', ...)withfatal: falseand leave you connected. - RPC errors reject the promise
room.call.<name>(...)returned. - Room-side errors reject a promise inside your room file and never reach a client.
- Client-local errors never touch the wire at all.
Branching on a code
There is no error class to import. Connection errors arrive as a plain object on the error event, with exactly three fields:
interface RoomError {
readonly code: string; // 'E_ROOM_FULL', or 'E_INTERNAL' for a local failure
readonly message: string;
readonly fatal: boolean; // fatal errors close the room
} So the connection branch is a switch on e.code:
// main.ts
room.on('error', (e) => {
switch (e.code) {
case 'E_STARTING':
showSpinner('waking the server');
break;
case 'E_ROOM_FULL':
showMessage('this room is full');
break;
case 'E_SLOW_CONSUMER':
// the client reconnects on its own; nothing to do but say so
showMessage('reconnecting');
break;
default:
if (e.fatal) showDisconnected(e.message);
}
}); RPC failures are different, and the difference matters. room.call.<name>() rejects with a plain Error that carries no code property; read its message. When your handler refused the call with ctx.deny(reason), the message is deny: <reason>, which is the normal way a room says no. When
irtio refused the call, or your handler threw, the message starts with the code and a colon:
try {
await room.call.play({ card });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.startsWith('deny: ')) {
showToast(message.slice('deny: '.length)); // your room's own reason: 'not your turn'
} else if (message.startsWith('E_RPC_BAD_PARAMS')) {
console.error('client and server schemas disagree', message);
} else {
console.error(message);
}
} A fatal error that arrives before the join completes rejects joinRoom with an Error whose
message is the code, a colon, and the server’s message, so the same startsWith check works
there.
Connection and identity
| Code | Number | Fatal | What it means and what to do |
|---|---|---|---|
E_PROTOCOL_VERSION | 1 | yes | Your @irtio/client and the deployed server disagree about the wire protocol. Update the client, or redeploy the room, so both are on the same major version |
E_ORIGIN | 2 | yes | The page’s origin is not in the project’s origin list. Register it. localhost is always allowed, so this only bites on a deployed page, usually the first time you open a preview URL |
E_AUTH | 3 | yes | The project key was rejected, or no HELLO arrived in time. Check that project in irtio/schema.ts matches a project that exists on the control plane you are pointing at. On localhost with no irtio init, the client uses the key dev, which only irtio dev accepts |
E_SCHEMA_MISMATCH | 4 | yes | The schema your client was built with is not the schema the room is running. The hash covers every field, type, order, role and RPC signature. Rebuild the client against the schema you deployed, or deploy the schema your client has. If you ship the page with irtio deploy (--static, or static in irtio.json), end the client build with irtio stamp so the deploy refuses a stale page instead of putting it live |
E_RESUME_EXPIRED | 8 | no | Your reconnection window (reconnectGraceMs, 30 seconds by default) closed while you were away. The client rejoins fresh on its own. room.me changes, so re-read anything you cached from it. Raise reconnectGraceMs if your players routinely background the tab |
E_SCHEMA_MISMATCH is the error that catches “I changed a field and only redeployed one side”,
which is exactly what it is for.
Tokens (JWT auth)
Raised only for joins that present a token. A join with no token can never hit these. See Authentication for the claims and setup these codes are checking.
| Code | Number | Fatal | What it means and what to do |
|---|---|---|---|
E_TOKEN_EXPIRED | 21 | yes | The token’s exp is in the past (or its iat is in the future), beyond the 60-second clock leeway. Also what a reconnect gets when a session outlives its token. Mint a fresh token on your server, and pass token as a function so every reconnect fetches a new one |
E_TOKEN_INVALID | 22 | yes | The signature does not verify against any of the issuer’s live secrets. Check the server is signing with the secret irtio keys jwt-secret printed, and that a rotation was finished (irtio keys jwt-secret retire) rather than left half-done |
E_TOKEN_WRONG_PROJECT | 23 | yes | The token’s aud claim is not this project’s id. Set aud to the project id in irtio.json |
E_TOKEN_WRONG_ROOM | 24 | yes | The token carries a roomId claim and the join is for a different room (creating a new room counts). Mint the token for the room actually being joined, or omit roomId to make it good for any room in the project |
E_TOKEN_MALFORMED | 25 | yes | Not a JWT, or missing a required claim — iss, aud, sub and exp are all required. Sign with the documented recipe; irtio keys jwt-mint produces a known-good token to compare against |
E_TOKEN_BAD_ISSUER | 26 | yes | The token’s iss claim names an issuer this project has no signing secret for. irtio keys jwt-secret --issuer <label> mints for a named issuer; the default label is main |
E_TOKEN_BAD_ALG | 27 | yes | The token’s header asks for an algorithm the issuer’s configuration does not allow. The verifier trusts its config, never the token’s header, so alg: "none" dies here by design. Sign with HS256, the one algorithm accepted this milestone |
Two of these are worth reading as configuration problems rather than attacks: E_TOKEN_BAD_ISSUER right after minting usually means the tenant has not been placed again yet, and E_AUTH on a
token join means the project has no secret at all (irtio keys jwt-secret is the fix, and the
error says so).
Rooms
| Code | Number | Fatal | What it means and what to do |
|---|---|---|---|
E_ROOM_NOT_FOUND | 5 | yes | No such room, or a room id with characters that cannot be one (ids are 1 to 32 of A-Z a-z 0-9 - _). Usually a truncated or hand-edited share link. Also sent when the join set mustExist and the room does not exist, or mustExist was set and no room id was given. Do not construct ids yourself: let joinRoom create one and read room.link |
E_ROOM_FULL | 6 | yes | The room is at maxClients, 64 by default. Raise it in irtio/room.ts, or send the player to a new room by joining with no ?room= |
E_ROOM_CLOSED | 7 | yes | The room called room.close(), was reset by a code reload, or could not recover from repeated failures. Join a new room. If you did not call close(), check irtio logs |
E_KICKED | 16 | yes | Your room called room.kick(clientId, reason). The reason is yours, so make it useful |
E_ADMISSION | 45 | yes | The room refused the join before it ever had a seat: either your onAdmit called ctx.deny(reason), or the joining subject is inside a room.ban cooldown. Distinct from E_KICKED, which ends a session that was already in the room. The message is the reason your room gave |
E_EJECTED | 46 | yes | A relay participant ejected this peer. Its verified credential subject is excluded until the room ends. An anonymous viewer can obtain a fresh credential; see moderation |
E_TYPE_AT_CAPACITY | 30 | yes | This room type is already running as many rooms as it declared it would. The tenant’s VM memory was sized from the declared per-room heap times that number, so the next room is one the machine was never built to hold. The message names the type, the limit and the field to raise |
E_AUDIENCE_FULL | 43 | yes | The room’s audience is full. Separate from E_ROOM_FULL on purpose: the audience cap and the participant cap are different numbers over different populations, so a viewer refused here is not being told the streamer’s own seats are taken |
E_AUDIENCE_SEND | 44 | no | An audience peer sent chat, addressed 'all', or exceeded its input budget. Viewers may address a client id or role. The message is refused and the connection stays open |
A room that throws in tick three times in a row restarts from its last snapshot. A room that
restarts too many times in a minute is closed with E_INTERNAL.
RPCs
| Code | Number | What it means and what to do |
|---|---|---|
E_RPC_UNKNOWN | 11 | The room has no implementation for that RPC name or id. Normally impossible to compile, so it means client and server were built from different rpc.ts files. Rebuild both |
E_RPC_REJECTED | 12 | You called a client-direction RPC from the client. Client-direction RPCs are what the server calls on you; declare it with server(...) if the client should call it |
E_RPC_TIMEOUT | 13 | No reply in time: 10 seconds for a client’s call to the server, 5 seconds for room.call(clientId) in the other direction. A server-to-client call rejects on disconnect, so a timeout usually means the client’s implementation never returned. Returning a promise is fine; never resolving it is not |
E_RPC_BAD_PARAMS | 14 | The parameters did not match the declared shape: a missing field, a wrong type, a string or list over its declared maximum. Types normally prevent this, so it means skew or a hand-built call |
To refuse an action, call ctx.deny(reason) in the handler. The caller’s promise rejects with the
message deny: <reason>, and the room carries on:
// irtio/room.ts
rpc: {
start(state, _params, ctx) {
if (ctx.role !== 'host') ctx.deny('host only');
state.match.phase = 'playing';
},
} The client catches an Error whose message is deny: host only. A handler that throws instead is
treated as a bug: the caller gets E_INTERNAL: rpc start, and the thrown message goes only to irtio logs. See RPCs.
The client-side timeout message is rpc <name> timed out, with no code prefix. A call attempted
while the socket is not open fails fast with its own message rather than waiting out the full
timeout.
Writes and ownership
| Code | Number | What it means and what to do |
|---|---|---|
E_NOT_OWNER | 10 | Someone tried to write an instance they do not own. Normally you never see this — writing a non-owned instance is a compile error and a warn-once no-op on the client — so it shows up when hand-writing frames, or after ownership changed underneath you. await room.requestOwnership(entity, id) first, and re-read the instance after a grant: object identity is not preserved across an ownership change |
E_WRITE_REJECTED | 15 | Your room’s validate refused the write outright, or the value did not fit its declared type (a str(24) over 24 UTF-8 bytes, a number outside a u8). If this is your own player moving legitimately, validate is too strict or the field too narrow |
Note the difference from a correction: a validate handler that returns prev, or a clamped
object, answers with a CORRECT carrying the server’s current values instead of either of these
errors, and you see it on the client as room.on('correct', ...). See ownership.
Load and lifecycle
| Code | Number | Fatal | What it means and what to do |
|---|---|---|---|
E_RATE_LIMITED | 9 | sometimes | Two unrelated limits share this code, so read the message, not just the code. rate limited is too many frames from one connection or project key (240 frames/s per connection, burst 2×; 600 HELLOs per client address per minute, and 6,000 per minute for the whole project): batch your writes instead of forcing them. Owned writes are already batched once per animation frame, so calling room.flush() in a loop defeats that, and an RPC per input event should be coalesced. too many connections from this address is a separate per-address connection cap (an IPv6 address counts by its /64), checked as the socket is accepted, before any frames: a token bucket, default connectionsPerIpPerMin: 120 (120 tokens, refilled 2/s), so a burst of more than 120 new connections from one address is refused. This one is per-project configuration: PATCH /v1/projects/:id with connectionsPerIpPerMin, which reaches the tenant as IRT_CONNECTIONS_PER_IP_PER_MIN at its next start (see irtio.json). A load run of more than about 120 clients from one machine needs it raised for the run, or the clients spread across addresses. Locally, startDev({ limits: { connectionsPerIpPerMin: … } }) sets it in code |
E_SLOW_CONSUMER | 20 | yes | The server dropped a client that was not draining its stream, rather than growing an unbounded queue for it. The client reconnects for a fresh snapshot on its own. The join snapshot does not count against the budget until it has been sent, so large room state alone does not cause this. If it recurs, the room produces more per tick than the connection can carry: lower tickRate, narrow field types, or use role visibility so each client receives less |
E_STARTING | 19 | no | Not really an error. Your project’s server was asleep and is waking. The client surfaces it as room.status === 'starting' and keeps waiting. Show a spinner; the lobby element’s connection dot already does |
E_TENANT_WAKING | 47 | yes | Your project’s machine was not up when this join arrived: it is booting behind a fresh deploy, it is being restarted, it is at its memory limit and is putting idle rooms to sleep to make room, or the address the router had cached named a machine that had just gone away. Nothing is broken and the join usually succeeds seconds later, so @irtio/client treats it as retriable — it keeps joinRoom() pending, shows room.status === 'starting', and re-dials three times (2s, 4s, 8s) before failing for real. Distinct from E_STARTING, which the router sends on a connection it is holding open for you; this one ends the attempt and asks you back. Also sent to a mustExist join to a relay room while the relay store cannot be read, so a store outage is never reported as a room that does not exist. A client older than this code sees an unknown fatal error and stops, which is what every version did before it existed |
E_PLACEMENT | 33 | yes | Your project’s region has no server with room for another machine. The message names the region. The socket closes with code 1013 (try again later) and the client does not retry this join on its own, because nothing a player does can fix it: the operator adds a server to the region or raises a server’s VM cap. Until then, joins for a project whose machine is already up keep working; only projects that need a machine started are refused |
E_USAGE_CAP | 28 | yes | Your project reached a usage cap for the current billing period, and this join is new work. On the free tier a cap is a hard wall: new joins, new rooms and new writes are refused, while players already connected keep playing to the end of their session. Read the message for which meter it was. Add a card on the billing page to turn the wall into an alert, wait for the period to reset on the 1st, or raise the ceiling if you set one yourself |
E_ASSERTION_UNVERIFIABLE | 29 | yes | You joined with identity: true, and the server holding your room has not been handed the platform verification key yet. This is a delivery gap, not a bad credential: keys reach a tenant the moment its machine comes up, so the fix is to retry in a second or two. It is a distinct code from E_TOKEN_INVALID precisely so you can tell “your token is wrong” from “this server is not ready to check it”. If it persists past a few seconds, the box is not talking to the control plane, which is ours to fix |
E_ROOM_DELETED | 34 | yes | Somebody with a rooms API credential deleted this room, and its stored state is gone. Not a kick and not a normal close: the room you were in no longer exists, so reconnecting would start an empty one rather than rejoining. The client stops for good rather than auto-resuming, which is what keeps a deleted room deleted instead of having the players who were in it immediately recreate it. The socket closes with code 4291. If this was not you, someone on your team ran irtio rooms delete --force or a script holding one of your project’s API keys did; irtio logs --room <id> names which |
E_IDLE_TIMEOUT | 35 | no | The box dropped this client because the socket stopped answering. The server pings every 20 seconds and drops a session that has missed two pongs, so about a minute of silence ends it. Nothing was lost and the room is still there: reconnect. It has its own code so that an idle drop is distinguishable from a lost network or a crashed box, which a bare 1006 close is not. The socket closes with code 4408 |
E_EGRESS_WALL | 31 | yes | The project has used its egress allowance for the period and the box stopped forwarding. Distinct from E_USAGE_CAP, which is the polite refusal of new work at a cap evaluated up to a minute ago; this is the box stopping traffic locally the moment its leased budget ran out. Add a card, or wait for the period to reset. The socket closes with code 4290 |
E_TIER_LIMIT | 32 | yes | A free-tier shape limit: the relay client cap, or the concurrent awake-rooms cap. The message names which limit and which tier. Adding a card raises it |
E_ROOM_MOVED | 41 | no | The room was placed on another machine, and this one stood it down. Routine on a healthy platform and nothing is wrong: look the room up again and reconnect, which lands you on the successor. The successor wakes from the room’s last save; changes since that save can be lost. The socket closes with code 4292 |
E_ROOM_UNSAVABLE | 42 | yes | The machine holding this room can reach neither the control plane nor storage, so it stopped the room rather than keep taking input it could not save. Final: the client does not reconnect, because retrying against the one machine that has just proven it can reach nothing produces a reconnect storm and no room. Look the room up again later. The socket closes with code 4293 |
| E_STATE_CAP | 36 | no | A relay room with state is at its 512 KB state cap, and this write would have grown it. The write was refused; nothing was dropped, evicted or truncated, every other client is unaffected, and the room keeps serving. Read the room’s state size before you reach it: state that is the current situation fits the cap, and state that is a world or a history does not. A move log, a chat history or a growing canvas belongs in storage rather than in synced state; a world that streams by interest needs a room file |
| E_RECORD_CAP | 37 | no | The collection named in the message is at its 1024-record cap, so the add was refused. Remove records you no longer need, or deploy a room file if the game genuinely holds more than a thousand of something |
| E_WRITE_RATE | 38 | no | You sent more than 35 writes in a second on a relay room with state. The extra writes were not applied and the connection stayed open. One write per tick plus a buffer is the budget; batch your changes instead of writing per input event, and let room.flush() run once per frame rather than in a loop |
Faults
| Code | Number | Fatal | What it means and what to do |
|---|---|---|---|
E_INTERNAL | 17 | sometimes | Something inside irtio failed. Deliberately vague on the wire; the detail is in irtio logs at the same moment. An RPC handler that throws reaches its caller as E_INTERNAL: rpc <name>. The client also uses this code locally for a frame it could not decode or apply, always non-fatal, because the stream may recover |
E_BAD_FRAME | 18 | yes | A frame that could not be decoded, or arrived out of order (a write before the session was joined). You should never see this from @irtio/client. It means a hand-written client (for example a HELLO whose name or role is over 32 UTF-8 bytes), a proxy mangling binary frames, or genuine corruption |
Player storage
These never reach a client. They reject the promise room.kv.get, set or delete returned,
inside your room code, and the room keeps running. A limit is something to handle, not something to
fall over on. They have no numeric code because nothing about them crosses the client socket.
| Code | What it means and what to do |
|---|---|
E_KV_VALUE_TOO_LARGE | A value is over 16 KiB of UTF-8. Store less, or store a reference (an object key, an id) instead of the thing itself |
E_KV_TOO_MANY_KEYS | One player may hold 128 distinct keys per project. Consolidate into one JSON value, or delete what you no longer read. Overwriting a key the player already has is never refused by this limit, so a player at the cap can still shrink their own data |
E_KV_BAD_KEY | The key is empty, over 256 bytes, contains control characters, or is exactly . or ... Those two are path segments to every URL parser, so they cannot travel as a key. Dots inside a longer key (../save, a..b), / and @ are legal, because a key is never used as a storage path |
E_KV_BAD_PLAYER | Same rules, for the player id, including a player id of exactly . or ... Usually an empty ctx.playerId reaching room.kv, which means the room called it before a join |
E_KV_PROJECT_FULL | The whole project hit its row cap of one million. This is a conversation rather than a code change |
E_KV_FORBIDDEN | A request tried to reach another project’s rows. Unreachable in normal operation, since the project comes from the tenant’s own credential and never from the request. Seeing it means something between the room and the gateway is misconfigured. Also answered to a write for a player who has deleted their account: nothing may be stored under their id again |
E_USAGE_CAP | The project is at its storage cap, so this write was refused (HTTP 507 from the storage gateway). Nothing is deleted and reads still serve, so a room can read what it saved and room.kv.delete still works. Delete some saved data, raise the ceiling, or add a card |
E_RATE_LIMITED | The project as a whole went over its player-storage rate: 12,000 reads a minute with a burst of 3,000, or 6,000 writes and deletes a minute with a burst of 2,000. Counted across every room of the project, so a busy game that saves on every change can reach it. Write back on a timer or at match end instead, and wait before retrying, since the allowance refills over a minute. See player storage |
E_KV_UNAVAILABLE | Either the control plane could not be reached, or this tenant has no player storage behind it at all. irtio dev is the second case, since there is no control plane there. Expect it locally and handle the rejection; in production it is an outage, and retrying later is reasonable |
E_HOST_TIMEOUT | The host never answered a room.save() or room.kv call at all, within 10 seconds. This one is ours, not yours. Seeing it instead of E_KV_UNAVAILABLE means the failure was below the layer that names its own errors |
Leaderboards
These never reach a client either. They reject the promise room.leaderboard.submit returned,
inside your room code, and the room keeps running. There is no client-side submit at all, so
there is no client-side failure to handle.
| Code | What it means and what to do |
|---|---|
E_LB_NOT_IN_ROOM | You submitted a score for a player who is not a client of this room. Pass ctx.playerId, not an id you built yourself. This check runs outside your tenant, so it is a real boundary rather than a lint: it is what makes a board server-authoritative rather than room-authoritative |
E_LB_BAD_SCORE | Scores are whole numbers, in the safe-integer range. A float, a NaN, or a string is refused. If you are tracking a time, submit milliseconds and make the board lower |
E_LB_BAD_BOARD | A board name is 1 to 64 characters of a-z 0-9 . _ -, starting with a letter or digit. It is a public URL segment, which is why the rules are tight |
E_LB_BAD_PLAYER | The player id is empty, over 256 bytes, or contains control characters — the same rules player storage applies. Also answered to a submit for a player who has deleted their account |
E_LB_PROJECT_FULL | The project hit its row cap of one million board rows. A conversation rather than a code change |
E_LB_UNAVAILABLE | Either the control plane could not be reached, or this tenant has no leaderboards behind it at all. irtio dev is the second case, since there is no control plane there. Expect it locally and handle the rejection; in production it is an outage, and a lost score is better than a wedged room, which is why the call rejects rather than retrying forever |
E_LB_BAD_CURSOR | The ?cursor= you passed was not made for this board, direction, period and bucket. A cursor names a position in one ranking, so one from last week or from another cohort is refused rather than paged. Start again from the first page |
E_LB_BAD_PERIOD | The ?period= you passed is not shaped like a period key. The four shapes are a day (2026-09-03), an ISO week (2026-W36), a month (2026-09) and a season name |
E_LB_BAD_BUCKET | A bucket name is 1 to 64 characters of a-z 0-9 . _ -, starting with a letter or digit. That is the rule board names follow, and for the same reason: it is a public parameter and it lands in a primary key |
E_LB_BUCKET_REQUIRED | This board declares buckets: true, so a submit has to name one and a read has to pass ?bucket=. There is deliberately no merged read across buckets: keep an unbucketed board beside the bucketed one if you want both views |
E_LB_NO_BUCKETS | You named a bucket on a board that declares none. Refused rather than ignored, because a bucket that was quietly dropped would put a cohort’s scores on the wrong ranking. Set buckets: true on the board, or leave the bucket out |
E_CHAT_TOO_LONG | One chat line is at most 512 bytes of UTF-8. The message names the cap and the size you sent. Refused whole and never trimmed: a trimmed line puts words in a player’s mouth. The connection stays open |
E_CHAT_RATE | You are sending chat faster than one line a second (five in a burst). The line is not sent, the connection stays open, and you are told once rather than once per refused line |
Quick-match and identity (HTTP)
These come back from the control plane over HTTPS, before any socket exists, so they arrive as
a JSON body with a code and a message rather than on the error event.
| Code | Status | What it means and what to do |
|---|---|---|
E_NO_MATCH | 200 | Not an HTTP error at all: POST /match answered {"status":"timeout"} and the client SDK turned it into this. The queue did not fill before the deadline. The body says how many were waiting, which is the useful part: 1 of 2 means nobody else came. Queue again, or offer a room code instead |
E_QUEUE_UNKNOWN | 404 | No queue by that name on this project. A project that has configured no queues has exactly one, default, of two players; once it configures any, default is no longer implicit. Set them with PATCH /v1/projects/:id |
E_MATCH_DUPLICATE | 409 | This identity queued again while an earlier request was still waiting, so the earlier one was retired to make room for the newer one. The usual cause is a page reload. Nothing is wrong; the surviving request is the one to wait on |
E_IDENTITY_INVALID | 401 | The identity token presented is not one this control plane knows. Deliberately not distinguished from expired or revoked. Mint a new one; the SDK does this for you once, automatically |
E_RATE_LIMITED | 429 | Too many requests to a public control-plane surface. The message says whether it was your address or the project as a whole, and retry-after says how long to wait. The limits page has the numbers |
E_PARTY_UNKNOWN | 404 | The party code is not valid. The same answer for a code that never existed, one that has expired, one from another game, and a live one you are not a member of, so a code cannot be guessed at by comparing the answers. Ask for a new code |
E_PARTY_FULL | 409 | The party already holds everyone it was made for. You reach this only holding a live code, so it names the reason. Make a bigger party, or wait for someone to leave |
E_PARTY_TOO_BIG | 400 | The party has more members than the queue holds. The message names both numbers. Pick a bigger queue, or split the party |
E_PARTY_BAD_SIZE | 400 | A party size has to be a whole number from 2 to 64, the largest queue a project can declare |
E_PRESENCE_TOO_MANY | 400 | A presence query names at most 64 player ids. Refused rather than answered short, so a partial answer is never mistaken for the whole one. Split it and ask twice |
E_PRESENCE_NO_SUBJECTS | 400 | A presence query has to name at least one player id in subjects |
E_PRESENCE_BAD_WAIT | 400 | wait has to be a number of milliseconds. A wait longer than the maximum is clamped rather than refused; this is for a value that is not a number at all |
E_PRESENCE_TOO_MANY_WATCHES | 429 | This address already has four presence watches open. One watch covers 64 ids, so one is usually enough. Stop a watch before starting another |
E_PRESENCE_WATCHES_BUSY | 503 | The control plane is holding every presence watch it will hold, or this project is holding its share of them (64). Not your doing: read without wait, which costs one query, and try a watch again shortly |
E_LAST_DEVICE | 409 | identity.revokeDevice() (DELETE /v1/account/devices/:id) named the account’s only device. Revoking it would leave an account nobody can reach or delete. To remove the last device, delete the account with identity.deleteAccount() |
Client-local
E_CONNECT_FAILED is not a protocol code. The connection failed or closed before the room was
joined, so the server never got to name a reason. joinRoom and joinRelay reject with a message
in one of these two shapes:
E_CONNECT_FAILED: cannot reach <url>: <reason> (the room was never joined)
E_CONNECT_FAILED: <url> closed the connection before the room was joined (code 1006) Check the URL in the message. If it names your deployment, the tenant could not start, and irtio logs will say why. If it is a localhost URL, either irtio dev is not running, or you
are in Node and expected production, in which case set IRT_URL.
A client that resolved its endpoint from the localhost default (step 3 of endpoint resolution) does not fail on 7070 alone: it
probes 7171, 7272, 7373 and 7474 as well, because irtio dev binds those in turn when it cannot
take 7070. The message names every port tried:
E_CONNECT_FAILED: cannot reach ws://localhost:7474: ... — no irtio dev server answered on any of
7070, 7171, 7272, 7373, 7474. irtio dev prints the port it bound. Pass it as the url option, set window.IRT_URL = 'ws://localhost:<port>' before your bundle runs, or start the dev server with irtio dev --port 7070 to pin the port and fail loudly when it is unavailable.
A close before the join deliberately does not trigger reconnection. A join that never succeeded should fail rather than retry forever.
Deploy
| Message | What it means and what to do |
|---|---|
not logged in | deploy, logs, rooms, whoami and migrate create need credentials. Run irtio login |
E_BREAKING_SCHEMA / deploy refused: N breaking changes | Your schema changed in a way existing snapshots cannot be read under. Every change is listed with its reason. Run irtio migrate create <name>, write the transform, then irtio deploy --allow-breaking. Additive changes (a new field with .default(...) or .opt) need none of this |
--allow-breaking needs a migration for v<n> | You passed --allow-breaking with no irtio/migrations/<n>_*.ts. Deploy only attaches the file numbered for the version it is creating. If irtio migrate create guessed a different number (it does when it cannot reach the control plane, and says so), rename the file; the refusal names any migration file no deployment has used |
irtio dev: no room file found | No --room, and none of irtio/room.ts, irtio/room.js, room.ts exists. Run irtio init, or pass --room path/to/room.ts |
<path>/irtio.json is not valid JSON | The file did not parse. See the irtio.json reference for its keys |
no config file at <path> | -c/--config named a file that is not there. It is never silently swapped for irtio.json, because that would deploy the wrong project on a typo |
E_USAGE_CAP | The project is at its storage cap, so the deploy’s bundle or static upload was refused (HTTP 507). Existing deployments keep serving and a rollback still works, because neither writes anything new. irtio usage shows where the project stands; delete unused saves, raise the ceiling, or add a card |
no directory to upload — pass --static <dir> or add "static" to irtio.json | deploy --static had no directory from either source. See static |
E_CONFLICT / a static activate for this project is already running | Another irtio deploy --static for the same project was putting its site live at the same moment, usually two CI jobs or two terminals (HTTP 409). This deploy uploaded its files but did not put them live. Wait for the other deploy to finish, then run yours again |
HOLE bot <n> cheated and drew 0 corrections | Not a failure, and an important warning. irtio simulate --cheat (or --cheat-bot <n>) sent deliberately illegal writes and your room took all of them. Add rules to validate |
See deploying and simulated players.
WebSocket close codes
A few refusals also get their own close code, so a client can act on onclose before it has
parsed a frame. Everything else closes with 1008 (policy) or 1011 (internal error).
| Close code | Error | Reconnect? |
|---|---|---|
| 1013 | E_PLACEMENT | No. The region has no server with room for another machine, and nothing the client does changes that |
| 4290 | E_EGRESS_WALL | No. The project is out of data-out budget for the period |
| 4291 | E_ROOM_DELETED | No. Reconnecting would create an empty room rather than rejoin the deleted one |
| 4292 | E_ROOM_MOVED | Yes. Look the room up again; the lookup lands you on the machine now serving it |
| 4293 | E_ROOM_UNSAVABLE | No. The machine can reach neither irtio nor storage, so retrying against it produces nothing |
| 4408 | E_IDLE_TIMEOUT | Yes. The socket stopped answering keepalives; nothing was lost |