---
title: Troubleshooting
description: Common errors, the normalized FilesError code model, adapter-specific gotchas, and debugging tips for resolving issues across every Files SDK backend.
---

## The error model

Every method throws a single `FilesError` with a normalized `code` and the original error preserved on `cause`. Match on `code` for control flow; reach into `cause` for the provider-specific detail.

```ts lineNumbers
import { FilesError } from "files-sdk";

try {
  await files.download("missing.png");
} catch (err) {
  if (err instanceof FilesError) {
    switch (err.code) {
      case "NotFound":
        return null;
      case "Unauthorized":
        /* re-auth */ break;
      case "Conflict":
        /* retry */ break;
      case "ReadOnly":
        /* use a writable Files instance */ break;
      case "Invalid":
        /* fix the call: bad key, option, or config */ break;
      case "Unsupported":
        /* check files.capabilities, or take another path */ break;
      case "Provider":
        console.error(err.cause);
        break;
    }
  }
  throw err;
}
```

> **Logging note:** `cause` can carry request IDs, response headers, and partial request metadata from `@aws-sdk` and friends. If you forward `FilesError` to logs that cross a trust boundary, strip or whitelist `cause` rather than `JSON.stringify`-ing the whole thing.

### `NotFound`

The key does not exist (or the bucket / container does not exist on providers that don't distinguish).

- `download`, `head`, `copy` (source key), and `delete` on strict providers will throw this.
- `exists` returns `false` instead of throwing. Any other failure still throws, so if `exists` rejects, the underlying error wasn't a missing-key signal - check `cause`.
- `delete` is idempotent on providers that treat it that way (S3, R2, Vercel Blob, FTP, Bunny Storage, the local `fs` adapter): a missing key is a no-op, but real failures still surface - FTP maps a refused delete of an existing file (550) to `Unauthorized`, and Bunny Storage reports auth and 5xx errors. Strict providers (some SaaS and BaaS backends) throw `NotFound` for missing keys.

### `Unauthorized`

Credentials are missing, expired, or insufficient for the operation.

- Missing env vars (`R2_ACCESS_KEY_ID`, `AWS_ACCESS_KEY_ID`, `BLOB_READ_WRITE_TOKEN`, `GOOGLE_APPLICATION_CREDENTIALS`, ...).
- The R2 API token is read-only or scoped to other buckets, or an S3 IAM policy doesn't grant the action (`s3:PutObject`, `s3:GetObject`, `s3:ListBucket`, ...).
- Token expired (Dropbox, Box, Google Drive, OneDrive, SharePoint - OAuth tokens need refresh).
- Bucket region mismatch on S3 - the request is signed for one region and rejected by another.

### `Conflict`

A precondition failed - usually a conditional write losing a race, or an object existing when the call required it to be absent.

### `ReadOnly`

The call tried to write through a read-only `Files` instance created with `new Files({ readonly: true })` or `files.readonly()`.

- Reads still work: `download`, `head`, `exists`, `list`, `listAll`, `search`, `url`.
- Writes are blocked uniformly: `upload`, `delete`, `copy`, `move`, `signedUploadUrl`, `abortUpload`, plus the equivalent `file(key)` helpers.
- `files.raw` is not governed by this flag. If the mutation came through `raw`, the SDK's read-only guard will not see it.

### `Invalid`

The call itself is wrong: an empty, null-byte, or `..` key, a malformed range, ETag, or condition, options that contradict each other, missing or bad constructor config, or a file a [`validation()`](/docs/plugins/validation) rule rejects. The message says which. Retrying can't help; fix the call.

### `Unsupported`

The call is fine, but this adapter can't do it in its current mode, or an installed plugin refuses it: a `range` on an adapter without range reads, `metadata` where the provider has no metadata field, `url()` without a `publicBaseUrl` on FTP/SFTP/WebDAV, a `maxSize` a presigned upload can't enforce, `url()` under `encryption()`. Check [`files.capabilities`](/docs/capabilities) before calling, or see the gotchas below.

### `Provider`

The backend or transport failed: network errors, malformed responses, throttling, provider outages, timeouts, and aborts. These are the only failures [retried](/docs/retries) (unless `permanent`). `cause` has the original.

## Adapter-specific gotchas

The unified API only covers what every adapter can do; a handful of operations are surfaced but throw on adapters that can't honor them. These are the ones worth knowing.

### `url()` throws without a URL primitive

`url()` returns the most direct URL each adapter can produce - a signed `GetObject`, a SAS read URL, a CDN URL when `publicBaseUrl` is configured, etc. Adapters with no signing primitive throw unless you configure a public URL for them:

- **R2 Workers binding with no `publicBaseUrl` and no HTTP credentials** - the binding API has no URL primitive. Either configure `publicBaseUrl` (custom domain or `r2.dev`) or pass HTTP credentials alongside the binding to enable signing.
- **Bunny Storage without `publicBaseUrl`** - the Storage API requires an `AccessKey` header on every request, so there's nothing to hand to a browser. Configure your Pull Zone as `publicBaseUrl`.
- **FTP, SFTP, and WebDAV without `publicBaseUrl`** - the protocols have no signing primitive. Point `publicBaseUrl` at an HTTP server fronting the same tree.
- **Google Drive, OneDrive, and SharePoint without `publicByDefault: true`** - there's no signed-URL primitive, only permanent anonymous links.
- **Appwrite without `public: true`** - Appwrite can't mint signed read URLs with an API key.
- **Netlify Blobs**, always - it has no public-URL primitive. Use `download()`.

[Provider gaps](/docs/provider-gaps#url-requires-explicit-public-mode-configuration) has the per-adapter details.

`responseContentDisposition: "attachment"` forces signing even when `publicBaseUrl` is set - a permanent CDN URL has no signature to bind the override into, so the alternative would be silently dropping a security ask.

### `signedUploadUrl()` throws on unsupported adapters

`signedUploadUrl()` returns a discriminated PUT-or-POST contract so a browser can upload directly to the bucket. These adapters throw:

- **Bunny Storage** - writes require the `AccessKey` header.
- **Appwrite**, **PocketBase**, and **Netlify Blobs** - no presigned upload primitive. For Appwrite and PocketBase, mint a short-lived auth token for the client instead.
- **Box** and **Dropbox** - their upload endpoints need a request shape (Box's multipart `attributes` part, Dropbox's raw-body POST) that doesn't fit the PUT-or-POST contract.
- **FTP**, **SFTP**, and **WebDAV** - the protocols have no presigned-upload concept.
- **fs** - no built-in upload server, signer, or verifier. Upload through `files.upload()` or an application route that enforces controls server-side.
- **Convex** - `generateUploadUrl()` cannot bind the caller's SDK key or upload constraints. Upload through a Convex action with `files.upload()` instead.
- **R2 Workers binding without hybrid mode** - configure the binding _and_ HTTP credentials to enable signing.
- **Bun S3 with `contentType`** - Bun's presigned PUT URLs sign only the host header, so the Content-Type can't be enforced. Omit `contentType`, or use `s3()` / `s3Fetch()`, which sign it.

For these, upload through `files.upload()` on the server, or put the [UI gateway](/docs/ui) in front - it proxies the bytes through your server when the adapter can't sign an upload.

### `maxSize` and `minSize` throw where they can't be enforced

`maxSize` flips `signedUploadUrl()` from PUT to POST-with-policy to enforce the cap at the bucket via `content-length-range`, and a positive `minSize` rides on the same policy. Some adapters can't honor that contract, so they throw `Unsupported` rather than hand back an uncapped URL (`minSize: 0` asks for nothing and is always fine):

- **Azure**, **Supabase**, **Cloudinary**, and **UploadThing** throw on `maxSize` - none exposes a URL-level `content-length-range` equivalent. Cloudinary and UploadThing also throw on a positive `minSize` (`minSize: 0` is fine), and Cloudinary throws on `contentType`, since it detects the format from the uploaded bytes.
- **R2** throws on `maxSize` and a positive `minSize` - it doesn't implement the S3 POST Object API.
- **Backblaze B2** throws on `maxSize` and a positive `minSize` - its [S3-compatible API](https://www.backblaze.com/docs/cloud-storage-s3-compatible-api) doesn't support presigned POST uploads. Plain presigned PUT URLs still work.
- **Bun S3** and the **`fetch` S3 engine** ([`s3Fetch()`](/docs/adapters/s3-fetch), and R2 / MinIO / RustFS with `client: "fetch"`) throw on `maxSize` and a positive `minSize` - they mint presigned PUT URLs only, never POST policies.
- **`s3()`** and its S3-compatible wrappers throw on a positive `minSize` without `maxSize` - a presigned PUT has no size condition. Add `maxSize` and the call is accepted, because that routes to the POST policy, which enforces both.
- **Google Drive**, **OneDrive**, and **SharePoint** throw on `maxSize`/`minSize` - their upload sessions enforce no size range.
- **Vercel Blob** enforces `maxSize` at the CDN but has no minimum, so a positive `minSize` throws.

For these, enforce upload caps at your application gateway, or set the bucket / route limit at the provider's dashboard.

> **Always pass `maxSize`** on the providers that do support it. Without it, anyone with the URL can DoS your storage costs until `expiresIn` elapses.

### S3-compatible adapters are S3 wrappers

R2 (HTTP), MinIO, RustFS, DigitalOcean Spaces, Backblaze B2, Wasabi, Scaleway, OVH, Hetzner, Tigris, Storj, Filebase, Akamai, IDrive E2, Vultr, IBM COS, Oracle Cloud, Exoscale, Alibaba OSS, Tencent COS, Yandex, Archil, and Neon all wrap the `s3()` adapter with provider-specific defaults (endpoint, path-style, region quirks). If you hit an obscure failure on one of them, reproduce against `s3()` with the same options - if it repros, it's an S3 wire issue; if it doesn't, the wrapper's defaults are the culprit. R2, MinIO, and RustFS can also run on the `fetch` engine (`client: "fetch"`, which R2 picks by default inside Cloudflare Workers); reproduce those against [`s3Fetch()`](/docs/adapters/s3-fetch) instead.

### `copy` falls back to read+write

Server-side copy is used where the provider supports it; otherwise the adapter reads the source and writes the destination. For very large objects on adapters without server-side copy, this means bytes flow through your process - and several of them buffer the whole object in memory - so plan accordingly. The [`copy` reference](/docs/api/copy#the-read--write-fallback) lists which adapters fall back and how.

### `head` and `list` results have no body

`head`, `list`, and `search` return [`FileInfo`](/docs/api/stored-file), which has no `text()` / `arrayBuffer()` / `stream()` / `blob()`. Code written against v2, where those accessors existed and lazily downloaded the object, fails to typecheck: call `download(item.key)` for the bytes instead, and read `contentType` / `key` where it read `type` / `name`.

## Debugging tips

### Inspect the underlying error

```ts lineNumbers
try {
  await files.upload("a.png", file);
} catch (err) {
  if (err instanceof FilesError) {
    console.error(err.code, err.message);
    console.error(err.cause); // the original provider error
  }
}
```

For `@aws-sdk` errors, `cause` carries `$metadata` (request ID, HTTP status, attempts) and a typed `name` (`NoSuchKey`, `AccessDenied`, `SlowDown`, ...) that's more specific than the normalized `code`.

### Drop to the raw client

When you need a feature outside the unified surface, `files.raw` is typed per adapter and gives you the native client:

```ts lineNumbers
// With the s3() adapter, `files.raw` is typed as S3Client
const s3 = files.raw;
await s3.send(
  new PutObjectAclCommand({
    Bucket: "uploads",
    Key: "a.png",
    ACL: "public-read",
  })
);
```

On `r2()`, `files.raw` is the `S3Client`, `R2Bucket`, or aws4fetch `AwsClient` its mode uses, so it's typed as their union - narrow it first. See the [escape hatch](/docs/escape-hatch).

### CLI: `--verbose` and `--dry-run`

The CLI mirrors SDK semantics and is often the fastest way to confirm credentials and bucket layout:

```bash lineNumbers
files --provider r2 --bucket uploads --verbose head missing.txt
# adds stack traces to the error envelope

files --provider r2 --bucket uploads --dry-run delete reports/q1.pdf
# → {"action":"delete","dryRun":true,"provider":"r2","keys":["reports/q1.pdf"]}
```

Exit codes are stable: `0` ok, `1` `NotFound` (or `exists → false`), `2` a usage error (`Invalid`, `Unsupported`, `ReadOnly`, a bad flag), `3` `Unauthorized`, `4` `Conflict`, `5` `Provider`.

### Swap to the `fs` adapter

When you suspect the problem is wiring rather than the provider, swap to `files-sdk/fs` against a temp directory. The same call sites that work against `fs` will tell you whether the bug is in your code or in adapter / credential setup.

```ts lineNumbers
import { fs } from "files-sdk/fs";
const files = new Files({ adapter: fs({ root: "/tmp/store" }) });
```
