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.
Beforeif (error) {
return NextResponse.json({ ok: false, error: error.message }, { status: 400 });
}if (error) {
return fail('failure.internal', undefined, { cause: error });
}Set it up
npx prongs add errorsAdds 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.malformedThe request cannot be read: invalid JSON, the wrong content type
400 · your messagerequest.missingA required field or parameter is absent
400 · your messagerequest.invalidA field breaks a rule: too long, the wrong format, out of range
422 · your message
auth.requiredNobody is signed in, and this needs a signed-in user
401 · your messageauth.expiredThe caller was signed in, but the session or link has expired
401 · your messageauth.signinA sign-in with the wrong email or password
401 · generic message · writes security.signinauth.invalidA wrong credential: a bad API token, webhook signature or preview secret
401 · generic message · writes security.token
permission.deniedSigned in, but not allowed to do this
403 · your message
record.missingThe thing does not exist, or the caller may not know it does
404 · your message
conflict.duplicateIt would create something that exists: a taken handle or email, a second follow
409 · your messageconflict.stateNot possible right now: create your page first, already published
409 · your message
limit.rateToo many requests in a short time; the caller should wait
429 · your message · retryable · writes security.limited
failure.internalOur fault: a bug, a failed database call. The caller gets a generic message; the details go to the log
500 · generic message · writes incident.failedfailure.upstreamA service we depend on failed or timed out: a feed, an email provider, an API
502 · generic message · writes incident.degradedfailure.unavailableTemporarily unavailable: maintenance, missing configuration, overload
503 · generic message · retryable · writes incident.failed
Using fail()
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.
causeThe underlying error. Written to the log when the intent logs; never sent to the caller.
unknowncontextExtra fields for the log entry, like ids or handles. Not sent to the caller.
Record<string, unknown>bodyExtra fields for the response body, like an empty list the client can still render.
Record<string, unknown>retryAfterSeconds 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.internalnow: 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.unavailablenow, a 503 that writesincident.failed.tscand lint were unchanged, and the client code needed no changes: it readsok,errorandfield, which fail() keeps, plus a newcodenaming the intent.