s3nd

Errors

Every failure is a S3ndError with a stable code.

Everything s3nd throws is a S3ndError carrying a code you can branch on, and the underlying error in cause:

import { isS3ndError } from 's3nd'

try {
  await store.putSnapshot(code, state)
} catch (error) {
  if (isS3ndError(error)) {
    console.error(error.code, error.message, error.cause)
  }

  throw error
}

Codes

CodeWhen
INVALID_CONFIGMissing bucket, publicUrl that is not an absolute URL, non-positive maxSize.
INVALID_SYNC_CODEThe code is empty, or holds characters outside the alphabet.
INVALID_SNAPSHOTData JSON cannot represent, a stored object that is not a snapshot, an unreadable one, or an envelope from a newer format.
SNAPSHOT_TOO_NEWThe snapshot's schema version is above the maxVersion you passed.
PRECONDITION_FAILEDAn ifMatch or ifAbsent write lost the race.
INVALID_KEYEmpty key, leading or trailing /, .. segment, backslash, control character, over 1024 bytes.
INVALID_BODYThe upload body is not a supported type.
MISSING_CONTENT_LENGTHA stream was uploaded without contentLength.
FILE_TOO_LARGEThe body is above the configured maxSize.
UPLOAD_FAILEDPutObject was rejected. Original error in cause.
GET_FAILEDGetObject failed for a reason other than a missing key, or answered without a body.
DELETE_FAILEDDeleteObject/DeleteObjects was rejected, or S3 reported per-key errors.
URL_FAILEDInvalid expiresIn, signing failure, or a public URL requested with no publicUrl configured.

Failures that never reach the network

INVALID_CONFIG, INVALID_SYNC_CODE, INVALID_KEY, INVALID_BODY, MISSING_CONTENT_LENGTH, FILE_TOO_LARGE, and an INVALID_SNAPSHOT caused by unserializable data are all raised before a single byte leaves your server. A user pasting nonsense into the code field, or a 200 MB database posted to an endpoint capped at 4 MB, costs you a comparison rather than a request.

A missing snapshot is not an error

getSnapshot() returns null for a code that was never used and for one that has expired. Both mean the same thing to the person typing it, so there is nothing to tell apart:

const snapshot = await store.getSnapshot(code)

if (!snapshot) {
  return Response.json({ error: 'Unknown or expired code' }, { status: 404 })
}

delete() follows S3's own semantics: deleting something that is not there succeeds quietly, which is what makes a retry after a partial failure converge.

The three that are decisions, not bugs

Most codes mean something is wrong. These three are the system telling you something true, and what you do with them is a product choice:

PRECONDITION_FAILED — another device wrote first. Retry the read-merge-write loop, ask the user, or refuse. See two devices, one snapshot.

SNAPSHOT_TOO_NEW — the snapshot came from a build ahead of this one. The only safe answer is to stop and tell the user to update the app; importing anyway silently drops the fields this build does not know about.

INVALID_SYNC_CODE — the typed code cannot exist. Better feedback than a lookup that finds nothing, and it costs no request.

Mapping to HTTP

import { isS3ndError } from 's3nd'

const STATUS: Record<string, number> = {
  FILE_TOO_LARGE: 413,
  INVALID_BODY: 400,
  INVALID_KEY: 400,
  INVALID_SNAPSHOT: 400,
  INVALID_SYNC_CODE: 400,
  MISSING_CONTENT_LENGTH: 411,
  PRECONDITION_FAILED: 409,
  SNAPSHOT_TOO_NEW: 409,
}

export function statusFor(error: unknown): number {
  return (isS3ndError(error) && STATUS[error.code]) || 500
}

Keep UPLOAD_FAILED, GET_FAILED, DELETE_FAILED, URL_FAILED and INVALID_CONFIG on 500: they mean something on your side is wrong, and they belong in your logs with cause attached.

On this page