Introduce the @backstage/errors package.
Encode thrown errors in the backend as a JSON payload using a facility in that package, and render helpful frontend displays of those errors. Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
# Common error handling functionality
|
||||
|
||||
Contains some common functionality of error handling.
|
||||
|
||||
This package will be imported both by the frontend and backend.
|
||||
|
||||
## Links
|
||||
|
||||
- [The Backstage homepage](https://backstage.io)
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@backstage/errors",
|
||||
"version": "0.1.1",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/errors"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.2",
|
||||
"cross-fetch": "^3.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.6.1",
|
||||
"@types/jest": "^26.0.7"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class CustomErrorBase extends Error {
|
||||
readonly cause?: Error;
|
||||
|
||||
constructor(message?: string, cause?: Error) {
|
||||
let fullMessage = message;
|
||||
if (cause) {
|
||||
if (fullMessage) {
|
||||
fullMessage += `; caused by ${cause}`;
|
||||
} else {
|
||||
fullMessage = `caused by ${cause}`;
|
||||
}
|
||||
}
|
||||
|
||||
super(fullMessage);
|
||||
|
||||
Error.captureStackTrace?.(this, this.constructor);
|
||||
|
||||
this.name = this.constructor.name;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as errors from './common';
|
||||
|
||||
describe('common', () => {
|
||||
it('extends Error properly', () => {
|
||||
for (const [name, E] of Object.entries(errors)) {
|
||||
const error = new E('abcdef');
|
||||
expect(error.name).toBe(name);
|
||||
expect(error.message).toBe('abcdef');
|
||||
expect(error.stack).toContain(__filename);
|
||||
expect(error.toString()).toContain(name);
|
||||
expect(error.toString()).toContain('abcdef');
|
||||
}
|
||||
});
|
||||
|
||||
it('supports causes', () => {
|
||||
const cause = new Error('hello');
|
||||
for (const [name, E] of Object.entries(errors)) {
|
||||
const error = new E('abcdef', cause);
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.toString()).toContain(
|
||||
`${name}: abcdef; caused by Error: hello`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CustomErrorBase } from './CustomErrorBase';
|
||||
|
||||
/*
|
||||
* A set of common business logic errors.
|
||||
*
|
||||
* A backend error handler middleware would understand these and translate them
|
||||
* to well formed HTTP responses.
|
||||
*
|
||||
* While these are intentionally analogous to HTTP errors, they are not
|
||||
* intended to be thrown by the request handling layer. In those places, please
|
||||
* use e.g. the http-errors library.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The given inputs are malformed and cannot be processed.
|
||||
*/
|
||||
export class InputError extends CustomErrorBase {}
|
||||
|
||||
/**
|
||||
* The request requires authentication, which was not properly supplied.
|
||||
*/
|
||||
export class AuthenticationError extends CustomErrorBase {}
|
||||
|
||||
/**
|
||||
* The authenticated caller is not allowed to perform this request.
|
||||
*/
|
||||
export class NotAllowedError extends CustomErrorBase {}
|
||||
|
||||
/**
|
||||
* The requested resource could not be found.
|
||||
*
|
||||
* Note that this error usually is used to indicate that an entity with a given
|
||||
* ID does not exist, rather than signalling that an entire route is missing.
|
||||
*/
|
||||
export class NotFoundError extends CustomErrorBase {}
|
||||
|
||||
/**
|
||||
* The request could not complete due to a conflict in the current state of the
|
||||
* resource.
|
||||
*/
|
||||
export class ConflictError extends CustomErrorBase {}
|
||||
|
||||
/**
|
||||
* The requested resource has not changed since last request.
|
||||
*/
|
||||
export class NotModifiedError extends CustomErrorBase {}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
InputError,
|
||||
AuthenticationError,
|
||||
NotAllowedError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
NotModifiedError,
|
||||
} from './common';
|
||||
export { ServerResponseError } from './server';
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseServerResponseErrorBody,
|
||||
ServerResponseErrorBody,
|
||||
} from '../payload';
|
||||
|
||||
/**
|
||||
* An error thrown as the result of a failed server request.
|
||||
*
|
||||
* The server is expected to respond on the ServerResponseErrorBody format.
|
||||
*/
|
||||
export class ServerResponseError extends Error {
|
||||
static async forResponse(response: Response): Promise<ServerResponseError> {
|
||||
const body = await parseServerResponseErrorBody(response);
|
||||
const status = body.error.statusCode || response.status;
|
||||
const statusText = body.error.name || response.statusText;
|
||||
const message = `Request failed with ${status} ${statusText}`;
|
||||
return new ServerResponseError(message, body);
|
||||
}
|
||||
|
||||
readonly body: ServerResponseErrorBody;
|
||||
|
||||
constructor(message: string, body: ServerResponseErrorBody) {
|
||||
super(message);
|
||||
this.name = 'ServerResponseError';
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './error';
|
||||
export * from './payload';
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseServerResponseErrorBody,
|
||||
ServerResponseErrorBody,
|
||||
} from './ServerResponseErrorBody';
|
||||
|
||||
describe('parseServerResponseErrorBody', () => {
|
||||
it('handles the happy path', async () => {
|
||||
const body: ServerResponseErrorBody = {
|
||||
error: {
|
||||
statusCode: 444,
|
||||
name: 'Fours',
|
||||
message: 'Expected fives',
|
||||
stack: 'lines',
|
||||
},
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
},
|
||||
};
|
||||
|
||||
const response: Partial<Response> = {
|
||||
status: 444,
|
||||
statusText: 'Fours',
|
||||
text: async () => JSON.stringify(body),
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
};
|
||||
|
||||
await expect(
|
||||
parseServerResponseErrorBody(response as Response),
|
||||
).resolves.toEqual(body);
|
||||
});
|
||||
|
||||
it('uses request header and text body when wrong content type, even if parsable', async () => {
|
||||
const body: ServerResponseErrorBody = {
|
||||
error: {
|
||||
statusCode: 333,
|
||||
name: 'Threes',
|
||||
message: 'Expected twos',
|
||||
},
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
},
|
||||
};
|
||||
|
||||
const response: Partial<Response> = {
|
||||
status: 444,
|
||||
statusText: 'Fours',
|
||||
text: async () => JSON.stringify(body),
|
||||
headers: new Headers({ 'Content-Type': 'not-application/not-json' }),
|
||||
};
|
||||
|
||||
await expect(
|
||||
parseServerResponseErrorBody(response as Response),
|
||||
).resolves.toEqual({
|
||||
error: {
|
||||
statusCode: 444,
|
||||
name: 'Unknown',
|
||||
message: `Request failed with status 444 Fours, ${JSON.stringify(
|
||||
body,
|
||||
)}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses request header and text body when not parsable', async () => {
|
||||
const body: ServerResponseErrorBody = {
|
||||
error: {
|
||||
statusCode: 333,
|
||||
name: 'Threes',
|
||||
message: 'Expected twos',
|
||||
},
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '/',
|
||||
},
|
||||
};
|
||||
|
||||
const response: Partial<Response> = {
|
||||
status: 444,
|
||||
statusText: 'Fours',
|
||||
text: async () => JSON.stringify(body).substring(1),
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
};
|
||||
|
||||
await expect(
|
||||
parseServerResponseErrorBody(response as Response),
|
||||
).resolves.toEqual({
|
||||
error: {
|
||||
statusCode: 444,
|
||||
name: 'Unknown',
|
||||
message: `Request failed with status 444 Fours, ${JSON.stringify(
|
||||
body,
|
||||
).substring(1)}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses request header when failing to get body', async () => {
|
||||
const response: Partial<Response> = {
|
||||
status: 444,
|
||||
statusText: 'Fours',
|
||||
text: async () => {
|
||||
throw new Error('bail');
|
||||
},
|
||||
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||
};
|
||||
|
||||
await expect(
|
||||
parseServerResponseErrorBody(response as Response),
|
||||
).resolves.toEqual({
|
||||
error: {
|
||||
statusCode: 444,
|
||||
name: 'Unknown',
|
||||
message: `Request failed with status 444 Fours`,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A standard shape of JSON data returned as the body of backend errors.
|
||||
*/
|
||||
export type ServerResponseErrorBody = {
|
||||
/** Details of the error that was caught */
|
||||
error: {
|
||||
/** The numeric HTTP status code that was returned */
|
||||
statusCode: number;
|
||||
/** The name of the exception that was thrown */
|
||||
name: string;
|
||||
/** The message of the exception that was thrown */
|
||||
message: string;
|
||||
/** A stringified stack trace, may not be present */
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
/** The incoming request */
|
||||
request?: {
|
||||
/** The HTTP method of the request */
|
||||
method: string;
|
||||
/** The URL of the request (excluding protocol and host/port) */
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to extract the ServerResponseErrorBody out of a server response.
|
||||
* This consumes the body of the response.
|
||||
*
|
||||
* The code is forgiving, and constructs a useful synthetic body as best it can
|
||||
* if the response wasn't on the expected form.
|
||||
*
|
||||
* @param response The response of a failed request
|
||||
*/
|
||||
export async function parseServerResponseErrorBody(
|
||||
response: Response,
|
||||
): Promise<ServerResponseErrorBody> {
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
if (
|
||||
response.headers.get('content-type')?.startsWith('application/json')
|
||||
) {
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
if (body.error && body.request) {
|
||||
return body;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
statusCode: response.status,
|
||||
name: 'Unknown',
|
||||
message: `Request failed with status ${response.status} ${response.statusText}, ${text}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
statusCode: response.status,
|
||||
name: 'Unknown',
|
||||
message: `Request failed with status ${response.status} ${response.statusText}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { parseServerResponseErrorBody } from './ServerResponseErrorBody';
|
||||
export type { ServerResponseErrorBody } from './ServerResponseErrorBody';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user