Errors

Four classes cover every failure path in sonuslab-storage.

Error classes

NameSurfaceDescription
StorageApiErrorserver SDKNon-2xx response from storage-api. Exposes .status (number) and .body (parsed JSON or string).
UploadErrorbrowserNetwork or PUT failure in the browser helper. Exposes .cause with the underlying error.
WebhookVerificationErrorwebhookThrown by verifyWebhook on missing secret, bad signature, expired timestamp, or malformed payload.
DecryptionErrorcryptoThrown by decryptBytes / decryptContainer on a wrong key, an unsupported container version, a tampered header, or a corrupt payload. Exposes .cause.

StorageApiError

Every server SDK call rejects with this class on a non-2xx response. Switch on .status to map to your own HTTP responses.

import { StorageApiError } from 'sonuslab-storage/server'

try {
  await storage.upload({ name, contentType, data })
} catch (err) {
  if (err instanceof StorageApiError) {
    if (err.status === 401) // invalid or revoked API key
    if (err.status === 413) // file exceeds maxFileSize
    if (err.status === 404) // file id not found
    if (err.status === 429) // rate limited (see below)
    console.error(err.status, err.body)
  }
  throw err
}

Common statuses

CodeMeaningDescription
400Bad RequestRequest body failed validation. .body has the field-level error.
401UnauthorizedAPI key missing, invalid, or revoked.
404Not FoundFile or upload id does not exist (or belongs to a different app).
413Payload Too Largesize exceeds the app maxFileSize. Use multipart instead.
429Too Many RequestsRate limit hit. Honour the Retry-After header.
5xxServer ErrorTransient storage-api error. Retry with exponential backoff.

WebhookVerificationError

Thrown by verifyWebhook when:

  • The signature header is missing or malformed.
  • The HMAC doesn't match (wrong secret, modified body).
  • The timestamp is older than the tolerance window (default 300s).
  • The secret is empty or missing — it is never treated as a valid key.

Always respond with 401 — do not leak which check failed.

import { WebhookVerificationError } from 'sonuslab-storage/webhook'

try {
  verifyWebhook({ body, signature, secret })
} catch (err) {
  if (err instanceof WebhookVerificationError) {
    throw createError({ statusCode: 401, statusMessage: 'Invalid signature' })
  }
  throw err
}

UploadError

Wraps any failure during the browser upload flow — presign request, the direct PUT, abort, encryption, or finalize. The original error is preserved in .cause.

message is a fixed, safe summary — upstream R2/S3 bodies name the object key and carry request and host ids, and adapters surface message straight into toasts and error trackers, so the details live on .cause instead. Inspect .cause (it carries status and body for HTTP failures); code that string-matches on message will break.
import { UploadError } from 'sonuslab-storage/client'

try {
  await uploadFile({ file, presignEndpoint, completeEndpoint })
} catch (err) {
  if (err instanceof UploadError) {
    console.error('upload failed:', err.message, err.cause)
    toast.error('Could not upload — please try again')
    return
  }
  throw err
}

Patterns

Map server errors to HTTP responses

export default defineEventHandler(async (event) => {
  try {
    return await storage.presign(await readBody(event))
  } catch (err) {
    if (err instanceof StorageApiError) {
      throw createError({ statusCode: err.status, statusMessage: err.body?.message ?? 'Upload error' })
    }
    throw err
  }
})

Retry on 5xx / 429

async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn()
    } catch (err) {
      const retryable = err instanceof StorageApiError && (err.status === 429 || err.status >= 500)
      if (!retryable || i === attempts - 1) throw err
      await new Promise((r) => setTimeout(r, 250 * 2 ** i))
    }
  }
  throw new Error('unreachable')
}

const file = await withRetry(() => storage.upload({ name, contentType, data }))
Copyright © 2026