Skip to main content

Errors

8 min read
lib/avatar.ts

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#

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.

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


The codes#

e.status is what an upload route answers with.

CodeStatusDefault messageWhen you see it
not_found404not foundget, 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_exists409already existsput with allowOverwrite: false against a path that already has an object. Carries etag and size.
conflict409the object changed since it was readifUnchanged did not match, or storage answered PreconditionFailed. Also updateJson giving up after maxAttempts rounds, six by default.
content_type_not_allowed400content type not allowedThe declared type is not in contentTypes, or the file's leading bytes contradict the declaration.
invalid_input400invalid inputArguments 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_large413too largeOver 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_body400empty bodyA zero-byte file at begin, or a put from a Request with no body left to read.
length_required411length requiredput of an unknown-length stream with neither size nor maxSize.
signature_mismatch403signature mismatchA 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.
unauthorized401unauthorizedThe bucket token was rejected, or your own auth check refused the upload.
forbidden403forbiddenA completion token that is not valid for this route, or has expired.
rate_limited429rate limitedStorage answered SlowDown or TooManyRequests, or credential requests are being rate limited.
mint_backoff429the credential service asked for a backoff longer than a request can waitThe credential service asked for more than 10 seconds of backoff. retryAfter says how long.
not_ready503bucket is not readyThe bucket is not ready to serve requests yet.
partial_delete500some paths were not deletedAn array or prefix del where some objects survived. failed lists them.
move_left_a_copy500move left a copy at the sourcemove copied the object but could not delete the source. The destination is kept.
invalid_content_type_pattern500invalid content type patternA contentTypes entry that is not a type/subtype or one of image/*, video/*, audio/*. An empty list throws this too.
request_failed500request failedEverything 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#

Some codes carry extra fields.

FieldTypeSet by
hintstring | undefinedAny code. signature_mismatch and length_required have a built-in one, and many call sites add their own.
failedstring[] | undefinedpartial_delete: the paths that survived the delete.
etagstring | undefinedalready_exists: the etag of what is already there.
sizenumber | undefinedalready_exists: the size of what is already there.
retryAfternumber | undefinedmint_backoff and rate_limited: seconds the service asked the caller to wait.
causeunknownThe underlying error, when there was one. Standard Error.cause.

Messages#

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#

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.

CodeBuilt-in hint
signature_mismatcha 403 from R2 usually means the body length or type differs from the signature
length_requiredpass { size } or { maxSize } so the length is known before the first byte

Errors in the browser#

app/picker.tsx

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 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.
StatusCode
401unauthorized
403forbidden
404not_found
409conflict
411length_required
413too_large
429rate_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.


What storage errors map to#

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

Storage answeredBecomes
404, or NoSuchKey / NoSuchUploadnot_found
412, or PreconditionFailedconflict
401unauthorized
403 with ExpiredToken, InvalidAccessKeyId or TokenRefreshRequiredunauthorized, "storage refused the temporary credential", hinting that it expired mid-request and the SDK re-mints and retries once
any other 403signature_mismatch
429, or SlowDown / TooManyRequestsrate_limited, "R2 rate limited the request"
503not_ready
413, or EntityTooLargetoo_large
anything elserequest_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:

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.

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

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.


Platform body limits#

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:

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#

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

CodeStatusMeaningWhat to do
unauthorized401The bucket token was rejected.Check UPSTASH_BLOB_TOKEN. Nothing retries this.
not_ready503The bucket is not ready yet.Retry the request.
mint_backoff429The 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.