Documentation/frontend/Error handling
In this section

Error handling

Remote failures use TitanError and a stable numeric code. Binary RPC can restore a registered constructor; HTTP clients do not promise the original server-side subclass. Branch on code, not a constructor name or message.

TitanError

TitanError and ErrorCode come from the /errors subpath, not the package root. The root exports the transport-level errors (NetronError, ConnectionError, TimeoutError, …) — the ones raised by the client itself — while /errors carries what the server sent, including ErrorCode, the Errors factory and toTitanError.

import { TitanError, ErrorCode } from '@omnitron-dev/netron-browser/errors';

try {
  await users.findById('missing');
} catch (e) {
  if (!(e instanceof TitanError)) throw e;

  switch (e.code) {
    case ErrorCode.NOT_FOUND:         return null;
    case ErrorCode.UNAUTHORIZED:      return reauth();
    case ErrorCode.FORBIDDEN:         return showAccessDenied();
    case ErrorCode.TOO_MANY_REQUESTS: return scheduleRetry(e);
    case ErrorCode.VALIDATION_ERROR:  return showFieldErrors(e.details?.errors);
    case ErrorCode.SERVICE_UNAVAILABLE: return showOutage();
    case ErrorCode.REQUEST_TIMEOUT:   return showTimeout();
    default:                          throw e;
  }
}

The full ErrorCode enum mirrors HTTP status codes — see Titan / Errors catalog for the complete reference.

HTTP error contract

Node and browser HTTP clients share the decoder in netron-protocol. Peer, connection and per-request-hint paths preserve numeric codes, decimal code strings and shared symbolic codes such as FORBIDDEN. Application-specific symbols stay in details.errorCode; their numeric category comes from the HTTP status. Malformed codes fall back to a failure category, never NaN or a success status. X-Request-Id, X-Correlation-Id, X-Trace-Id and X-Span-Id remain available on the error for diagnostics, including authentication and rate-limit failures.

A permission refusal remains 403; an authority outage remains 503. This distinction controls both the message shown to an operator and the decision an agent can make. Do not repeat a denied mutation or interpret a timeout as proof of cancellation. The request deadline includes reading the body, not only receiving its headers; one expired request does not cancel another request on the same connection.

Transport-level errors

Errors that don't come from the server's TitanError system:

ClassWhenRecoverable?
NetworkErrorDNS failure, connection refused, browser offline✓ (often)
TimeoutErrorRequest exceeded timeout⚠ (sometimes)
ConnectionErrorWS upgrade failed; HTTP cert invalid✗
TransportErrorGeneric transport-layer failure⚠

These are exported from the package root alongside the other Netron error classes (NetronError, ProtocolError, ServiceError, MethodNotFoundError, InvalidArgumentsError, SerializationError).

There is no CircuitOpenError class. When the fluent HTTP interface's circuit breaker is open it throws a TitanError with code: ErrorCode.SERVICE_UNAVAILABLE (message "Circuit breaker is open") — check the code, not a class.

import { NetworkError, TimeoutError, ConnectionError }
  from '@omnitron-dev/netron-browser';
import { TitanError, ErrorCode } from '@omnitron-dev/netron-browser/errors';

try {
  await users.findById(id);
} catch (e) {
  if (e instanceof NetworkError) {
    return showOfflineBanner();
  }
  if (e instanceof TimeoutError) {
    return showSlowNetworkWarning();
  }
  // Circuit breaker open (fluent HTTP interface):
  if (e instanceof TitanError && e.code === ErrorCode.SERVICE_UNAVAILABLE) {
    return showServiceUnavailable();
  }
  throw e;
}

Errors in useQuery / useMutation

Errors land on error:

const { data, error, isError } = users.getUser.useQuery([id]);

if (isError) {
  if (error instanceof TitanError && error.code === ErrorCode.NOT_FOUND) {
    return <NotFoundCard />;
  }
  return <ErrorCard error={error} onRetry={() => refetch()} />;
}

For mutations:

