> ## Documentation Index
> Fetch the complete documentation index at: https://upstash-dx-2982-blob-docs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

```ts lib/avatar.ts theme={"system"}
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 theme={"system"}
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 theme={"system"}
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 theme={"system"}
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 theme={"system"}
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 theme={"system"}
"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 theme={"system"}
const record = start({ file })
record?.cancel()          // status becomes 'canceled', error stays undefined
```

***

## Platform body limits

```tsx theme={"system"}
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 theme={"system"}
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).
