MESH ONLINE│CODENAME: —
v0.46

Errors

Every rejection is a LeafError subclass mirroring net_leaf::error. .message is verbatim the Rust Display text that crossed the boundary; .kind is a flat, stable discriminant a caller branches on.

.kindClassRust variant
wireWireErrorLeafError::Wire
sessionSessionErrorLeafError::Session
control-planeControlPlaneErrorLeafError::ControlPlane
identityIdentityErrorLeafError::Identity
not-leaderNotLeaderErrorLeafError::NotLeader
ice-timeoutRtcErrorRtcError::IceTimeout
udp-blockedRtcErrorRtcError::UdpBlocked
channel-closedRtcErrorRtcError::ChannelClosed
rtc-unsupportedRtcErrorRtcError::Unsupported
rpc-refusedRpcErrorRpcError::Refused
rpc-timeoutRpcErrorRpcError::Timeout
session-lostRpcErrorRpcError::SessionLost
leader-lostRpcErrorRpcError::LeaderLost
rpc-indeterminateRpcErrorRpcError::Indeterminate
rpc-malformedRpcErrorRpcError::Malformed
ice-server-conflictIceServerConflictErrorLeafError::IceServerConflictsWithPeer
unknownUnknownLeafErrornothing

Calls behind an organization boundary end with their own terminal kinds, all subclasses of OrgStreamError: org-admission-denied (OrgAdmissionDeniedError, with a coarse reason: denied, not-supported or unavailable), org-revoked (OrgRevokedError, a kind of denial), org-timeout, org-cancelled, org-leader-lost, org-session-lost, org-indeterminate, org-refused, org-internal and org-malformed, each with the matching Org…Error class.

An unrecognised message becomes UnknownLeafError rather than being folded into a near neighbour. Mis-typing a failure is the exact mistake this taxonomy exists to prevent, so the package does not guess.

Three failures that look alike

"Admission is not carriage" is the distinction to hold on to:

SituationKindBecause
The anchor answered and said noidentityA refused enrollment — a replayed or expired invite, or a request over the size bound
The anchor never answered at allrpc-timeoutNot a refusal, and not carriage
Carriage failed — offer, trickle, announcement publish, signalcontrol-planeThe message never got where it was going

That three-way split, plus node.isEnrolled(), is how a page tells "my invite was already redeemed" from "the anchor is slow" from "the anchor is broken" — all of which otherwise look like a call that never returned.

Two RPC failures are surfaced and never retried silently: session-lost and leader-lost. That is the rule for a call whose leader or session went away, and the caller is the one who gets to decide. rpc-indeterminate follows the same rule for a different reason — see Session.

udp-blocked: the correction

An ICE timeout is not evidence that UDP is blocked. An anchor that is down, misconfigured or saturated produces exactly the same symptom. So an ICE failure surfaces as ice-timeout, and only two observations together may narrow it:

  1. the HTTPS bootstrap to that anchor succeeded — it is up and addressable;
  2. a STUN binding to every RTC endpoint that same anchor published went unanswered. A dual-stack anchor publishes one per family (IPv4 and IPv6), and all of them are probed at once, under one deadline.

classifyRtcFailureAll({ bootstrapOk, probes }) is a pure function of those facts and the only path to a udp-blocked error. probeStunBindings(addrs) produces the probes; probeBootstrapReachable() produces the first observation when no connected event has arrived yet. (classifyRtcFailure and probeStunBinding remain for a single endpoint.)

Anything short of every probe unanswered stays ice-timeout:

  • One family answered. UDP works there, so the failure is something else. This is the IPv6-only player behind NAT64: HTTPS reaches the anchor, the IPv4 probe is silent, the IPv6 probe answers. That is not blocked UDP.
  • A probe that did not run (notRun, unsupported, an address that is not one) observed nothing, so it is not evidence.
  • No endpoints at all.

Even when every probe is silent, the claim is the observation, not a proven cause. Blocked UDP looks exactly like a stopped UDP listener, a wrong advertised address, a routing failure or loss, so the message says only what was seen:

