Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
trentcharlie authored Nov 6, 2024
2 parents 2c14c6d + 6f654d9 commit a7b9a5b
Show file tree
Hide file tree
Showing 54 changed files with 2,329 additions and 1,214 deletions.
5 changes: 0 additions & 5 deletions .changeset/chilled-spies-train.md

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
node-version: [18.x, 20.x, 22.x]
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"build": "turbo build",
"integration-test": "turbo integration-test",
"lint": "turbo lint -- --max-warnings=0",
"prepare": "husky install",
"prepare": "husky",
"prettier-check": "prettier --check .",
"prettier-fix": "prettier --write .",
"publint": "turbo publint",
Expand All @@ -29,18 +29,18 @@
},
"prettier": "@vercel/style-guide/prettier",
"devDependencies": {
"@changesets/cli": "2.27.1",
"@changesets/cli": "2.27.9",
"@vercel/style-guide": "5.2.0",
"eslint": "8.56.0",
"eslint-config-custom": "workspace:*",
"husky": "9.0.11",
"husky": "9.1.6",
"jest": "29.7.0",
"lint-staged": "15.2.2",
"prettier": "3.2.5",
"publint": "0.2.7",
"ts-jest": "29.1.2",
"lint-staged": "15.2.10",
"prettier": "3.3.3",
"publint": "0.2.11",
"ts-jest": "29.2.5",
"turbo": "1.12.4",
"typescript": "^5.3.3"
"typescript": "5.6.2"
},
"packageManager": "[email protected]",
"engines": {
Expand Down
42 changes: 42 additions & 0 deletions packages/blob/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# @vercel/blob

## 0.25.1

### Patch Changes

- d58f9de: fix(blob): provide custom errors for expired client tokens and pathname mismatch

## 0.25.0

### Minor Changes

- 61b5939: BREAKING CHANGE, we're no more accepting non-encoded versions of ?, # and // in pathnames. If you want to use such characters in your pathnames then you will need to encode them.

## 0.24.1

### Patch Changes

- 37d84ef: Throw specific error (BlobContentTypeNotAllowed) when file type doesn't match
- da87e89: Fix bad detection of Request being a plain object

## 0.24.0

### Minor Changes

- 8098803: Add createFolder method. Warning, if you were using the standard put() method to created fodlers, this will now fail and you must move to createFolder() instead.

### Patch Changes

- 8d7e8b9: Limit pathname length to 950 to respect internal limitations and provide better early DX.

## 0.23.4

### Patch Changes

- 30401f4: fix(blob): Throw when trying to upload a plain JS object

## 0.23.3

### Patch Changes

- c0bdd40: fix(blob): also retry internal_server_error
- c5d10d7: chore(blob): add observability headers

## 0.23.2

### Patch Changes
Expand Down
10 changes: 5 additions & 5 deletions packages/blob/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vercel/blob",
"version": "0.23.2",
"version": "0.25.1",
"description": "The Vercel Blob JavaScript API client",
"homepage": "https://vercel.com/storage/blob",
"repository": {
Expand Down Expand Up @@ -75,15 +75,15 @@
"@edge-runtime/types": "2.2.9",
"@types/async-retry": "1.4.8",
"@types/bytes": "3.1.4",
"@types/jest": "29.5.12",
"@types/node": "20.11.19",
"@types/jest": "29.5.13",
"@types/node": "22.7.3",
"eslint": "8.56.0",
"eslint-config-custom": "workspace:*",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"ts-jest": "29.1.2",
"ts-jest": "29.2.5",
"tsconfig": "workspace:*",
"tsup": "8.0.2"
"tsup": "8.3.0"
},
"engines": {
"node": ">=16.14"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
BlobStoreNotFoundError,
BlobStoreSuspendedError,
BlobUnknownError,
BlobContentTypeNotAllowedError,
requestApi,
} from './api';
import { BlobError } from './helpers';
Expand Down Expand Up @@ -63,6 +64,8 @@ describe('api', () => {
body: '{"foo":"bar"}',
headers: {
authorization: 'Bearer 123',
'x-api-blob-request-attempt': '0',
'x-api-blob-request-id': expect.any(String) as string,
'x-api-version': '7',
},
method: 'POST',
Expand Down Expand Up @@ -101,18 +104,25 @@ describe('api', () => {
it.each([
[300, 'store_suspended', BlobStoreSuspendedError],
[400, 'forbidden', BlobAccessError],
[
400,
'forbidden',
BlobContentTypeNotAllowedError,
'"contentType" text/plain is not allowed',
],
[500, 'not_found', BlobNotFoundError],
[600, 'bad_request', BlobError],
[700, 'store_not_found', BlobStoreNotFoundError],
[800, 'not_allowed', BlobUnknownError],
[800, 'not_allowed', BlobUnknownError],
])(
`should not retry '%s %s' response error response`,
async (status, code, error) => {
async (status, code, error, message = '') => {
const fetchMock = jest.spyOn(undici, 'fetch').mockImplementation(
jest.fn().mockResolvedValue({
status,
ok: false,
json: () => Promise.resolve({ error: { code } }),
json: () => Promise.resolve({ error: { code, message } }),
}),
);

Expand Down
88 changes: 86 additions & 2 deletions packages/blob/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,44 @@ import { debug } from './debug';
import type { BlobCommandOptions } from './helpers';
import { BlobError, getTokenFromOptionsOrEnv } from './helpers';

// maximum pathname length is:
// 1024 (provider limit) - 26 chars (vercel internal suffixes) - 31 chars (blob `-randomId` suffix) = 967
// we round it to 950 to make it more human friendly, and we apply the limit whatever the value of
// addRandomSuffix is, to make it consistent
export const MAXIMUM_PATHNAME_LENGTH = 950;

export class BlobAccessError extends BlobError {
constructor() {
super('Access denied, please provide a valid token for this resource.');
}
}

export class BlobContentTypeNotAllowedError extends BlobError {
constructor(message: string) {
super(`Content type mismatch, ${message}.`);
}
}

export class BlobPathnameMismatchError extends BlobError {
constructor(message: string) {
super(
`Pathname mismatch, ${message}. Check the pathname used in upload() or put() matches the one from the client token.`,
);
}
}

export class BlobClientTokenExpiredError extends BlobError {
constructor() {
super('Client token has expired.');
}
}

export class BlobFileTooLargeError extends BlobError {
constructor(message: string) {
super(`File is too large, ${message}.`);
}
}

export class BlobStoreNotFoundError extends BlobError {
constructor() {
super('This store does not exist.');
Expand Down Expand Up @@ -70,7 +102,11 @@ type BlobApiErrorCodes =
| 'store_not_found'
| 'not_allowed'
| 'service_unavailable'
| 'rate_limited';
| 'rate_limited'
| 'content_type_not_allowed'
| 'client_token_pathname_mismatch'
| 'client_token_expired'
| 'file_too_large';

export interface BlobApiError {
error?: { code?: BlobApiErrorCodes; message?: string };
Expand Down Expand Up @@ -146,6 +182,28 @@ async function getBlobError(
code = 'unknown_error';
}

// Now that we have multiple API clients out in the wild handling errors, we can't just send a different
// error code for this type of error. We need to add a new field in the API response to handle this correctly,
// but for now, we can just check the message.
if (message?.includes('contentType') && message.includes('is not allowed')) {
code = 'content_type_not_allowed';
}

if (
message?.includes('"pathname"') &&
message.includes('does not match the token payload')
) {
code = 'client_token_pathname_mismatch';
}

if (message === 'Token expired') {
code = 'client_token_expired';
}

if (message?.includes('the file length cannot be greater than')) {
code = 'file_too_large';
}

let error: BlobError;
switch (code) {
case 'store_suspended':
Expand All @@ -154,6 +212,21 @@ async function getBlobError(
case 'forbidden':
error = new BlobAccessError();
break;
case 'content_type_not_allowed':
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- TS, be smarter
error = new BlobContentTypeNotAllowedError(message!);
break;
case 'client_token_pathname_mismatch':
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- TS, be smarter
error = new BlobPathnameMismatchError(message!);
break;
case 'client_token_expired':
error = new BlobClientTokenExpiredError();
break;
case 'file_too_large':
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- TS, be smarter
error = new BlobFileTooLargeError(message!);
break;
case 'not_found':
error = new BlobNotFoundError();
break;
Expand Down Expand Up @@ -188,6 +261,10 @@ export async function requestApi<TResponse>(
const token = getTokenFromOptionsOrEnv(commandOptions);
const extraHeaders = getProxyThroughAlternativeApiHeaderFromEnv();

const [, , , storeId = ''] = token.split('_');
const requestId = `${storeId}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
let retryCount = 0;

const apiResponse = await retry(
async (bail) => {
let res: Response;
Expand All @@ -197,6 +274,8 @@ export async function requestApi<TResponse>(
res = await fetch(getApiUrl(pathname), {
...init,
headers: {
'x-api-blob-request-id': requestId,
'x-api-blob-request-attempt': String(retryCount),
'x-api-version': apiVersion,
authorization: `Bearer ${token}`,
...extraHeaders,
Expand All @@ -221,7 +300,11 @@ export async function requestApi<TResponse>(
const { code, error } = await getBlobError(res);

// only retry for certain errors
if (code === 'unknown_error' || code === 'service_unavailable') {
if (
code === 'unknown_error' ||
code === 'service_unavailable' ||
code === 'internal_server_error'
) {
throw error;
}

Expand All @@ -232,6 +315,7 @@ export async function requestApi<TResponse>(
retries: getRetries(),
onRetry: (error) => {
debug(`retrying API request to ${pathname}`, error.message);
retryCount = retryCount + 1;
},
},
);
Expand Down
Loading

0 comments on commit a7b9a5b

Please sign in to comment.