Skip to main content

Errors

WIP

Typed 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:

ClasscodeExtra fieldsretryable default
UploadValidationErrorvalidation_failedreason: Contracts.Validation.Rejectionfalse
UploadStrategyMissingErrorstrategy_missingstrategy: stringfalse
UploadAbortErrorabortedreason: 'pause' | 'cancel' | 'unknown'false
UploadNetworkErrornetworktrue
UploadHttpErrorhttpstatus, statusText?true for >= 500 or 429
UploadTimeoutErrortimeouttrue
UploadAuthErrorauthfalse
UploadRateLimitErrorrate_limitretryAfterMs?true
UploadServerErrorserverserverCode?true
UploadUnknownErrorunknownfalse

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)
})

See also

  • Contracts — the Contracts.Errors union and validation shapes.
  • Engine — where errors originate and how retry re-enters.