const invite = users.invite.useMutation({
  onError: (error) => {
    if (error instanceof TitanError && error.code === ErrorCode.VALIDATION_ERROR) {
      for (const fieldErr of error.details?.errors ?? []) {
        form.setError(fieldErr.path, { type: 'server', message: fieldErr.message });
      }
    } else {
      toast.error('Could not send invite');
    }
  },
});

Server-side validation errors

When the server's @Validate(schema) rejects, the error carries field-level details:

{
  code: ErrorCode.VALIDATION_ERROR,
  message: 'Validation failed',
  details: {
    errors: [
      { path: 'email',    message: 'Invalid email format',  expected: 'email', received: 'foo' },
      { path: 'password', message: 'Must be ≥ 8 characters' },
    ],
  },
}

Map these onto form fields:

catch (e) {
  if (e instanceof TitanError && e.code === ErrorCode.VALIDATION_ERROR) {
    for (const err of e.details?.errors ?? []) {
      form.setError(err.path as any, { type: 'server', message: err.message });
    }
    return;
  }
  form.setError('root', { message: 'Something went wrong' });
}

The error appears under the matching <Field> exactly like client-side errors — no special UI path.

Retry classification

The fluent HTTP interface's retry (RetryOptions, via .retry({ ... })) ships a default shouldRetry that classifies failures by whether the request provably never reached the server vs. an ambiguous failure where it may have been processed. Ambiguous failures are retried only when you mark the call idempotent: true, so a mutation is never silently re-executed:

FailureRetried by default?Why
Connection refused / DNS / unreachable✓Request provably never reached the server
429 TOO_MANY_REQUESTS✓Server rejected without processing; honours Retry-After
Connection reset / timeoutonly if idempotentAmbiguous — may have been processed
5xx (server) / 408only if idempotentAmbiguous
SERVICE_UNAVAILABLE / REQUEST_TIMEOUT / INTERNAL_ERRORonly if idempotentAmbiguous
Other 4xx (400/403/404/409/422)✗Deterministic client error — won't change
TypeError / ReferenceError✗Programming bug
401 UNAUTHORIZEDspecialAn auth-error middleware refreshes the token and re-invokes once (separate from retry)

A custom shouldRetry overrides the gate entirely — you own the decision (signature (error, attempt) => boolean | Promise<boolean>):

const users = await peer.queryFluentInterface<UserService>('users@1.0.0');

await users
  .retry({
    attempts: 3,
    shouldRetry: (error, attempt) => {
      // Cap retries on timeout (it may have succeeded server-side):
      if (error instanceof TimeoutError && attempt >= 2) return false;
      // Retry network failures and 5xx:
      return error instanceof NetworkError ||
             (error instanceof TitanError && error.code >= 500);
    },
  })
  .api.findById(id);

Circuit breaker integration

The circuit breaker is not a middleware — it lives on a RetryManager (the fluent HTTP interface) configured with CircuitBreakerOptions:

import { RetryManager } from '@omnitron-dev/netron-browser';

const retryManager = new RetryManager({
  circuitBreaker: {
    threshold:    5,         // open after 5 failures…
    windowTime:   10_000,    // …within a 10s window
    cooldownTime: 30_000,    // try half-open after 30s
  },
});

peer.setRetryManager(retryManager);

const users = await peer.queryFluentInterface<UserService>('users@1.0.0');
await users.retry({ attempts: 3 }).api.findById(id);

The breaker is checked before each attempt: while it is open, calls fail fast — the RetryManager throws a TitanError with code: ErrorCode.SERVICE_UNAVAILABLE (message "Circuit breaker is open") without attempting the request. After cooldownTime one probe runs; success closes the breaker. There is no CircuitOpenError class — match on the code.

Error UI patterns

Inline form errors (mutations)

FormAlert takes the message as children — there is no error prop. The distinction matters more than it looks: the component renders with empty children rather than unmounting (so a conditional slot causes no layout shift), and it announces itself with role="alert", aria-live="assertive" and aria-atomic="true". Passing the error object to a prop that does not exist therefore produces an alert that a screen reader announces as empty — silence where the failure should be.

