From 3cf97b14a7bd8582f7f2af852ac2dd4328973a60 Mon Sep 17 00:00:00 2001 From: Fabio Benedetti Date: Wed, 27 Sep 2023 15:31:41 +0200 Subject: [PATCH] [blob] review error codes (#406) * [blob] review error codes * update readme, fix head type * apply changeset * Update gold-donuts-fold.md --------- Co-authored-by: Vincent Voyer --- .changeset/gold-donuts-fold.md | 6 ++ packages/blob/README.md | 9 +-- packages/blob/src/helpers.ts | 79 +++++++++++++++++++++++-- packages/blob/src/index.browser.test.ts | 1 + packages/blob/src/index.node.test.ts | 40 ++++++++++--- packages/blob/src/index.ts | 42 ++++--------- packages/blob/src/put.ts | 11 +--- 7 files changed, 133 insertions(+), 55 deletions(-) create mode 100644 .changeset/gold-donuts-fold.md diff --git a/.changeset/gold-donuts-fold.md b/.changeset/gold-donuts-fold.md new file mode 100644 index 000000000..9e3f51b3d --- /dev/null +++ b/.changeset/gold-donuts-fold.md @@ -0,0 +1,6 @@ +--- +'@vercel/blob': minor +--- + +This new version brings consistent and detailed errors about request failures (store does not exist, blob does not exist, store is suspended...). +BREAKING CHANGE: head() will now throw instead of returning null when the blob does not exist. diff --git a/packages/blob/README.md b/packages/blob/README.md index 476a1271a..aa3a0a731 100644 --- a/packages/blob/README.md +++ b/packages/blob/README.md @@ -57,7 +57,7 @@ async function del( ### head(url, options) -Get the metadata of a blob by its full URL. Returns `null` when the blob does not exist. +Get the metadata of a blob by its full URL. Throws a `BlobNotFoundError` when the blob does not exist. ```ts async function head( @@ -73,7 +73,7 @@ async function head( contentDisposition: string; url: string; cacheControl: string; -} | null> {} +}> {} ``` ### list(options) @@ -206,6 +206,8 @@ All methods of this module will throw if the request fails for either: - missing parameters - bad token or token doesn't have access to the resource +- suspended Blob store +- Blob file or Blob store not found - or in the event of unknown errors You should acknowledge that in your code by wrapping our methods in a try/catch block: @@ -279,10 +281,9 @@ export default defineConfig(({ mode }) => { import { put } from '@vercel/blob'; + import { BLOB_TOKEN } from '$env/static/private'; -const kv = await head("filepath", { +const blob = await head("filepath", { - token: '', + token: BLOB_TOKEN, }); -await kv.set('key', 'value'); ``` diff --git a/packages/blob/src/helpers.ts b/packages/blob/src/helpers.ts index ee5d3c691..667ef33f7 100644 --- a/packages/blob/src/helpers.ts +++ b/packages/blob/src/helpers.ts @@ -1,5 +1,8 @@ // common util interface for blob raw commands, not meant to be used directly // this is why it's not exported from index/client + +import { type Response } from 'undici'; + export interface BlobCommandOptions { token?: string; } @@ -24,17 +27,81 @@ export class BlobError extends Error { } } -export class BlobAccessError extends Error { +export class BlobAccessError extends BlobError { + constructor() { + super('Access denied, please provide a valid token for this resource'); + } +} + +export class BlobStoreNotFoundError extends BlobError { constructor() { - super( - 'Vercel Blob: Access denied, please provide a valid token for this resource' - ); + super('This store does not exist'); } } -export class BlobUnknownError extends Error { +export class BlobStoreSuspendedError extends BlobError { constructor() { - super('Vercel Blob: Unknown error, please visit https://vercel.com/help'); + super('This store has been suspended'); + } +} + +export class BlobUnknownError extends BlobError { + constructor() { + super('Unknown error, please visit https://vercel.com/help'); + } +} + +export class BlobNotFoundError extends BlobError { + constructor() { + super('The requested blob does not exist'); + } +} + +type BlobApiErrorCodes = + | 'store_suspended' + | 'forbidden' + | 'not_found' + | 'unknown_error' + | 'bad_request' + | 'store_not_found' + | 'not_allowed'; + +interface BlobApiError { + error?: { code?: BlobApiErrorCodes; message?: string }; +} + +export async function validateBlobApiResponse( + response: Response +): Promise { + if (!response.ok) { + if (response.status >= 500) { + throw new BlobUnknownError(); + } else { + let data: unknown; + try { + data = await response.json(); + } catch { + throw new BlobUnknownError(); + } + const error = (data as BlobApiError).error; + + switch (error?.code) { + case 'store_suspended': + throw new BlobStoreSuspendedError(); + case 'forbidden': + throw new BlobAccessError(); + case 'not_found': + throw new BlobNotFoundError(); + case 'store_not_found': + throw new BlobStoreNotFoundError(); + case 'bad_request': + throw new BlobError(error.message ?? 'Bad request'); + case 'unknown_error': + case 'not_allowed': + default: + throw new BlobUnknownError(); + } + } } } diff --git a/packages/blob/src/index.browser.test.ts b/packages/blob/src/index.browser.test.ts index 6d690ec46..e576682af 100644 --- a/packages/blob/src/index.browser.test.ts +++ b/packages/blob/src/index.browser.test.ts @@ -6,6 +6,7 @@ jest.mock('undici', () => ({ fetch: (): unknown => Promise.resolve({ status: 200, + ok: true, json: () => Promise.resolve({ url: `${BLOB_STORE_BASE_URL}/foo-id.txt`, diff --git a/packages/blob/src/index.node.test.ts b/packages/blob/src/index.node.test.ts index 1297eac0e..c8acda428 100644 --- a/packages/blob/src/index.node.test.ts +++ b/packages/blob/src/index.node.test.ts @@ -66,10 +66,10 @@ describe('blob client', () => { path: () => true, method: 'GET', }) - .reply(404, 'Not found'); + .reply(404, { error: { code: 'not_found', message: 'Not found' } }); - await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).resolves.toEqual( - null + await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( + new Error('Vercel Blob: The requested blob does not exist') ); }); @@ -79,7 +79,7 @@ describe('blob client', () => { path: () => true, method: 'GET', }) - .reply(403, 'Invalid token'); + .reply(403, { error: { code: 'forbidden' } }); await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( @@ -112,6 +112,32 @@ describe('blob client', () => { ) ); }); + + it('should throw when store is suspended', async () => { + mockClient + .intercept({ + path: () => true, + method: 'GET', + }) + .reply(403, { error: { code: 'store_suspended' } }); + + await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( + new Error('Vercel Blob: This store has been suspended') + ); + }); + + it('should throw when store does NOT exist', async () => { + mockClient + .intercept({ + path: () => true, + method: 'GET', + }) + .reply(403, { error: { code: 'store_not_found' } }); + + await expect(head(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( + new Error('Vercel Blob: This store does not exist') + ); + }); }); describe('del', () => { @@ -181,7 +207,7 @@ describe('blob client', () => { path: () => true, method: 'POST', }) - .reply(403, 'Invalid token'); + .reply(403, { error: { code: 'forbidden' } }); await expect(del(`${BLOB_STORE_BASE_URL}/foo-id.txt`)).rejects.toThrow( new Error( @@ -266,7 +292,7 @@ describe('blob client', () => { path: () => true, method: 'GET', }) - .reply(403, 'Invalid token'); + .reply(403, { error: { code: 'forbidden' } }); await expect(list()).rejects.toThrow( new Error( @@ -358,7 +384,7 @@ describe('blob client', () => { path: () => true, method: 'PUT', }) - .reply(403, 'Invalid token'); + .reply(403, { error: { code: 'forbidden' } }); await expect( put('foo.txt', 'Test Body', { diff --git a/packages/blob/src/index.ts b/packages/blob/src/index.ts index 2b99df3a0..606841e2f 100644 --- a/packages/blob/src/index.ts +++ b/packages/blob/src/index.ts @@ -4,17 +4,23 @@ import { fetch } from 'undici'; import type { BlobCommandOptions } from './helpers'; import { - BlobAccessError, - BlobUnknownError, getApiUrl, getApiVersionHeader, getTokenFromOptionsOrEnv, + validateBlobApiResponse, } from './helpers'; import type { PutCommandOptions } from './put'; import { createPutMethod } from './put'; // expose the BlobError types -export { BlobAccessError, BlobError, BlobUnknownError } from './helpers'; +export { + BlobAccessError, + BlobError, + BlobUnknownError, + BlobStoreNotFoundError, + BlobStoreSuspendedError, + BlobNotFoundError, +} from './helpers'; export type { PutBlobResult } from './put'; // vercelBlob.put() @@ -42,13 +48,7 @@ export async function del( body: JSON.stringify({ urls: Array.isArray(url) ? url : [url] }), }); - if (blobApiResponse.status !== 200) { - if (blobApiResponse.status === 403) { - throw new BlobAccessError(); - } else { - throw new BlobUnknownError(); - } - } + await validateBlobApiResponse(blobApiResponse); (await blobApiResponse.json()) as DeleteBlobApiResponse; } @@ -72,7 +72,7 @@ interface HeadBlobApiResponse extends Omit { export async function head( url: string, options?: BlobCommandOptions -): Promise { +): Promise { const headApiUrl = new URL(getApiUrl()); headApiUrl.searchParams.set('url', url); @@ -84,17 +84,7 @@ export async function head( }, }); - if (blobApiResponse.status === 404) { - return null; - } - - if (blobApiResponse.status !== 200) { - if (blobApiResponse.status === 403) { - throw new BlobAccessError(); - } else { - throw new BlobUnknownError(); - } - } + await validateBlobApiResponse(blobApiResponse); const headResult = (await blobApiResponse.json()) as HeadBlobApiResponse; @@ -151,13 +141,7 @@ export async function list( }, }); - if (blobApiResponse.status !== 200) { - if (blobApiResponse.status === 403) { - throw new BlobAccessError(); - } else { - throw new BlobUnknownError(); - } - } + await validateBlobApiResponse(blobApiResponse); const results = (await blobApiResponse.json()) as ListBlobApiResponse; diff --git a/packages/blob/src/put.ts b/packages/blob/src/put.ts index ed1871e37..970462ea2 100644 --- a/packages/blob/src/put.ts +++ b/packages/blob/src/put.ts @@ -4,12 +4,11 @@ import { fetch } from 'undici'; import type { ClientPutCommandOptions } from './client'; import type { BlobCommandOptions } from './helpers'; import { - BlobAccessError, - BlobUnknownError, getApiUrl, getApiVersionHeader, getTokenFromOptionsOrEnv, BlobError, + validateBlobApiResponse, } from './helpers'; export interface PutCommandOptions extends BlobCommandOptions { @@ -117,13 +116,7 @@ export function createPutMethod< duplex: 'half', }); - if (blobApiResponse.status !== 200) { - if (blobApiResponse.status === 403) { - throw new BlobAccessError(); - } else { - throw new BlobUnknownError(); - } - } + await validateBlobApiResponse(blobApiResponse); const blobResult = (await blobApiResponse.json()) as PutBlobApiResponse;