▸ text
rtc: no UDP response from the anchor's advertised endpoints: its HTTPS bootstrap
succeeded but STUN bindings to 203.0.113.7:7101, [2001:db8::7]:7101 went unanswered

The kind is udp-blocked, for compatibility. Match on kind (or isUdpBlocked), never on the message. error.failure.evidence.probedAll lists every endpoint probed.

What each probe keys on was measured in headless Chromium, not assumed. The Rust (wasm) probe and the TypeScript probe apply the same rule (probeEventAnswers), held to one shared vector file by a test on each side:

What the engine didOutcomeClassification
A server-reflexive candidate arrivedreflexivestays ice-timeout
icecandidateerror below 700 — a STUN error responsestunErrorstays ice-timeout
icecandidateerror 701, or gathering completed with no reflexive candidateunansweredudp-blocked if the bootstrap succeeded
Nothing at all before the deadlineunansweredudp-blocked if the bootstrap succeeded

Three details shape that table:

  • Host candidates prove nothing and are ignored. They are gathered whatever the network does to UDP, and Chromium hides them behind an mDNS .local name.
  • A STUN error response still proves reachability. An agent refusing an unauthenticated binding means the packet arrived and the reply got home, which is why the rule is a < 700 threshold rather than a list of codes.
  • Against a black-holed address Chromium emits no error event and never completes gathering, so the probe's deadline is load-bearing rather than a safety net.

The probe needs a subject

The addresses come from the connected event's rtcAddrs (every endpoint the anchor published, primary first; rtcAddr is still the primary), or from connect({ anchorRtcAddr }) for a page that already knows one. With neither, there is no evidence and an ICE timeout correctly stays ice-timeout.

▸ typescript
await connect({
  credentialB64,
  anchorRtcAddr: '203.0.113.7:50000',      // if you know it before connecting
  failureTyping: { probeOnIceTimeout: false },  // or switch probing off
});

diagnosticStunUrl(rtcAddr) builds the probe's target, and its name says what it is for. It is not a source of iceServers: for a connection with that anchor, rtc_addr is the ICE peer, and a peer cannot be its own STUN server. ConnectOptions.iceServers defaults to the separate stun_addr the anchor announces, and an entry naming any of this connection's peer endpoints (either family, in any spelling of the address) is refused with ice-server-conflict before any ICE work rather than silently stripped.

The one assumption this rests on is worth naming: the anchor's published rtc_addr must answer an unauthenticated STUN binding request, with a success or an error response. An anchor that silently dropped them would make a healthy anchor look unanswered, so the browser matrix asserts it directly with a healthy-anchor control run.

What a page does with it

▸ typescript
import { isUdpBlocked } from '@net-mesh/browser';
 
try {
  const node = await connect({ credentialB64 });
  // …
} catch (error) {
  if (isUdpBlocked(error)) {
    // Two observations, not one: the anchor answered HTTPS and no advertised
    // UDP endpoint answered. Most often this network blocks UDP; the pair
    // stays routed through the anchor either way.
  }
}

udp-blocked is an explanation, not a dead end: the pair stays routed through the anchor and keeps working. ice-timeout without it means the anchor was unreachable, misconfigured, or slow — and the address was not established.

Store errors are separate

The store has its own StoreError and its own code set, documented in Store. A store refusal is not a LeafError; branch on StoreError.code.

The game helpers' errors

These are not LeafErrors either; each has its own discriminant.

ErrorBranch onValues
CredentialRequestError (requestCredential).kindunknown-game, rate-limited (retry shortly), malformed-request, unreachable, unexpected; .status is the HTTP status when the anchor answered
LobbyError (lobbies).codenot-found, ambiguous (two nodes claim the code), invalid
BorderActionError (handoffs.forward).coderefused (the neighbour said no; the message is its reason), unresolved (no answer: it may have run, never twice)

See Session, Store and World.

Next