<form>
  {form.formState.errors.root && (
    <FormAlert title="Couldn't sign in">
      {form.formState.errors.root.message}
    </FormAlert>
  )}
  <Field name="email" />
  <Field name="password" />
</form>

Page-level error (query failure)

if (error instanceof TitanError && error.code === ErrorCode.NOT_FOUND) {
  return <EmptyContent
    illustration="error-404"
    title="Not found"
    description="The thing you're looking for doesn't exist."
    action={<Button onClick={() => navigate('/')}>Home</Button>}
  />;
}

if (error) {
  return <EmptyContent
    illustration="error-500"
    title="Something broke"
    description={error.message}
    action={<Button onClick={() => refetch()}>Try again</Button>}
  />;
}

Toast (background failure)

const save = useMutation({
  onError: (error) => {
    toast.error(`Save failed: ${error instanceof TitanError ? error.message : 'unknown'}`);
  },
});

Toasts for background events (autosave failed, webhook errored); page-level cards for primary content; inline alerts for form submissions.

Error boundaries

For synchronous render errors (rare with proper data fetching):

import { ErrorBoundary } from '@omnitron-dev/prism';

<ErrorBoundary
  fallback={(error, reset) => (
    <EmptyContent
      illustration="error-500"
      title="Something broke"
      description={error.message}
      action={<Button onClick={reset}>Reload section</Button>}
    />
  )}
  onError={(error, info) => reportToSentry(error, info)}
>
  <SuspectComponent />
</ErrorBoundary>

Boundary catches render-time errors only — async failures go through the standard useQuery error flow.

Global error handling

client.on('error', (error, ctx) => {
  if (error instanceof TitanError && error.code === ErrorCode.UNAUTHORIZED) {
    auth.clear();
    navigate('/sign-in');
    return;
  }
  reportToSentry(error, { service: ctx.service, method: ctx.method });
});

Use sparingly — most errors should be handled at the call site where the context is richer.

Reporting to Sentry

import {
  type MiddlewareFunction,
  MiddlewareStage,
} from '@omnitron-dev/netron-browser';

const sentryMiddleware: MiddlewareFunction = async (ctx, next) => {
  Sentry.withScope((scope) => {
    scope.setTag('rpc.service', ctx.service);
    scope.setTag('rpc.method',  ctx.method);
    scope.setContext('rpc',     { args: ctx.args });
    Sentry.captureException(ctx.error);   // populated in the error stage
  });
  await next();        // re-throw
};

// Register on the error stage:
client.use(sentryMiddleware, { name: 'sentry', priority: 200 }, MiddlewareStage.ERROR);

Filter noise — don't report NOT_FOUND or UNAUTHORIZED, they're not bugs.

Anti-patterns

  • Catching Error generically. Loses the typed identity; always check instanceof TitanError.

  • Mapping every error to "Something went wrong". Users get no actionable info; check code and route accordingly.

  • Retry on 4xx. Won't help; logs the user out of all patience.

  • Logging full TitanError details client-side. Server details may be sensitive; log code + message.

  • Throwing custom error classes without registering them. Wire-format only preserves classes the receiver knows about; for app-specific errors, extend TitanError and ensure both sides import the same definition.

  • No error UI for useQuery. Users see infinite spinner on backend failure; always handle isError.

Best practices

  • Switch on code, not message. Messages are human-readable; codes are stable.

  • Show field-level errors next to fields; form-level errors above the form; transient/background errors in toasts.

  • Wire SentryMiddleware once for global reporting; let call sites handle UI.

  • Pair retry with circuit breaker. Configure the RetryManager with circuitBreaker so retries don't amplify failure load on a sick backend.

  • Mark idempotent calls idempotent: true. The default fluent retry only retries ambiguous failures (5xx / reset / timeout) for idempotent calls — mutations stay safe.

  • Honour Retry-After on TOO_MANY_REQUESTS — the fluent RetryManager reads it automatically; do it manually if you write your own retry loop.

See also