Skip to main content

Error handling

Server-side TitanError subclasses arrive on the client as the same class — the wire format preserves constructor name + code. This lets you instanceof-check exactly as you would server-side.

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.

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 / unreachableRequest provably never reached the server
429 TOO_MANY_REQUESTSServer 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 / ReferenceErrorProgramming 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