# 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

```bash
npx prongs add errors
```

Adds the errors profile and its starter intents, copies `fail()` (this page) 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.

| Intent | Use it for | Resolves to |
|---|---|---|
| [`request.malformed`](https://www.prongs.dev/docs/intents/request/malformed.md) | The request cannot be read: invalid JSON, the wrong content type | `400 · your message` |
| [`request.missing`](https://www.prongs.dev/docs/intents/request/missing.md) | A required field or parameter is absent | `400 · your message` |
| [`request.invalid`](https://www.prongs.dev/docs/intents/request/invalid.md) | A field breaks a rule: too long, the wrong format, out of range | `422 · your message` |

| Intent | Use it for | Resolves to |
|---|---|---|
| [`auth.required`](https://www.prongs.dev/docs/intents/auth/required.md) | Nobody is signed in, and this needs a signed-in user | `401 · your message` |
| [`auth.expired`](https://www.prongs.dev/docs/intents/auth/expired.md) | The caller was signed in, but the session or link has expired | `401 · your message` |
| [`auth.signin`](https://www.prongs.dev/docs/intents/auth/signin.md) | A sign-in with the wrong email or password | `401 · generic message · writes security.signin` |
| [`auth.invalid`](https://www.prongs.dev/docs/intents/auth/invalid.md) | A wrong credential: a bad API token, webhook signature or preview secret | `401 · generic message · writes security.token` |

| Intent | Use it for | Resolves to |
|---|---|---|
| [`permission.denied`](https://www.prongs.dev/docs/intents/permission/denied.md) | Signed in, but not allowed to do this | `403 · your message` |

| Intent | Use it for | Resolves to |
|---|---|---|
| [`record.missing`](https://www.prongs.dev/docs/intents/record/missing.md) | The thing does not exist, or the caller may not know it does | `404 · your message` |

| Intent | Use it for | Resolves to |
|---|---|---|
| [`conflict.duplicate`](https://www.prongs.dev/docs/intents/conflict/duplicate.md) | It would create something that exists: a taken handle or email, a second follow | `409 · your message` |
| [`conflict.state`](https://www.prongs.dev/docs/intents/conflict/state.md) | Not possible right now: create your page first, already published | `409 · your message` |

| Intent | Use it for | Resolves to |
|---|---|---|
| [`limit.rate`](https://www.prongs.dev/docs/intents/limit/rate.md) | Too many requests in a short time; the caller should wait | `429 · your message · retryable · writes security.limited` |

| Intent | Use it for | Resolves to |
|---|---|---|
| [`failure.internal`](https://www.prongs.dev/docs/intents/failure/internal.md) | 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`](https://www.prongs.dev/docs/intents/failure/upstream.md) | A service we depend on failed or timed out: a feed, an email provider, an API | `502 · generic message · writes incident.degraded` |
| [`failure.unavailable`](https://www.prongs.dev/docs/intents/failure/unavailable.md) | 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.*`](https://www.prongs.dev/docs/intents/errors.md#status) intents, so changing [`status.internal`](https://www.prongs.dev/docs/intents/errors.md#status-internal) changes every 500. Every 5xx names the log entry it writes, and validation enforces it.

| Prop | Type | What it does |
|---|---|---|
| `cause` | `unknown` | The underlying error. Written to the log when the intent logs; never sent to the caller. |
| `context` | `Record<string, unknown>` | Extra fields for the log entry, like ids or handles. Not sent to the caller. |
| `body` | `Record<string, unknown>` | Extra fields for the response body, like an empty list the client can still render. |
| `retryAfter` | `number` | Seconds before the caller may try again; sets `Retry-After`. |

## In Feedspace

- 68 error responses were written by hand. All of them now go through [`fail()`](https://www.prongs.dev/docs/profiles/errors.md), across 13 intents.
- 10 sent a database error’s own message to the browser with status 400. They are [`failure.internal`](https://www.prongs.dev/docs/intents/failure/internal.md) now: a 500, a generic message, and the error in the log.
- “Handle is already taken” moved from 400 to 409 ([`conflict.duplicate`](https://www.prongs.dev/docs/intents/conflict/duplicate.md)); “Create your page first” too ([`conflict.state`](https://www.prongs.dev/docs/intents/conflict/state.md)); a broken rule, like a bad handle format, from 400 to 422 ([`request.invalid`](https://www.prongs.dev/docs/intents/request/invalid.md)).
- A missing admin key returned a 500 without logging anything. It is [`failure.unavailable`](https://www.prongs.dev/docs/intents/failure/unavailable.md) now, a 503 that writes [`incident.failed`](https://www.prongs.dev/docs/intents/incident/failed.md).
- `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.
