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 fromunsecure. - The verify functions return
falsefor 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,RangeErrororSyntaxErrorcoming out ofunsecureis 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:
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:
MALFORMEDfrom these two calls means the client sent a bad cursor. Catch everyUnsecureErrorhere 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:
/** 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:
| Code | When it fires | Usually whose mistake |
|---|---|---|
INVALID_TYPE | A 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_RANGE | The 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. |
MALFORMED | Text 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. |
UNSUPPORTED | A 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. |
FROZEN | sanitizeObject cannot delete a dangerous key because the object is frozen or sealed. | Yours: use sanitizeObjectCopy. |
PLATFORM | The 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 everyPLATFORMerror and theMALFORMEDthatsafeJsonParsethrows. Log it: the message tells you which operation failed, andcausetells you why.- Two copies of the package: if another dependency installed its own copy of
unsecure, errors from that copy failinstanceof UnsecureError. Code that has to work across copies can checkerror.name === "UnsecureError"and then readcode. - Libraries built on
unsecure: thrownew UnsecureError(code, "yourFunction: …")too, so your callers deal with one class and one set of codes. Pass{ cause }when the failure started somewhere else.