Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,41 @@ type UploadMediaRequest =
function uploadMedia(params: UploadMediaRequest): Promise<{ url: string }>;
```

## エラーハンドリング

microCMS APIへのリクエストに失敗した場合は、`isMicroCMSRequestError`を使用して、HTTPステータスコード、リクエスト先URL、元のネットワークエラーを参照できます。

```typescript
import { createClient, isMicroCMSRequestError } from 'microcms-js-sdk';

const client = createClient({
serviceDomain: 'serviceDomain',
apiKey: 'apiKey',
});

try {
await client.getList({ endpoint: 'blog' });
} catch (error) {
if (isMicroCMSRequestError(error)) {
console.log(error.status);
console.log(error.url);
console.log(error.originalError);
}
}
```

| プロパティ | HTTPエラー | ネットワークエラー |
| --------------- | ----------------------------- | --------------------------- |
| `status` | HTTPステータスコード | `undefined` |
| `url` | リクエスト先URL | リクエスト先URL |
| `originalError` | `undefined` | `fetch`が投げた元の値 |

`url`に`draftKey`が含まれる場合、その値は`***`にマスクされます。リクエストヘッダー、リクエストボディ、`Response`オブジェクトはエラーへ追加されません。

`originalError`の内容はNode.js、ブラウザ、Edge Runtimeなどの実行環境によって異なり、SDKとして形式を保証しません。

追加されるプロパティは非列挙です。そのため、既存の`message`、`toString()`、`Object.keys()`、`JSON.stringify()`の結果には影響しません。

## ヒント

### 読み取り用と書き込み用で別々のAPIキーを使用する
Expand Down
35 changes: 35 additions & 0 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,41 @@ type UploadMediaRequest =
function uploadMedia(params: UploadMediaRequest): Promise<{ url: string }>;
```

## Error handling

When a request to the microCMS API fails, use `isMicroCMSRequestError` to access the HTTP status code, request URL, and original network error.

```typescript
import { createClient, isMicroCMSRequestError } from 'microcms-js-sdk';

const client = createClient({
serviceDomain: 'serviceDomain',
apiKey: 'apiKey',
});

try {
await client.getList({ endpoint: 'blog' });
} catch (error) {
if (isMicroCMSRequestError(error)) {
console.log(error.status);
console.log(error.url);
console.log(error.originalError);
}
}
```

| Property | HTTP error | Network error |
| --------------- | ---------------- | ------------------------------ |
| `status` | HTTP status code | `undefined` |
| `url` | Request URL | Request URL |
| `originalError` | `undefined` | Original value thrown by `fetch` |

If `url` contains a `draftKey`, its value is masked as `***`. Request headers, request bodies, and the `Response` object are not added to the error.

The contents of `originalError` depend on the runtime environment, such as Node.js, browsers, or Edge Runtime, and are not guaranteed by this SDK.

The additional properties are non-enumerable, so they do not affect the existing `message`, `toString()`, `Object.keys()`, or `JSON.stringify()` results.

## Tips

