# Errors

```ts lib/avatar.ts
import { BlobError, Bucket } from "@upstash/blob"

const bucket = Bucket.fromEnv()

export async function avatar(path: string) {
  try {
    return await bucket.info(path)
  } catch (e) {
    if (BlobError.is(e) && e.code === "not_found") return null
    throw e
  }
}
```

Everything the SDK throws is a `BlobError`. It carries a `code` from a fixed list, a `status`, and a `message` you can show to a user. The same class and codes are used on the server, in the browser, and inside the React hooks.

`BlobError` is exported from all three entrypoints: `@upstash/blob`, `@upstash/blob/browser` and `@upstash/blob/react`.

---

## Use `BlobError.is()`, not `instanceof`

```ts
if (BlobError.is(e)) {
  e.code    // BlobErrorCode
  e.status  // number
  e.message // string
}
```

<Warning>
  An ESM copy and a CJS copy of the class are two different classes, so `instanceof` can return
  false for an error that genuinely is one. `BlobError.is()` checks a `Symbol.for` marker instead,
  which is shared across every copy of the package in the process.
</Warning>

`is()` is a type guard, so the fields are typed after it.

---

## The codes

`e.status` is what an upload route answers with.

| Code | Status | Default message | When you see it |
| ---- | ------ | --------------- | --------------- |
| `not_found` | 404 | `not found` | `get`, `info` or `copy` on a path that is not there. Storage answered `NoSuchKey` or `NoSuchUpload`. An unknown upload route. A completion whose object never landed. |
| `already_exists` | 409 | `already exists` | `put` with `allowOverwrite: false` against a path that already has an object. Carries `etag` and `size`. |
| `conflict` | 409 | `the object changed since it was read` | `ifUnchanged` did not match, or storage answered `PreconditionFailed`. Also `updateJson` giving up after `maxAttempts` rounds, six by default. |
| `content_type_not_allowed` | 400 | `content type not allowed` | The declared type is not in `contentTypes`, or the file's leading bytes contradict the declaration. |
| `invalid_input` | 400 | `invalid input` | Arguments the SDK will not accept: metadata outside printable ASCII, a malformed upload request body, `input` that fails the route's schema, a `del` target that is none of the three shapes. |
| `too_large` | 413 | `too large` | Over `maxSize`, over the route's `constraints`, or over what a single PUT can carry when `multipart: false` forbids the parts the body needs. Also a 413 from storage or from your platform. |
| `empty_body` | 400 | `empty body` | A zero-byte file at `begin`, or a `put` from a `Request` with no body left to read. |
| `length_required` | 411 | `length required` | `put` of an unknown-length stream with neither `size` nor `maxSize`. |
| `signature_mismatch` | 403 | `signature mismatch` | A 403 from storage that is not a credential problem: the body length or type does not match what was signed. Also a completion where the stored size is not the declared size. |
| `unauthorized` | 401 | `unauthorized` | The bucket token was rejected, or your own auth check refused the upload. |
| `forbidden` | 403 | `forbidden` | A completion token that is not valid for this route, or has expired. |
| `rate_limited` | 429 | `rate limited` | Storage answered `SlowDown` or `TooManyRequests`, or credential requests are being rate limited. |
| `mint_backoff` | 429 | `the credential service asked for a backoff longer than a request can wait` | The credential service asked for more than 10 seconds of backoff. `retryAfter` says how long. |
| `not_ready` | 503 | `bucket is not ready` | The bucket is not ready to serve requests yet. |
| `partial_delete` | 500 | `some paths were not deleted` | An array or prefix `del` where some objects survived. `failed` lists them. |
| `move_left_a_copy` | 500 | `move left a copy at the source` | `move` copied the object but could not delete the source. The destination is kept. |
| `invalid_content_type_pattern` | 500 | `invalid content type pattern` | A `contentTypes` entry that is not a `type/subtype` or one of `image/*`, `video/*`, `audio/*`. An empty list throws this too. |
| `request_failed` | 500 | `request failed` | Everything else. This is the one code whose `status` the thrower sets, so it also carries 502 and 503. |

Bad option values are not in this list. An unparseable `'5mib'`, a missing `token`, a route with no `onBeforeUpload`: those throw a `TypeError` where the option is written, not a `BlobError` per request.

