DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Seventeen error classes, five of which inherit the right to be shown to a user, and one instanceof ladder two lines long

Nakodo has 17 classes that extend Error. Five of them extend a different one of the 17, and that inheritance is the entire permission system for error messages.

src/lib/errors.ts is 47 lines and starts here:

// An error whose message is written for the user and safe to show as is.
// Anything else is logged and shown as a generic message.
export class UserError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "UserError";
  }
}
Enter fullscreen mode Exit fullscreen mode

No codes, no fields, no i18n key. The class says one thing: a human wrote this sentence for another human. new UserError(...) appears at 24 sites across ten files, and the sentences in them are things like "Delete a campaign to start another" rather than "constraint violation".

Inheritance as a permission

Five classes extend it, which is how a specialised error keeps the right to be read:

export class LimitError extends UserError { ... }     // src/lib/errors.ts
export class ResearchError extends UserError {}       // src/lib/campaigns.ts
export class RateLimitError extends UserError { ... }  // src/lib/ratelimit.ts
export class CrawlError extends UserError { ... }      // src/lib/research/crawl.ts
class TargetingError extends UserError {}              // src/lib/places/targeting.ts
Enter fullscreen mode Exit fullscreen mode

Two of those have no body at all. Their whole purpose is to be a name a catch could use while still being shown, and in fact nothing catches either of them by name today: they are caught by the UserError branch, same as their parent. The other twelve classes do not extend it, so whatever they say ends up in the log and the user gets a fixed sentence. Three of the twelve live in the clients for the services behind discovery and name a provider in their message, so they stay out of this post, as the discovery internals always do.

The ladder that reads it

One function in the whole codebase asks what kind of error it has:

// src/lib/action.ts
export function failure(e: unknown, { context, fallback = GENERIC }: ... = {}): ActionFailure {
  unstable_rethrow(e);
  if (e instanceof LimitError) return { ok: false, error: e.message, upgrade: true };
  if (e instanceof UserError) return { ok: false, error: e.message };
  if (context) console.error(context, e);
  else console.error(e);
  return { ok: false, error: fallback };
}
Enter fullscreen mode Exit fullscreen mode

Three things in four lines.

unstable_rethrow(e) is first because Next signals redirect() and notFound() by throwing. Without it, a Server Action that redirects on success would be caught by its own error handler and turned into "Something went wrong. Try again in a moment." while the navigation never happens. It is the one call in that file I would not have thought of without reading the framework's own docs.

The two instanceof lines are order-dependent, and the comment in errors.ts says so out loud:

// Thrown when an action would go over the plan (src/lib/limits/plan.ts). The
// message is shown to the user next to an upgrade link. Check for it before
// UserError, which it also is.
export class LimitError extends UserError {
  constructor(
    readonly limit: string,
    message: string,
  ) {
    super(message);
    this.name = "LimitError";
  }
}
Enter fullscreen mode Exit fullscreen mode

Swap the two lines and nothing breaks loudly. Every message still reaches the user; the upgrade: true flag just stops being set, so the toast that should carry a "See plans" link quietly stops carrying it. There are six new LimitError sites, five of them in the plan-limit module, and no test asserts the flag survives the ladder. That is the first thing I would fix in this file.

Three different mechanisms for three different questions

The app asks three questions about an error, and answers each one differently on purpose.

"May the user read this?" Inheritance, as above.

"Should this job wait rather than fail?" A concrete class carrying a time:

export class SourceLimitError extends Error {
  constructor(
    message: string,
    readonly provider: string,
    readonly until: Date,
    readonly quiet = false,
  ) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Thrown at 12 sites, checked with instanceof at four, and it is not a UserError because its audience is the queue. A stage that catches one re-queues its jobs for until instead of marking them failed, and quiet is the difference between a line in the run log and routine pacing nobody needs to read. DeferJob in the queue module does the same job for a job that decides for itself to come back later, and that is 10 throws against one instanceof.

"Is a retry worth paying for?" Neither of the above. A duck-typed field:

// An error that would only happen again on a retry, such as the AI running
// out of room for its answer or refusing (AiError in src/lib/ai.ts): its job
// gives up at once rather than pay for the same failure twice more.
const isPermanent = (error: unknown) =>
  typeof error === "object" && error !== null && (error as { permanent?: unknown }).permanent === true;
Enter fullscreen mode Exit fullscreen mode
export class AiError extends Error {
  readonly permanent = true;
  ...
}
Enter fullscreen mode Exit fullscreen mode

AiError has zero instanceof checks anywhere, because the queue deliberately does not want to know the class. Any error from anywhere can opt out of retries by carrying permanent = true, and the module that decides retries never imports the module that decides to refuse. For a cost that is measured in paid model calls, structural beats nominal.

The two functions nobody notices

export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));

// An error as saved with a job, a run or a conversation. A failed query's
// message carries all its parameters, so it's cut short, and NULs would fail
// the save itself.
export function errorText(error: unknown, max = 2000): string {
  return errorMessage(error).replaceAll("\0", "").slice(0, max);
}
Enter fullscreen mode Exit fullscreen mode

Both halves of that comment are load-bearing. The second one is checkable in a minute against any Postgres:

$ psql -c "create table t (x text)" -c "insert into t values ('a' || chr(0) || 'b')"
CREATE TABLE
ERROR:  null character not permitted
Enter fullscreen mode Exit fullscreen mode

And through the driver this app actually uses, with the NUL inside a bound parameter:

driver error: invalid byte sequence for encoding "UTF8": 0x00 | code: 22021
after strip: { len: 2 }
Enter fullscreen mode Exit fullscreen mode

SQLSTATE 22021. A Postgres text column cannot hold a zero byte, a JavaScript string can, and crawled page text and provider error bodies are full of surprises. So the error recorded against a failed job is stripped before the write, because the one thing worse than a job failing is the attempt to record why it failed failing too, inside the same transaction.

The max is not decoration either. It is 2000 by default, 300 where an error is being pushed into a run's notes beside other notes, and 4000 for the one place that saves a stack rather than a message. A postgres error message includes the statement's parameters, which for a batch insert is as long as the batch.

A fourth pattern, which is really a fifth answer

try {
  const matches = await findPlaces(parsed.data);
  return matches.length > 0 ? { ok: true, matches } : fail(`Nothing found for "${parsed.data}". Try a nearby town or the country.`);
} catch (e) {
  if (!(e instanceof GeocodeError)) console.error("Place search failed", e);
  return fail("Place search isn't answering. Try again in a moment.");
}
Enter fullscreen mode Exit fullscreen mode

GeocodeError extends Error {}, empty body, and the instanceof here does not decide what the user sees. The user sees the same sentence either way. It decides whether the failure is worth a line in the log, because a geocoder answering 503 is weather, not news. That is the opposite of UserError: the class carries no message, only an expectation.

What I would change

  • A two-line test on failure(): a LimitError comes back with upgrade: true, a plain UserError without. Right now the ordering of two ifs is the only thing holding a product behaviour up.
  • ResearchError and TargetingError are empty subclasses that nothing catches by name. Either something should, or they should be UserError with a better message.
  • Twelve of the 17 classes are never asked about with instanceof, which makes them documentation that the type system does not read. Worth keeping the ones whose name tells you where you are in a stack trace, worth deleting the rest.

The messages that are allowed out are the user-facing half of this: the plan limits those LimitErrors are about are published on pricing, what the app does between those limits is on how it works, and the public sponsorship cost calculator is a page where every refusal has to be a sentence rather than a code.

Top comments (0)