Handle errors

Catch the one error class unsecure throws, tell a client's mistake from your own by its code, and answer each with the right response.

Whenever unsecure throws, it throws an UnsecureError. Its code is a short label, like MALFORMED, that says what kind of mistake happened, and that's what your code should branch on. The message is written for whoever reads the logs, and its wording can change in any release.

#Before you start

  • Import the class from unsecure/errors, or from unsecure.
  • The verify functions return false for bad client input instead of throwing; trusted and untrusted input lists them.
  • Every other function throws when a value is wrong. Whether a throw is the client's fault depends on where the value came from, not on the code alone.
  • A native TypeError, RangeError or SyntaxError coming out of unsecure is a bug in the library. Report it in the unsecure issue tracker.

#Catch what the client can cause

Put the try around the calls that read request data, catch the one code that data can cause, and rethrow everything else:

handle-errors.ts
import { UnsecureError } from "unsecure/errors";
import { safeJsonParse } from "unsecure/sanitize";
import { base64Parse } from "unsecure/utils";

// Cursor and OrderStore are your application's own types.

/** `GET /orders?cursor=…`, where the cursor is base64url-encoded JSON. */
export async function listOrders(request: Request, store: OrderStore): Promise<Response> {
  const raw = new URL(request.url).searchParams.get("cursor");
  let cursor: Cursor | undefined;
  if (raw !== null) {
    try {
      cursor = safeJsonParse<Cursor>(base64Parse(raw, { alphabet: "base64url" }));
    } catch (error) {
      // MALFORMED is the one code this text can cause. Anything else is a
      // bug on the server, so it goes on to the fallback handler.
      if (!(error instanceof UnsecureError) || error.code !== "MALFORMED") throw error;
    }
    if (typeof cursor?.after !== "string") {
      return Response.json({ error: "Invalid cursor." }, { status: 400 });
    }
  }
  return Response.json(await store.page(cursor));
}

The catch is only as wide as the input it guards:

  • Narrow catch: MALFORMED from these two calls means the client sent a bad cursor. Catch every UnsecureError here instead, and a server bug turns into a 400 that hides it.
  • Shape check: a cursor can decode and parse fine and still have no after. That's the client's mistake too, so it gets the same 400.

#Answer everything else

An error that no route handled came from your code, your configuration or the runtime, so one fallback handler logs it, cause included, and picks the status:

handle-errors.ts
/** The last stop for an error no route handled. */
export function errorResponse(error: unknown, log: (entry: object) => void): Response {
  if (!(error instanceof UnsecureError)) {
    log({ error });
    return Response.json({ error: "Internal error." }, { status: 500 });
  }
  // `cause` holds the runtime's own error, when there is one.
  log({ code: error.code, message: error.message, cause: error.cause });
  switch (error.code) {
    case "PLATFORM": {
      return Response.json({ error: "Try again later." }, { status: 503 });
    }
    default: {
      return Response.json({ error: "Internal error." }, { status: 500 });
    }
  }
}

Keep the default branch: a later minor release can add a code, and a switch without one would let that code through unanswered.

#Which code fires when

Each code names one kind of mistake, and where the value came from tells you whose mistake it is:

CodeWhen it firesUsually whose mistake
INVALID_TYPEA value of the wrong JavaScript type, or nothing where a value is required: null passed to a codec, a password that is not text, a Map reaching sanitizeObjectCopy.Yours, unless the value came straight from a request.
OUT_OF_RANGEThe right type outside its allowed range: an empty secret, digits: 9 for a one-time password, a base32 alphabet of 31 characters, a stored Argon2 hash above limits.Yours: often configuration that did not load.
MALFORMEDText that is not what it claims to be: non-canonical hex, base64 or base32, decoded bytes that are not UTF-8, JSON that safeJsonParse cannot parse, a string that is not a UUIDv7 or an Argon2 hash.The client's when the text came from a request; a data bug when it came from your database.
UNSUPPORTEDA name the library does not know: an algorithm such as "MD5", a returnAs that hash, hmac or hkdf does not offer, an Argon2 variant or version it cannot compute.Yours.
FROZENsanitizeObject cannot delete a dangerous key because the object is frozen or sealed.Yours: use sanitizeObjectCopy.
PLATFORMThe runtime refused an operation the library had already checked: Web Crypto declined, crypto.getRandomValues failed, or Argon2 could not allocate its memory. cause holds the runtime's own error.The runtime's.

#Going further

  • cause: only set when the failure came from outside the library, which means every PLATFORM error and the MALFORMED that safeJsonParse throws. Log it: the message tells you which operation failed, and cause tells you why.
  • Two copies of the package: if another dependency installed its own copy of unsecure, errors from that copy fail instanceof UnsecureError. Code that has to work across copies can check error.name === "UnsecureError" and then read code.
  • Libraries built on unsecure: throw new UnsecureError(code, "yourFunction: …") too, so your callers deal with one class and one set of codes. Pass { cause } when the failure started somewhere else.