---

## Extra fields

```ts
try {
  await bucket.del(["a.png", "b.png", "c.png"])
} catch (e) {
  if (!BlobError.is(e)) throw e
  if (e.code === "partial_delete") await queueForRetry(e.failed ?? [])
  if (e.code === "rate_limited") await sleep((e.retryAfter ?? 1) * 1000)
}
```

Some codes carry extra fields.

| Field | Type | Set by |
| ----- | ---- | ------ |
| `hint` | `string \| undefined` | Any code. `signature_mismatch` and `length_required` have a built-in one, and many call sites add their own. |
| `failed` | `string[] \| undefined` | `partial_delete`: the paths that survived the delete. |
| `etag` | `string \| undefined` | `already_exists`: the etag of what is already there. |
| `size` | `number \| undefined` | `already_exists`: the size of what is already there. |
| `retryAfter` | `number \| undefined` | `mint_backoff` and `rate_limited`: seconds the service asked the caller to wait. |
| `cause` | `unknown` | The underlying error, when there was one. Standard `Error.cause`. |

---

## Messages

```ts
new BlobError("not_found").message                         // 'Not found'
new BlobError("forbidden", "not your thread").message       // 'Not your thread'
new BlobError("too_large", "cat.png is 3.1 MB, over the 2 MB limit").message
// 'cat.png is 3.1 MB, over the 2 MB limit'
```

Messages are sentence-cased, so an app can print `e.message` directly. A message that opens with an identifier, like a MIME type or a file name, keeps that identifier's case.

`e.message` never carries a credential, a token, or an internal path. Every message is assembled from a code, a caller-supplied string, or an HTTP status.

### Hints fold into the message

```ts
new BlobError("length_required").message
// 'Length required (pass { size } or { maxSize } so the length is known before the first byte)'
```

A hint is appended to the message in parentheses, so printing `message` alone is enough. `e.hint` is still there separately if you want to lay it out yourself. A message that already contains its hint is not doubled.

| Code | Built-in hint |
| ---- | ------------- |
| `signature_mismatch` | a 403 from R2 usually means the body length or type differs from the signature |
| `length_required` | pass `{ size }` or `{ maxSize }` so the length is known before the first byte |

---

## Errors in the browser

```tsx app/picker.tsx
"use client"
import type { BlobError } from "@upstash/blob/react"
import { useUpload } from "@/lib/upload-hooks"

export function Picker() {
  const { start, upload, accept } = useUpload()

  return (
    <>
      <input
        type="file"
        accept={accept}
        onChange={(e) => start({ file: e.target.files?.[0] })}
      />
      {upload?.status === "error" && <p role="alert">{describe(upload.error)}</p>}
    </>
  )
}

function describe(error: BlobError): string {
  switch (error.code) {
    case "unauthorized":
      return "Your session expired. Sign in and try again."
    case "rate_limited":
      return `Too many uploads. Try again in ${error.retryAfter ?? 30}s.`
    case "not_ready":
      return "Storage is warming up. Try again in a moment."
    default:
      // too_large, content_type_not_allowed and the rest already read as a sentence.
      return error.message
  }
}
```

An upload route answers every refusal with the error's own code and status, and the browser rebuilds it. `error.code` inside a hook is the code your server raised, not a status number you have to decode.

### What reaches the browser, in order

