Skip to content

feat: typed NetworkError for branchable connectivity failures (ErrorBoundary follow-up) #8882

Description

@maiieul

Note

Updated 2026-08-04 to match the converged v2 error-API design: PublicError is removed from the model; server-origin failures are always redacted, client-origin errors never are. The original motivation ("connectivity failures get redacted") no longer applies — the surviving motivation is that transport failures need a type. The class below is now standalone.

What is it?

Context: the settled error model

  • Server-origin failures never cross the wire. The client receives a framework-authored generic error (+ digest); the original stays server-side (logs, onError). No exceptions — server-authored displayable content exists only as outcomes (httpError(), invalid(), redirects) or typed returns.
  • Client-origin errors are never redacted. Their messages come from code already in the browser, so rendering them is leak-safe by construction.
  • Failures land in .error (guarded) or the closest <ErrorBoundary> (unguarded).

Problem

A user goes offline and SPA-navigates. The loader data fetch rejects at the transport level, and that raw rejection is what lands in the failure channel. Raw is leak-safe, but it is not an offline UX:

  • The message is browser-divergent trivia: Failed to fetch (Chrome), Load failed (Safari), NetworkError when attempting to fetch resource. (Firefox). Nothing to branch on, nothing a user should read.
  • The useful affordance (offline notice, retry, "showing cached data") needs a type, not a string. (Raised by @wmertens.)

And the fix cannot be server-side leniency, because the redaction membrane must stay absolute. Unexpected err.message values in production routinely name infrastructure — and they fire exactly during incidents (credential rotation, network partition) when no app code changed:

Source Real production err.message
pg (auth failure, 28P01) password authentication failed for user "admin"
Node net layer connect ECONNREFUSED 10.0.3.7:5432
Node DNS getaddrinfo ENOTFOUND db.internal.corp
mysql2 Access denied for user 'admin'@'10.0.2.14' (using password: YES)
Prisma P1000 Authentication failed against database server at `10.0.3.7`, the provided database credentials for `admin` are not valid
Prisma P1001 Can't reach database server at `10.0.3.7`:`5432
AWS SDK (IAM) User: arn:aws:iam::123456789012:user/app-server is not authorized to perform: s3:GetObject on resource: …
undici / Node 18+ fetch message is fetch failed, but err.cause carries connect ECONNREFUSED 10.0.3.7:443

Connectivity failures were never "unexpected" in the first place: the framework can prove what they are, because the framework owns the fetch. No inference, no consent problem.

Proposal: a framework-constructed NetworkError

// @qwik.dev/core — vocabulary, not machinery; usable router-less
export class NetworkError extends Error {
  constructor() {
    super('Could not reach the server'); // framework-authored message
  }
}

Standalone class — no parent, no serdes, no special cases. It rides the ordinary failure channels (.error / boundary); instanceof is the entire API.

// router fetch layer (loader data fetches, server$ client stub) — sketch
try {
  response = await fetch(url, { signal, headers });
} catch (e) {
  if ((e as Error)?.name === 'AbortError') throw e; // cancellation stays cancellation
  throw new NetworkError();                         // transport-level rejection ONLY
}
// an HTTP response is NOT a network error — the server answered; those paths are unchanged

Rules

  1. Placement: class in core (re-exported from the router for discoverability); wrap sites in the router (loader data fetch layer, server$ client stub, and the batched transport when/if it lands).
  2. Wrap scope: only framework-owned, transport-level rejections (offline, DNS, CORS-opaque, timeout). AbortError stays cancellation. Any HTTP response — error envelopes, 422, 500-no-detail — keeps its existing path.
  3. Prefetch failures stay silent: prefetching is speculative; a failed prefetch must not construct a NetworkError, populate .error, or touch a boundary. The nav-time fetch retries naturally. (Otherwise walking through a tunnel makes hovered links light up error UI.)
  4. Semantics = client connectivity only: NetworkError means "this browser could not reach the server". A server-side upstream failure during SSR is a different situation (the user's connection is fine) — docs steer that to throw httpError(503, …) or a typed return. Framework-constructed instances are client-side only and never cross the wire, so instanceof NetworkError just works with no serializer support; a copy constructed server-side is a failure like any other throw → redacted.
  5. Fetch-layer hygiene: a rejected coalesced fetch rejects all registered consumers with the same NetworkError, once; a transport failure teaches any transport-level caching/hints nothing (no response, no headers).
  6. App-owned fetches (e.g. inside async computeds) get the one-line recipe: catch the transport rejection, throw new NetworkError() — same class, same fallbacks.

What it enables

First paint while offline — the boundary displays it, and the fallback can finally branch:

<ErrorBoundary
  fallback$={(err) => {
    if (err instanceof NetworkError) {
      return <div>You appear to be offline. <button onClick$={retry}>Retry</button></div>;
    }
    // server failures arrive redacted — render your own copy (+ digest for support)
    return <p>Something went wrong.</p>;
  }}
>
  <Suspense fallback={<Skeleton />}>
    <Orders />
  </Suspense>
</ErrorBoundary>

Failed background refresh — with the retention semantics (a failed revalidation keeps the held value and surfaces on .error; reading .error is the guard that unlocks .value), the offline-first story falls out for free:

const orders = useOrders(); // e.g. { poll: 30_000 }

return (
  <div>
    {orders.error instanceof NetworkError && <Badge>Offline — showing cached data</Badge>}
    <ul>{orders.value.map((o) => <li key={o.id}>{o.title}</li>)}</ul>
  </div>
);

Task-side observation (toast, no content replacement):

useTask$(({ track }) => {
  if (track(() => orders.error) instanceof NetworkError) {
    toasts.push('Connection lost — data may be stale');
  }
});

server$ — imperative, the caller decides:

try {
  await save(draft.value);
} catch (err) {
  if (err instanceof NetworkError) { queue.push(draft.value); return; } // offline queue
  throw err; // anything else → boundary (server failures arrive redacted)
}

(Modeled server$ failures are typed returns under the settled design, so they never reach this catch.)

Tests

  • Router unit: transport rejection → NetworkError; AbortError excluded; HTTP responses excluded; prefetch failure is silent; coalesced rejection fans out once.
  • E2E: offline SPA nav → boundary shows the instanceof NetworkError branch; offline background refresh → retained value + .error badge, boundary never fires; offline server$ call → try/catch receives an instanceof NetworkError.

Scope

Follow-up to #8745 — not part of the EB PR. Depends on the error-model rework landing (server-side redaction membrane + .error/boundary routing). PublicError removal is tracked in the EB workstream.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions