Skip to content

Commit

Permalink
[blob] review error codes (#406)
Browse files Browse the repository at this point in the history
* [blob] review error codes

* update readme, fix head type

* apply changeset

* Update gold-donuts-fold.md

---------

Co-authored-by: Vincent Voyer <[email protected]>
  • Loading branch information
correttojs and vvo authored Sep 27, 2023
1 parent 10dd94f commit 3cf97b1
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 55 deletions.
6 changes: 6 additions & 0 deletions .changeset/gold-donuts-fold.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions packages/blob/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -73,7 +73,7 @@ async function head(
contentDisposition: string;
url: string;
cacheControl: string;
} | null> {}
}> {}
```

### list(options)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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>',
+ token: BLOB_TOKEN,
});

await kv.set('key', 'value');
```
79 changes: 73 additions & 6 deletions packages/blob/src/helpers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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<void> {
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();
}
}
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/blob/src/index.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
40 changes: 33 additions & 7 deletions packages/blob/src/index.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
);
});

Expand All @@ -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(
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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', {
Expand Down
42 changes: 13 additions & 29 deletions packages/blob/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
Expand All @@ -72,7 +72,7 @@ interface HeadBlobApiResponse extends Omit<HeadBlobResult, 'uploadedAt'> {
export async function head(
url: string,
options?: BlobCommandOptions
): Promise<HeadBlobResult | null> {
): Promise<HeadBlobResult> {
const headApiUrl = new URL(getApiUrl());
headApiUrl.searchParams.set('url', url);

Expand All @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
11 changes: 2 additions & 9 deletions packages/blob/src/put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down

1 comment on commit 3cf97b1

@vercel
Copy link

@vercel vercel bot commented on 3cf97b1 Sep 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.