A route runs [`onError`](/blob/uploads/upload-handler#onerror) first. If it returns a `Response`, that is the answer; if it returns a `BlobError`, the answer is that error's JSON at its own status. Otherwise the throw falls through three cases:

1. **A `BlobError` is answered as itself**, at its own status, with `hint`, `failed`, `etag`, `size` and `retryAfter` when they are set.
2. **An app error carrying a `status` between 400 and 599 is mapped through the table below.** This is how an auth check that throws its own 401 reaches the browser as `unauthorized`.
3. **Anything else is treated as your bug and rethrown**, so your framework logs it with its stack rather than masking it as a generic 500.

| Status | Code |
| ------ | ---- |
| 401 | `unauthorized` |
| 403 | `forbidden` |
| 404 | `not_found` |
| 409 | `conflict` |
| 411 | `length_required` |
| 413 | `too_large` |
| 429 | `rate_limited` |

Any other status becomes `request_failed`, keeping the status it arrived with.

A throw out of `onUploadComplete` also deletes the completed object before the error is answered. See [onUploadComplete](/blob/uploads/upload-handler#onuploadcomplete).

---

## What storage errors map to

Storage is Cloudflare R2. Its errors are normalized before they leave the SDK, first matching wins.

| Storage answered | Becomes |
| ---------------- | ------- |
| 404, or `NoSuchKey` / `NoSuchUpload` | `not_found` |
| 412, or `PreconditionFailed` | `conflict` |
| 401 | `unauthorized` |
| 403 with `ExpiredToken`, `InvalidAccessKeyId` or `TokenRefreshRequired` | `unauthorized`, "storage refused the temporary credential", hinting that it expired mid-request and the SDK re-mints and retries once |
| any other 403 | `signature_mismatch` |
| 429, or `SlowDown` / `TooManyRequests` | `rate_limited`, "R2 rate limited the request" |
| 503 | `not_ready` |
| 413, or `EntityTooLarge` | `too_large` |
| anything else | `request_failed`, message `R2 responded <status> <Code>: <Message>` |

For that last row the status is passed through, except that a 5xx becomes 502, since the failure is upstream of your app rather than in it.

---

## Errors the browser raises on its own

Some failures never reach your route, so the browser names them itself.

**A PUT that fails with no status and no bytes sent** was refused by the browser before it went out, and the reason is never visible to script because it is the preflight that failed:

```
the browser blocked the request before sending any bytes, which is almost always CORS:
the bucket has to allow PUT and the signed headers from this origin
```

Buckets allow every origin by default, so this only shows up on a bucket whose CORS policy was narrowed.

**A 403 on a freshly minted presign** becomes `signature_mismatch`. A 401 or 403 on an older URL is read as an expired signature and the browser asks the route for a new one instead. See [Retries](/blob/uploads/large-files#retries).

**Exhausted retries** become `request_failed`, carrying the attempt count and the last status, hinted with what to do next:

```
Upload failed after 8 attempts (last status 500) (the parts that landed are kept:
task.retry(), or pick the same file again)
```

**A canceled upload rejects with an `AbortError`, not a `BlobError`.** The record's status is `canceled` and it carries no `error` at all, so a cancel never renders as a failure.

```ts
const record = start({ file })
record?.cancel()          // status becomes 'canceled', error stays undefined
```

---

## Platform body limits

```tsx
const { start, upload } = useServerUpload("/api/avatar")

if (upload?.status === "error" && upload.error.code === "too_large") {
  // Either your own maxSize or the platform's body cap. e.hint says which.
}
```

This applies to `useServerUpload` and any route of your own that the bytes pass through. It does not apply to direct browser uploads.

A 413 from the platform never reached your route, so it carries no code of its own. The SDK turns it into `too_large` and attaches the limits as a hint:

```
Too large (Vercel caps a serverless request body at 4.5MB, AWS Lambda at 6MB,
Cloudflare at 100MB on the free plan)
```

Keep a proxied route's own `maxSize` under the platform's cap, so the refusal comes from your code with your wording. A file bigger than the cap needs a direct browser upload instead.

---

## Credential errors

```ts
try {
  await bucket.put("u/7/report.pdf", body)
} catch (e) {
  if (BlobError.is(e) && e.code === "mint_backoff") {
    return retryAfterSeconds(e.retryAfter ?? 10)
  }
  throw e
}
```

Three codes come from the credential service rather than from storage or from your code.

| Code | Status | Meaning | What to do |
| ---- | ------ | ------- | ---------- |
| `unauthorized` | 401 | The bucket token was rejected. | Check `UPSTASH_BLOB_TOKEN`. Nothing retries this. |
| `not_ready` | 503 | The bucket is not ready yet. | Retry the request. |
| `mint_backoff` | 429 | The service asked for a backoff longer than a request can wait, over 10 seconds. `retryAfter` says how long. | Retry the request later rather than blocking on it. |

The SDK waits out short backoffs itself. `mint_backoff` is a pause too long for one request to wait through, handed back to you instead of holding a serverless invocation open.

Credentials are short-lived and re-minted before they expire. One that expires mid-request is handled inside the SDK, so only a second refusal surfaces. See [How signing works](/blob/reference/signing).
