QwikDev / QwikDev/qwik

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

Open
#8,882 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

V2
Dominant language
TypeScript
Stars
22.1k
Forks
1.4k
Avg merge
1d 10h
Merged PRs (30d)
52

Description

[!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?

  • Feature / enhancement — follow-up to the ErrorBoundary work in #8745 and the v2 error-model rework.

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by locating the core error exports and the router fetch layers for loader data, server$ client calls, and batched transport. Review the listed Router unit and E2E cases first, including AbortError, HTTP responses, prefetch, coalescing, offline navigation, refresh, and server$ behavior. Done means transport-only client failures produce NetworkError while cancellation, responses, prefetch, and server-side failures retain their specified paths.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
frontend, testing-qa
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.