Errors
WIPTyped UploadEngineError classes with static, XSS-safe messages. Tainted values live on context, never in the message.
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.
import { UploadEngineError } from '@gentleduck/upload/core'
The base error
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:
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:
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):
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:
store.on('upload.error', ({ error, retryable }) => {
console.warn(error.code, error.message, retryable)
})