The engine throws `UploadEngineError` (and typed subclasses) from the intent, upload, and
finalize phases. Each error carries a stable machine-readable `code`, a **static** message the
library controls, a `retryable` flag, and optional structured `context`. Attacker-controlled
values (filenames, server bodies) never go in the message.

```ts
import { UploadEngineError } from '@gentleduck/upload/core'
```

## The base error

```ts
class UploadEngineError extends Error {
  readonly code: string                 // stable classification
  readonly retryable: boolean           // may the recovery system retry?
  readonly context?: Record<string, unknown>
  readonly cause?: unknown              // wrapped original (ES2022)
}
```

Static messages per code include:

```text
intent_failed    -> 'upload intent request failed'
upload_failed    -> 'upload transfer failed'
complete_failed  -> 'upload finalize failed'
validation_failed-> 'file validation failed'
strategy_missing -> 'upload strategy not found'
aborted / network / http / timeout / auth / rate_limit / server / unknown
```

Because the message is a constant the library owns, `err.message` is safe to render directly —
in a toast, as React children, even with `dangerouslySetInnerHTML`.

## Why static messages

Filenames are attacker-controlled. ``new Error(`upload of ${file.name} failed`)`` invites a
name like `<img src=x onerror=alert(1)>.png` to reach your DOM if you render `error.message`
unsafely. Keeping the message constant removes that whole class of XSS.

Tainted inputs live on `err.context` instead — and you **must** escape those before HTML
rendering (React children auto-escape; raw `innerHTML` does not).

## Typed subclasses

Each subclass adds structured, code-specific fields:

| Class | code | Extra fields | retryable default |
| --- | --- | --- | --- |
| `UploadValidationError` | `validation_failed` | `reason: Contracts.Validation.Rejection` | `false` |
| `UploadStrategyMissingError` | `strategy_missing` | `strategy: string` | `false` |
| `UploadAbortError` | `aborted` | `reason: 'pause' \| 'cancel' \| 'unknown'` | `false` |
| `UploadNetworkError` | `network` | — | `true` |
| `UploadHttpError` | `http` | `status`, `statusText?` | `true` for `>= 500` or `429` |
| `UploadTimeoutError` | `timeout` | — | `true` |
| `UploadAuthError` | `auth` | — | `false` |
| `UploadRateLimitError` | `rate_limit` | `retryAfterMs?` | `true` |
| `UploadServerError` | `server` | `serverCode?` | `true` |
| `UploadUnknownError` | `unknown` | — | `false` |

## Discriminating on code

`code` is stable, so branch on it for typed recovery:

```tsx
if (err instanceof UploadEngineError) {
  switch (err.code) {
    case 'auth':
      return <SignInAgain />
    case 'rate_limit':
      // UploadRateLimitError adds retryAfterMs.
      return <RetryLater afterMs={(err as UploadRateLimitError).retryAfterMs} />
    case 'http':
      return <GenericHttpError status={(err as UploadHttpError).status} />
    default:
      return <GenericError message={err.message} />
  }
}
```

## Reading wrapped causes

When the engine wraps an underlying failure, the original lives on `err.cause` (treat it as
`unknown`):

```ts
if (err instanceof UploadEngineError && err.cause instanceof Response) {
  console.error('HTTP', err.cause.status, await err.cause.text())
}
```

## The plain error shape

Errors surfaced through events and stored on items use the `UploadError` type — either a
`UploadEngineError` instance or a plain `Contracts.Errors.Error` object. Both always expose
`code`, `message`, and `retryable`, so event handlers can rely on those three without an
`instanceof` check:

```ts
store.on('upload.error', ({ error, retryable }) => {
  console.warn(error.code, error.message, retryable)
})
```

## See also

* [Contracts](/duck-upload/core/contracts) — the `Contracts.Errors` union and validation shapes.
* [Engine](/duck-upload/core/engine) — where errors originate and how retry re-enters.