Errors and logs

Errors

What went wrong, told to the caller: request, auth, permission, record, conflict, limit and failure intents, returned with fail().

Why

When a request fails, the server decides three things: the status, what the caller is told, and what is recorded. Written by hand, each route decides them again, and they drift. A database error goes to the browser with a status that blames the caller; a taken handle is reported as bad input; a failure is caught and forgotten. These meanings are the same in every app, so the profile ships them as starter intents.

Before
TSX
if (error) {
return NextResponse.json({ ok: false, error: error.message }, { status: 400 });
}
After
TSX
if (error) {
return fail('failure.internal', undefined, { cause: error });
}

Set it up

Terminal
npx prongs add errors

Adds the errors profile and its starter intents, copies fail() into components/prongs/errors.ts, and adds the logs profile too if it is missing, because errors name the log entries they write.

The kinds of failure

Pick the kind by what went wrong for the caller, then the case within it. Each domain has its own page.

  • request.malformed

    The request cannot be read: invalid JSON, the wrong content type

    400 · your message
  • request.missing

    A required field or parameter is absent

    400 · your message
  • request.invalid

    A field breaks a rule: too long, the wrong format, out of range

    422 · your message
  • auth.required

    Nobody is signed in, and this needs a signed-in user

    401 · your message
  • auth.expired

    The caller was signed in, but the session or link has expired

    401 · your message
  • auth.signin

    A sign-in with the wrong email or password

    401 · generic message · writes security.signin
  • auth.invalid

    A wrong credential: a bad API token, webhook signature or preview secret

    401 · generic message · writes security.token
  • record.missing

    The thing does not exist, or the caller may not know it does

    404 · your message
  • conflict.duplicate

    It would create something that exists: a taken handle or email, a second follow

    409 · your message
  • conflict.state

    Not possible right now: create your page first, already published

    409 · your message
  • limit.rate

    Too many requests in a short time; the caller should wait

    429 · your message · retryable · writes security.limited
  • failure.internal

    Our fault: a bug, a failed database call. The caller gets a generic message; the details go to the log

    500 · generic message · writes incident.failed
  • failure.upstream

    A service we depend on failed or timed out: a feed, an email provider, an API

    502 · generic message · writes incident.degraded
  • failure.unavailable

    Temporarily unavailable: maintenance, missing configuration, overload

    503 · generic message · retryable · writes incident.failed

Using fail()

TS
import { fail } from '@/components/prongs/errors';

if (!user) return fail('auth.required');
if (!profile) return fail('record.missing', 'Profile not found.');
if (!HANDLE_FORMAT.test(handle)) return fail('request.invalid', 'Use letters, numbers or hyphens.');
if (error?.code === '23505') return fail('conflict.duplicate', 'That handle is already taken.');
if (error) return fail('failure.internal', undefined, { cause: error, context: { handle } });

fail(intent, message?, options?) returns the Response: { ok: false, error, code } with the intent’s status. Statuses live in ref-only status.* intents, so changing status.internal changes every 500. Every 5xx names the log entry it writes, and validation enforces it.

  • cause

    The underlying error. Written to the log when the intent logs; never sent to the caller.

    unknown
  • context

    Extra fields for the log entry, like ids or handles. Not sent to the caller.

    Record<string, unknown>
  • body

    Extra fields for the response body, like an empty list the client can still render.

    Record<string, unknown>
  • retryAfter

    Seconds before the caller may try again; sets Retry-After.

    number

In Feedspace

  • 68 error responses were written by hand. All of them now go through fail(), across 13 intents.

  • 10 sent a database error’s own message to the browser with status 400. They are failure.internal now: a 500, a generic message, and the error in the log.

  • “Handle is already taken” moved from 400 to 409 (conflict.duplicate); “Create your page first” too (conflict.state); a broken rule, like a bad handle format, from 400 to 422 (request.invalid).

  • A missing admin key returned a 500 without logging anything. It is failure.unavailable now, a 503 that writes incident.failed.

  • tsc and lint were unchanged, and the client code needed no changes: it reads ok, error and field, which fail() keeps, plus a new code naming the intent.