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:
Fredrik Adelöw
2021-03-08 17:02:51 +01:00
parent 9804a5273e
commit 8686eb38cf
85 changed files with 880 additions and 177 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
View File
+9
View File
@@ -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)
+42
View File
@@ -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;
}
}
+41
View File
@@ -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`,
);
}
});
});
+62
View File
@@ -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 {}
+25
View File
@@ -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';
+43
View File
@@ -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;
}
}
+18
View File
@@ -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}`,
},
};
}
+18
View File
@@ -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';
+17
View File
@@ -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 {};