Troubleshooting
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.
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 "Provider":
console.error(err.cause);
break;
}
}
throw err;
}
Logging note:
causecan carry request IDs, response headers, and partial request metadata from@aws-sdkand friends. If you forwardFilesErrorto logs that cross a trust boundary, strip or whitelistcauserather thanJSON.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), anddeleteon strict providers will throw this.existsreturnsfalseinstead of throwing. Any other failure still throws, so ifexistsrejects, the underlying error wasn’t a missing-key signal - checkcause.deleteis idempotent on providers that treat it that way (S3, R2, Vercel Blob, FTP, Bunny Storage, the localfsadapter): a missing key is a no-op, but real failures still surface - FTP maps a refused delete of an existing file (550) toUnauthorized, and Bunny Storage reports auth and 5xx errors. Strict providers (some SaaS and BaaS backends) throwNotFoundfor missing keys.
Unauthorized
Credentials are missing, expired, or insufficient for the operation.
- Missing env vars (
AWS_ACCESS_KEY_ID,BLOB_READ_WRITE_TOKEN,GOOGLE_APPLICATION_CREDENTIALS, …). - 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, plus the equivalentfile(key)helpers. files.rawis not governed by this flag. If the mutation came throughraw, the SDK’s read-only guard will not see it.
Provider
The catch-all. Network errors, malformed responses, provider outages, and anything that doesn’t map cleanly to the codes above. 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
publicBaseUrland no HTTP credentials - the binding API has no URL primitive. Either configurepublicBaseUrl(custom domain orr2.dev) or pass HTTP credentials alongside the binding to enable signing. - Bunny Storage without
publicBaseUrl- the Storage API requires anAccessKeyheader on every request, so there’s nothing to hand to a browser. Configure your Pull Zone aspublicBaseUrl. - FTP, SFTP, and WebDAV without
publicBaseUrl- the protocols have no signing primitive. PointpublicBaseUrlat 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 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
AccessKeyheader. - 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
attributespart, 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 withfiles.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. OmitcontentType, or uses3()/s3Fetch(), which sign it.
For these, upload through files.upload() on the server, or put the UI gateway in front - it proxies the bytes through your server when the adapter can’t sign an upload.
maxSize throws where it can’t be enforced
maxSize flips signedUploadUrl() from PUT to POST-with-policy to enforce the cap at the bucket via content-length-range. Some adapters can’t honor that contract, so they throw rather than hand back an uncapped URL:
- Azure, Supabase, Cloudinary, and UploadThing throw on
maxSize- none exposes a URL-levelcontent-length-rangeequivalent. Cloudinary and UploadThing also throw on a positiveminSize(minSize: 0is fine), and Cloudinary throws oncontentType, since it detects the format from the uploaded bytes. - R2 throws on
maxSize- it doesn’t implement the S3 POST Object API. - Bun S3 and the
fetchS3 engine (s3Fetch(), and R2 / MinIO / RustFS withclient: "fetch") throw onmaxSize- they mint presigned PUT URLs only, never POST policies. - Google Drive, OneDrive, and SharePoint throw on
maxSize/minSize- their upload sessions enforce no size range. - Vercel Blob enforces
maxSizeat the CDN but has no minimum, so a positiveminSizethrows.
For these, enforce upload caps at your application gateway, or set the bucket / route limit at the provider’s dashboard.
Always pass
maxSizeon the providers that do support it. Without it, anyone with the URL can DoS your storage costs untilexpiresInelapses.
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() 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 lists which adapters fall back and how.
Lazy bodies in head and list
Body accessors (arrayBuffer, text, stream, blob) on results from head and list lazy-fetch on call. A loop over items that touches .arrayBuffer() issues one GET per item. If you only want the metadata, don’t touch the accessors.
Debugging tips
Inspect the underlying error
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:
const s3 = files.raw; // typed as S3Client
await s3.send(
new PutObjectAclCommand({
Bucket: "uploads",
Key: "a.png",
ACL: "public-read",
})
);
CLI: --verbose and --dry-run
The CLI mirrors SDK semantics and is often the fastest way to confirm credentials and bucket layout:
files --provider s3 --bucket uploads --verbose head missing.txt
# adds stack traces to the error envelope
files --provider s3 --bucket uploads --dry-run delete reports/q1.pdf
# → {"action":"delete","dryRun":true,"provider":"s3","keys":["reports/q1.pdf"]}
Exit codes are stable: 0 ok, 1 NotFound (or exists → false), 2 Provider, 3 Unauthorized, 4 Conflict.
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.
import { fs } from "files-sdk/fs";
const files = new Files({ adapter: fs({ root: "/tmp/store" }) });