### Separate API keys for read and write
Expand Down
28 changes: 19 additions & 9 deletions src/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* https://github.com/microcmsio/microcms-js-sdk
*/
import retry from 'async-retry';
import { createMicroCMSRequestError } from './lib/error';
import { generateFetchClient } from './lib/fetch';
import {
CreateRequest,
Expand Down Expand Up @@ -96,10 +97,13 @@ export const createClient = ({
const message = await getMessageFromResponse(response);

return bail(
new Error(
`fetch API response status: ${response.status}${
message ? `\n message is \`${message}\`` : ''
}`,
createMicroCMSRequestError(
new Error(
`fetch API response status: ${response.status}${
message ? `\n message is \`${message}\`` : ''
}`,
),
{ status: response.status, url },
),
);
}
Expand All @@ -109,10 +113,13 @@ export const createClient = ({
const message = await getMessageFromResponse(response);

return Promise.reject(
new Error(
`fetch API response status: ${response.status}${
message ? `\n message is \`${message}\`` : ''
}`,
createMicroCMSRequestError(
new Error(
`fetch API response status: ${response.status}${
message ? `\n message is \`${message}\`` : ''
}`,
),
{ status: response.status, url },
),
);
}
Expand All @@ -130,7 +137,10 @@ export const createClient = ({
}

return Promise.reject(
new Error(`Network Error.\n Details: ${error.message ?? ''}`),
createMicroCMSRequestError(
new Error(`Network Error.\n Details: ${error.message ?? ''}`),
{ url, originalError: error },
),
);
}
},
Expand Down
17 changes: 12 additions & 5 deletions src/createManagementClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createMicroCMSRequestError } from './lib/error';
import { generateFetchClient } from './lib/fetch';
import { MicroCMSManagementClient, UploadMediaRequest } from './types';
import {
Expand Down Expand Up @@ -72,7 +73,10 @@ export const createManagementClient = ({
}

return Promise.reject(
new Error(`Network Error.\n Details: ${error.message ?? ''}`),
createMicroCMSRequestError(
new Error(`Network Error.\n Details: ${error.message ?? ''}`),
{ url, originalError: error },
),
);
}

Expand All @@ -81,10 +85,13 @@ export const createManagementClient = ({
const message = await getMessageFromResponse(response);

return Promise.reject(
new Error(
`fetch API response status: ${response.status}${
message ? `\n message is \`${message}\`` : ''
}`,
createMicroCMSRequestError(
new Error(
`fetch API response status: ${response.status}${
message ? `\n message is \`${message}\`` : ''
}`,
),
{ status: response.status, url },
),
);
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { createClient } from './createClient';
export { createManagementClient } from './createManagementClient';
export { isMicroCMSRequestError } from './lib/error';
export * from './types';
59 changes: 59 additions & 0 deletions src/lib/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { MicroCMSRequestError } from '../types';

interface CreateMicroCMSRequestErrorOptions {
status?: number;
url: string;
originalError?: unknown;
}

const maskDraftKey = (url: string): string => {
return url.replace(/([?&]draftKey=)[^&#]*/g, '$1***');
};

export const createMicroCMSRequestError = (
error: Error,
{ status, url, originalError }: CreateMicroCMSRequestErrorOptions,
): MicroCMSRequestError => {
// Errorとしての実行時の同一性と既存のログ出力を維持するため、独自クラスは生成せず、
// 既存のErrorに追加情報を付与する。独自のErrorクラスは将来のメジャーリリースで再検討できる。
// 設計背景: https://github.com/microcmsio/microcms-js-sdk/pull/109
const microCMSRequestError = error as MicroCMSRequestError;

Object.defineProperties(microCMSRequestError, {
status: {
value: status,
enumerable: false,
configurable: true,
writable: true,
},
url: {
value: maskDraftKey(url),
enumerable: false,
configurable: true,
writable: true,
},
originalError: {
value: originalError,
enumerable: false,
configurable: true,
writable: true,
},
});
Comment on lines +22 to +41

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

なんとなくですが、Errorクラスを拡張して新たにErrorクラスを作るのが一般的ですかね?
Object.definePropertiesを選んだ理由などがあれば知りたいです!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@dc7290
ありがとうございます!
こちらはAIとのやり取りでも論点になったところでしたね・・!

今回はマイナー/パッチリリースを想定しており、既存のErrorのconstructor、name、通常のconsole.log表示、列挙・シリアライズ結果への影響をできる限り避けることを優先しました。
そのため、従来どおり生成したErrorに非列挙プロパティを追加する方式を選択しています。

メジャーバージョンアップのタイミングであれば、破壊的変更として影響範囲を明示したうえで、Errorを継承した独自classへ移行する方が、良いのかなとは思っています。

今回は後方互換性を優先したこの方式で進めたいと考えていますが、違和感があれば相談させてください!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@Sinhalite
なるほどですね・・・!

Errorクラスを拡張した上で後方互換性を保つ方法がないか調べてましたが、かなり複雑になるのと完璧には保てなさそうだったので、
マイナーバージョンでのリリースを考えると今回の方法で良さそうです!👍

1点、理想となる実装パターンと次のメジャーバージョンで移行したい旨をコメントに残しておけるとより良さそうですかね?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

あ、もしくは今回メジャーバージョンリリースするという手は無しなんですかね??
今回の実装でリリースした後に、独自classへ移行してもらう方がユーザーに手間をかけてしまうような気もしており、、、

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@dc7290
諸々検討ありがとうございます!

1点、理想となる実装パターンと次のメジャーバージョンで移行したい旨をコメントに残しておけるとより良さそうですかね?

マイナーバージョンで進める場合はこちらは残しておくようにします!

あ、もしくは今回メジャーバージョンリリースするという手は無しなんですかね??
今回の実装でリリースした後に、独自classへ移行してもらう方がユーザーに手間をかけてしまうような気もしており、、、

選択肢としてはありそうです!
ただ、今回のエラーハンドリング改善だけを理由にメジャーバージョンを上げるのは、変更内容とのバランスを考えると少し重いように感じています。
将来的な移行コストもそこまで大きくなさそうなので、ほかの破壊的変更と合わせたメジャーアップデートのタイミングで、改めて検討するのがよいかなと考えた部分ではありました!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@Sinhalite

将来的な移行コストもそこまで大きくなさそうなので、ほかの破壊的変更と合わせたメジャーアップデートのタイミングで、改めて検討するのがよいかなと考えた部分ではありました!

ここは少し気になっていて、「他の破壊的変更と合わせる」だと、まとまった分だけ1回のアップデートが重くなる可能性もあるなと思っています。
また、この先あるかわからない破壊的変更を前提に、公開APIの理想形を先送りする、という立て付けもやや弱い気がしています・・・!

とはいえ、JS SDKはユーザーも多いので気軽にメジャーは上げたくない、という点には同意です!
今回ユーザーが得たい価値(status / url / originalError と type guard)は、現状の方式でも届けられるので、マイナーで進める判断自体は妥当だと思いました。

独自 Error class については、「他の破壊的変更待ち」ではなく、
「Error の identity(constructor / name / instanceof)を変える価値が、単独のメジャーに見合うと判断したタイミング」
で改めて入れる、くらいの基準にしておけると良さそうです。
理想形と移行意図のコメントは残してもらえると助かります!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@dc7290
ありがとうございます!

たしかにメジャーアップデートの条件が曖昧に受け取られそうとは思ったため、提案いただいた価値ベースの記述に変更してます!
(コメントだけだと長くなりそうだったので、一部PRをリンクする形としました。)
2790315


return microCMSRequestError;
};

export const isMicroCMSRequestError = (
error: unknown,
): error is MicroCMSRequestError => {
if (!(error instanceof Error)) return false;

return (
Object.prototype.hasOwnProperty.call(error, 'status') &&
(typeof (error as MicroCMSRequestError).status === 'number' ||
typeof (error as MicroCMSRequestError).status === 'undefined') &&
Object.prototype.hasOwnProperty.call(error, 'url') &&
typeof (error as MicroCMSRequestError).url === 'string' &&
Object.prototype.hasOwnProperty.call(error, 'originalError')
);
};
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
export type Fetch = typeof fetch;

/**
* Error returned when a microCMS API request fails.
*/
export type MicroCMSRequestError = Error & {
status: number | undefined;
url: string;
originalError: unknown | undefined;
};

/**
* microCMS createClient params
*/
Expand Down
86 changes: 82 additions & 4 deletions tests/createClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { http, HttpResponse } from 'msw';
import { createClient } from '../src/createClient';
import { createClient, isMicroCMSRequestError } from '../src';
import { testBaseUrl } from './mocks/handlers';
import { server } from './mocks/server';

Expand Down Expand Up @@ -81,6 +81,30 @@ describe('createClient', () => {
new Error('fetch API response status: 404'),
);
});

test('Returns structured error details and masks draftKey', async () => {
server.use(
http.get(`${testBaseUrl}/list-type`, async () => {
return new HttpResponse(null, { status: 404 });
}),
);
const client = createClient({
serviceDomain: 'serviceDomain',
apiKey: 'apiKey',
});

const error = await client
.get({
endpoint: 'list-type',
queries: { draftKey: 'secret', fields: 'id' },
})
.catch((error) => error);

expect(isMicroCMSRequestError(error)).toBe(true);
expect(error.status).toBe(404);
expect(error.url).toBe(`${testBaseUrl}/list-type?draftKey=***&fields=id`);
expect(error.originalError).toBeUndefined();
});
});

test('Throws an error in the event of a network error.', () => {
Expand All @@ -99,6 +123,53 @@ describe('createClient', () => {
);
});

test('Keeps the original error in the event of a network error', async () => {
const originalError = new TypeError('fetch failed') as TypeError & {
cause: unknown;
};
originalError.cause = { code: 'ENOTFOUND' };
const fetchMock = jest
.spyOn(global, 'fetch')
.mockRejectedValueOnce(originalError);
const client = createClient({
serviceDomain: 'serviceDomain',
apiKey: 'apiKey',
});

const error = await client
.get({ endpoint: 'list-type' })
.catch((error) => error);

fetchMock.mockRestore();

expect(isMicroCMSRequestError(error)).toBe(true);
expect(error.status).toBeUndefined();
expect(error.url).toBe(`${testBaseUrl}/list-type`);
expect(error.originalError).toBe(originalError);
});

test('Does not convert a response body parsing error', async () => {
server.use(
http.get(`${testBaseUrl}/list-type`, async () => {
return new HttpResponse('invalid json', {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}),
);
const client = createClient({
serviceDomain: 'serviceDomain',
apiKey: 'apiKey',
});

const error = await client
.get({ endpoint: 'list-type' })
.catch((error) => error);

expect(error.name).toBe('SyntaxError');
expect(isMicroCMSRequestError(error)).toBe(false);
});

describe('Retry option is true', () => {
const retryableClient = createClient({
serviceDomain: 'serviceDomain',
Expand All @@ -118,9 +189,16 @@ describe('createClient', () => {
}),
);

await expect(retryableClient.get({ endpoint: '500' })).rejects.toThrow(
new Error('fetch API response status: 500'),
);
const error = await retryableClient
.get({ endpoint: '500' })
.catch((error) => error);

expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('fetch API response status: 500');
expect(isMicroCMSRequestError(error)).toBe(true);
expect(error.status).toBe(500);
expect(error.url).toBe(`${testBaseUrl}/500`);
expect(error.originalError).toBeUndefined();
expect(apiCallCount).toBe(3);
}, 30000);

Expand Down
Loading