Add error codes to our custom errors (#959)

This commit is contained in:
Fábio Rosado
2022-11-25 17:04:10 +00:00
committed by GitHub
parent 30e31a86ef
commit b062efcf17
20 changed files with 306 additions and 67 deletions

View File

@@ -0,0 +1,28 @@
# Exceptions and error codes
When creating pages with PyScript, you may encounter exceptions. Each handled exception will contain a specific code which will give you more information about it.
This reference guide contains the error codes you might find and a description of each of them.
## User Errors
| Error code | Description | Recommendation |
|------------|--------------------------------|--------------------|
| PY1000 | Invalid configuration supplied | Confirm that your `py-config` tag is using a valid `TOML` or `JSON` syntax and is using the correct configuration type. |
| PY9000 | Top level await is deprecated | Create a coroutine with your code and schedule it with `asyncio.ensure_future` or similar |
## Fetch Errors
These error codes are related to any exception raised when trying to fetch a resource. If, while trying to fetch a resource, we encounter a status code that is not 200, the error code will contain the HTTP status code and the `PY0` prefix. For example, if we encounter a 404 error, the error code will be `P02404`.
| Error Code | Description |
|------------|--------------------------------------------------------------|
| PY0001 | Generic fetch error, failed to fetch page from the server |
| PY0002 | Name supplied when trying to fetch resource is invalid |
| PY0401 | You are not authorized to access this resource. |
| PY0403 | You are not allowed to access this resource. |
| PY0404 | The page you are trying to fetch does not exist. |
| PY0500 | The server encountered an internal error. |
| PY0503 | The server is currently unavailable. |

View File

@@ -36,6 +36,15 @@ caption: API
API/*
```
```{toctree}
---
maxdepth: 2
glob:
caption: Exceptions
---
exceptions
```
```{toctree}
---
maxdepth: 1

View File

@@ -2,7 +2,8 @@ import { htmlDecode, ensureUniqueId } from '../utils';
import type { Runtime } from '../runtime';
import { getLogger } from '../logger';
import { pyExec } from '../pyexec';
import { FetchError, _createAlertBanner } from '../exceptions';
import { _createAlertBanner } from '../exceptions';
import { robustFetch } from '../fetch';
const logger = getLogger('py-script');
@@ -18,17 +19,14 @@ export function make_PyScript(runtime: Runtime) {
async getPySrc(): Promise<string> {
if (this.hasAttribute('src')) {
const url = this.getAttribute('src');
const response = await fetch(url);
if (response.status !== 200) {
const errorMessage = (
`Failed to fetch '${url}' - Reason: ` +
`${response.status} ${response.statusText}`
);
_createAlertBanner(errorMessage);
try {
const response = await robustFetch(url);
return await response.text();
} catch(e) {
_createAlertBanner(e.message);
this.innerHTML = '';
throw new FetchError(errorMessage);
throw e
}
return await response.text();
} else {
return htmlDecode(this.innerHTML);
}

View File

@@ -1,6 +1,7 @@
import type { Runtime } from '../runtime';
import type { PyProxy } from 'pyodide';
import { getLogger } from '../logger';
import { robustFetch } from '../fetch';
const logger = getLogger('py-register-widget');
@@ -92,7 +93,7 @@ export function make_PyWidget(runtime: Runtime) {
}
async getSourceFromFile(s: string): Promise<string> {
const response = await fetch(s);
const response = await robustFetch(s);
return await response.text();
}
}

View File

@@ -2,20 +2,49 @@ const CLOSEBUTTON = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'
type MessageType = "text" | "html";
/*
These error codes are used to identify the type of error that occurred.
The convention is:
* PY0 - errors that occur when fetching
* PY1 - errors that occur in config
* PY9 - Deprecation errors
*/
export enum ErrorCode {
GENERIC = "PY0000", // Use this only for development then change to a more specific error code
FETCH_ERROR = "PY0001",
FETCH_NAME_ERROR = "PY0002",
// Currently these are created depending on error code received from fetching
FETCH_UNAUTHORIZED_ERROR = "PY0401",
FETCH_FORBIDDEN_ERROR = "PY0403",
FETCH_NOT_FOUND_ERROR = "PY0404",
FETCH_SERVER_ERROR = "PY0500",
FETCH_UNAVAILABLE_ERROR = "PY0503",
BAD_CONFIG = "PY1000",
TOP_LEVEL_AWAIT = "PY9000"
}
export class UserError extends Error {
messageType: MessageType;
errorCode: ErrorCode;
constructor(message: string, t: MessageType = "text") {
constructor(errorCode: ErrorCode, message: string, t: MessageType = "text") {
super(message);
this.errorCode = errorCode;
this.name = "UserError";
this.messageType = t;
this.message = `(${errorCode}): ${message}`;
}
}
export class FetchError extends Error {
constructor(message: string) {
errorCode: ErrorCode;
constructor(errorCode: ErrorCode, message: string) {
super(message)
this.name = "FetchError"
this.name = "FetchError";
this.errorCode = errorCode;
this.message = `(${errorCode}): ${message}`;
}
}

47
pyscriptjs/src/fetch.ts Normal file
View File

@@ -0,0 +1,47 @@
import { FetchError, ErrorCode } from "./exceptions";
/*
This is a fetch wrapper that handles any non 200 response and throws a FetchError
with the right ErrorCode.
TODO: Should we only throw on 4xx and 5xx responses?
*/
export async function robustFetch(url: string, options?: RequestInit): Promise<Response> {
const response = await fetch(url, options);
if (response.status !== 200) {
const errorMsg = `Fetching from URL ${url} failed with error ${response.status} (${response.statusText}).`;
switch(response.status) {
case 404:
throw new FetchError(
ErrorCode.FETCH_NOT_FOUND_ERROR,
errorMsg
);
case 401:
throw new FetchError(
ErrorCode.FETCH_UNAUTHORIZED_ERROR,
errorMsg
);
case 403:
throw new FetchError(
ErrorCode.FETCH_FORBIDDEN_ERROR,
errorMsg
);
case 500:
throw new FetchError(
ErrorCode.FETCH_SERVER_ERROR,
errorMsg
);
case 503:
throw new FetchError(
ErrorCode.FETCH_UNAVAILABLE_ERROR,
errorMsg
);
default:
throw new FetchError(
ErrorCode.FETCH_ERROR,
errorMsg
);
}
}
return response
}

View File

@@ -11,7 +11,7 @@ import { getLogger } from './logger';
import { handleFetchError, showWarning, globalExport } from './utils';
import { calculatePaths } from './plugins/fetch';
import { createCustomElements } from './components/elements';
import { UserError, _createAlertBanner } from "./exceptions"
import { UserError, ErrorCode, _createAlertBanner } from "./exceptions"
import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio';
import { PyTerminalPlugin } from './plugins/pyterminal';
import { SplashscreenPlugin } from './plugins/splashscreen';
@@ -133,7 +133,7 @@ export class PyScriptApp {
loadRuntime() {
logger.info('Initializing runtime');
if (this.config.runtimes.length == 0) {
throw new UserError('Fatal error: config.runtimes is empty');
throw new UserError(ErrorCode.BAD_CONFIG, 'Fatal error: config.runtimes is empty');
}
if (this.config.runtimes.length > 1) {

View File

@@ -1,6 +1,6 @@
import { joinPaths } from '../utils';
import { FetchConfig } from "../pyconfig";
import { UserError } from '../exceptions';
import { UserError, ErrorCode } from '../exceptions';
export function calculatePaths(fetch_cfg: FetchConfig[]) {
const fetchPaths: string[] = [];
@@ -14,7 +14,10 @@ export function calculatePaths(fetch_cfg: FetchConfig[]) {
{
if (to_file !== undefined)
{
throw new UserError(`Cannot use 'to_file' and 'files' parameters together!`);
throw new UserError(
ErrorCode.BAD_CONFIG,
`Cannot use 'to_file' and 'files' parameters together!`
);
}
for (const each_f of files)
{
@@ -29,7 +32,10 @@ export function calculatePaths(fetch_cfg: FetchConfig[]) {
fetchPaths.push(from);
const filename = to_file || from.split('/').pop();
if (filename === '') {
throw new UserError(`Couldn't determine the filename from the path ${from}, supply ${to_file} parameter!`);
throw new UserError(
ErrorCode.BAD_CONFIG,
`Couldn't determine the filename from the path ${from}, please supply 'to_file' parameter.`
);
}
else {
paths.push(joinPaths([to_folder, filename]));

View File

@@ -2,7 +2,7 @@ import type { PyScriptApp } from '../main';
import type { AppConfig } from '../pyconfig';
import type { Runtime } from '../runtime';
import { Plugin } from '../plugin';
import { UserError } from "../exceptions"
import { UserError, ErrorCode } from "../exceptions"
import { getLogger } from '../logger';
import { type Stdio } from '../stdio';
@@ -24,8 +24,11 @@ export class PyTerminalPlugin extends Plugin {
t !== false &&
t !== "auto") {
const got = JSON.stringify(t);
throw new UserError('Invalid value for config.terminal: the only accepted' +
`values are true, false and "auto", got "${got}".`);
throw new UserError(
ErrorCode.BAD_CONFIG,
'Invalid value for config.terminal: the only accepted' +
`values are true, false and "auto", got "${got}".`
);
}
if (t === undefined) {
config.terminal = "auto"; // default value

View File

@@ -2,7 +2,7 @@ import toml from '../src/toml';
import { getLogger } from './logger';
import { version } from './runtime';
import { getAttribute, readTextFromPath, htmlDecode } from './utils';
import { UserError } from "./exceptions"
import { UserError, ErrorCode } from "./exceptions"
const logger = getLogger('py-config');
@@ -145,22 +145,34 @@ function parseConfig(configText: string, configType = 'toml') {
try {
// TOML parser is soft and can parse even JSON strings, this additional check prevents it.
if (configText.trim()[0] === '{') {
throw new UserError(`The config supplied: ${configText} is an invalid TOML and cannot be parsed`);
throw new UserError(
ErrorCode.BAD_CONFIG,
`The config supplied: ${configText} is an invalid TOML and cannot be parsed`
);
}
config = toml.parse(configText);
} catch (err) {
const errMessage: string = err.toString();
throw new UserError(`The config supplied: ${configText} is an invalid TOML and cannot be parsed: ${errMessage}`);
throw new UserError(
ErrorCode.BAD_CONFIG,
`The config supplied: ${configText} is an invalid TOML and cannot be parsed: ${errMessage}`
);
}
} else if (configType === 'json') {
try {
config = JSON.parse(configText);
} catch (err) {
const errMessage: string = err.toString();
throw new UserError(`The config supplied: ${configText} is an invalid JSON and cannot be parsed: ${errMessage}`);
throw new UserError(
ErrorCode.BAD_CONFIG,
`The config supplied: ${configText} is an invalid JSON and cannot be parsed: ${errMessage}`,
);
}
} else {
throw new UserError(`The type of config supplied '${configType}' is not supported, supported values are ["toml", "json"]`);
throw new UserError(
ErrorCode.BAD_CONFIG,
`The type of config supplied '${configType}' is not supported, supported values are ["toml", "json"]`
);
}
return config;
}

View File

@@ -1,6 +1,6 @@
import { getLogger } from './logger';
import { ensureUniqueId, ltrim } from './utils';
import { UserError } from './exceptions';
import { ensureUniqueId } from './utils';
import { UserError, ErrorCode } from './exceptions';
import type { Runtime } from './runtime';
const logger = getLogger('pyexec');
@@ -16,11 +16,12 @@ export function pyExec(runtime: Runtime, pysrc: string, outElem: HTMLElement) {
try {
if (usesTopLevelAwait(pysrc)){
throw new UserError(
'The use of top-level "await", "async for", and ' +
'"async with" is deprecated.' +
'\nPlease write a coroutine containing ' +
'your code and schedule it using asyncio.ensure_future() or similar.' +
'\nSee https://docs.pyscript.net/latest/guides/asyncio.html for more information.'
ErrorCode.TOP_LEVEL_AWAIT,
'The use of top-level "await", "async for", and ' +
'"async with" is deprecated.' +
'\nPlease write a coroutine containing ' +
'your code and schedule it using asyncio.ensure_future() or similar.' +
'\nSee https://docs.pyscript.net/latest/guides/asyncio.html for more information.',
)
}
return runtime.run(pysrc);

View File

@@ -1,11 +1,10 @@
import { Runtime } from './runtime';
import { Runtime, version } from './runtime';
import { getLogger } from './logger';
import { FetchError } from './exceptions'
import type { loadPyodide as loadPyodideDeclaration, PyodideInterface, PyProxy } from 'pyodide';
// eslint-disable-next-line
// @ts-ignore
import pyscript from './python/pyscript.py';
import { version } from './runtime';
import { robustFetch } from './fetch';
import type { AppConfig } from './pyconfig';
import type { Stdio } from './stdio';
@@ -112,10 +111,7 @@ export class PyodideRuntime extends Runtime {
this.interpreter.FS.mkdir(eachPath);
}
}
const response = await fetch(fetch_path);
if (response.status !== 200) {
throw new FetchError(`Unable to fetch ${fetch_path}, reason: ${response.status} - ${response.statusText}`);
}
const response = await robustFetch(fetch_path);
const buffer = await response.arrayBuffer();
const data = new Uint8Array(buffer);
pathArr.push(filename);

View File

@@ -1,4 +1,4 @@
import { _createAlertBanner, UserError } from "./exceptions"
import { _createAlertBanner, UserError, FetchError, ErrorCode } from "./exceptions"
export function addClasses(element: HTMLElement, classes: string[]) {
for (const entry of classes) {
@@ -50,21 +50,21 @@ export function handleFetchError(e: Error, singleFile: string) {
// fragile. We need a better solution.
let errorContent: string;
if (e.message.includes('Failed to fetch')) {
errorContent = `<p>PyScript: Access to local files
errorContent = `PyScript: Access to local files
(using "Paths:" in &lt;py-config&gt;)
is not available when directly opening a HTML file;
you must use a webserver to serve the additional files.
See <a style="text-decoration: underline;" href="https://github.com/pyscript/pyscript/issues/257#issuecomment-1119595062">this reference</a>
on starting a simple webserver with Python.</p>`;
on starting a simple webserver with Python.`;
} else if (e.message.includes('404')) {
errorContent =
`<p>PyScript: Loading from file <u>` +
`PyScript: Loading from file <u>` +
singleFile +
`</u> failed with error 404 (File not Found). Are your filename and path are correct?</p>`;
`</u> failed with error 404 (File not Found). Are your filename and path are correct?`;
} else {
errorContent = `<p>PyScript encountered an error while loading from file: ${e.message} </p>`;
errorContent = `PyScript encountered an error while loading from file: ${e.message}`;
}
throw new UserError(errorContent, "html");
throw new UserError(ErrorCode.FETCH_ERROR, errorContent, "html");
}
export function readTextFromPath(path: string) {

View File

@@ -145,7 +145,13 @@ class TestBasic(PyScriptTest):
self.check_js_errors()
error_msg = str(exc.value)
assert "Failed to fetch" in error_msg
if self.is_fake_server:
assert "Failed to fetch" in error_msg
else:
assert (
"Fetching from URL foo.py failed with error 404 (File not found)"
in error_msg
)
pyscript_tag = self.page.locator("py-script")
assert pyscript_tag.inner_html() == ""

View File

@@ -118,7 +118,7 @@ class TestConfig(PyScriptTest):
banner = self.page.wait_for_selector(".py-error")
assert "SyntaxError: Unexpected end of JSON input" in self.console.error.text
expected = (
"The config supplied: [[ is an invalid JSON and cannot be "
"(PY1000): The config supplied: [[ is an invalid JSON and cannot be "
"parsed: SyntaxError: Unexpected end of JSON input"
)
assert banner.inner_text() == expected
@@ -137,7 +137,7 @@ class TestConfig(PyScriptTest):
banner = self.page.wait_for_selector(".py-error")
assert "SyntaxError: Expected DoubleQuote" in self.console.error.text
expected = (
"The config supplied: [[ is an invalid TOML and cannot be parsed: "
"(PY1000): The config supplied: [[ is an invalid TOML and cannot be parsed: "
"SyntaxError: Expected DoubleQuote, Whitespace, or [a-z], [A-Z], "
'[0-9], "-", "_" but "\\n" found.'
)
@@ -178,7 +178,7 @@ class TestConfig(PyScriptTest):
"""
self.pyscript_run(snippet, wait_for_pyscript=False)
div = self.page.wait_for_selector(".py-error")
assert div.text_content() == "Fatal error: config.runtimes is empty"
assert div.text_content() == "(PY1000): Fatal error: config.runtimes is empty"
def test_multiple_runtimes(self):
snippet = """

View File

@@ -1,8 +1,7 @@
import { expect, it, jest } from "@jest/globals"
import { _createAlertBanner, UserError } from "../../src/exceptions"
import { expect, it, jest, describe, afterEach } from "@jest/globals"
import { _createAlertBanner, UserError, FetchError, ErrorCode } from "../../src/exceptions"
describe("Test _createAlertBanner", () => {
afterEach(() => {
// Ensure we always have a clean body
document.body.innerHTML = `<div>Hello World</div>`;
@@ -108,3 +107,21 @@ describe("Test _createAlertBanner", () => {
expect(banner[0].textContent).toBe("Test message");
})
})
describe("Test Exceptions", () => {
it('UserError contains errorCode and shows in message', async() => {
const errorCode = ErrorCode.BAD_CONFIG;
const message = 'Test error';
const userError = new UserError(ErrorCode.BAD_CONFIG, message);
expect(userError.errorCode).toBe(errorCode);
expect(userError.message).toBe(`(${errorCode}): ${message}`);
})
it('FetchError contains errorCode and shows in message', async() => {
const errorCode = ErrorCode.FETCH_NOT_FOUND_ERROR;
const message = 'Test error';
const fetchError = new FetchError(errorCode, message);
expect(fetchError.errorCode).toBe(errorCode);
expect(fetchError.message).toBe(`(${errorCode}): ${message}`);
})
})

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, jest } from '@jest/globals'
import { FetchError, ErrorCode } from "../../src/exceptions"
import { robustFetch } from "../../src/fetch"
import { Response } from 'node-fetch';
describe("robustFetch", () => {
it("should return a response object", async () => {
global.fetch = jest.fn(() => (Promise.resolve(new Response(status="200", "Hello World"))));
const response = await robustFetch("https://pyscript.net");
expect(response).toBeInstanceOf(Response);
expect(response.status).toBe(200);
})
it('receiving a 404 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => (Promise.resolve(new Response("Not Found", {status: 404}))));
const url = "https://pyscript.net/non-existent-page"
const expectedError = new FetchError(
ErrorCode.FETCH_NOT_FOUND_ERROR,
`Fetching from URL ${url} failed with error 404 (Not Found).`
)
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
})
it('receiving a 404 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => (Promise.resolve(new Response("Not Found", {status: 404}))));
const url = "https://pyscript.net/non-existent-page"
const expectedError = new FetchError(
ErrorCode.FETCH_NOT_FOUND_ERROR,
`Fetching from URL ${url} failed with error 404 (Not Found).`
)
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
})
it('receiving a 401 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => (Promise.resolve(new Response("", {status: 401}))));
const url = "https://pyscript.net/protected-page"
const expectedError = new FetchError(
ErrorCode.FETCH_UNAUTHORIZED_ERROR,
`Fetching from URL ${url} failed with error 401 (Unauthorized).`
)
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
})
it('receiving a 403 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => (Promise.resolve(new Response("", {status: 403}))));
const url = "https://pyscript.net/secret-page"
const expectedError = new FetchError(
ErrorCode.FETCH_FORBIDDEN_ERROR,
`Fetching from URL ${url} failed with error 403 (Forbidden).`
)
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
})
it('receiving a 500 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => (Promise.resolve(new Response("Not Found", {status: 500}))));
const url = "https://pyscript.net/protected-page"
const expectedError = new FetchError(
ErrorCode.FETCH_SERVER_ERROR,
`Fetching from URL ${url} failed with error 500 (Internal Server Error).`
)
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
})
it('receiving a 503 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => (Promise.resolve(new Response("Not Found", {status: 503}))));
const url = "https://pyscript.net/protected-page"
const expectedError = new FetchError(
ErrorCode.FETCH_UNAVAILABLE_ERROR,
`Fetching from URL ${url} failed with error 503 (Service Unavailable).`
)
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
})
});

View File

@@ -1,5 +1,5 @@
import { jest } from "@jest/globals"
import { UserError } from "../../src/exceptions"
import { describe, it, beforeEach, expect } from "@jest/globals"
import { UserError, ErrorCode } from "../../src/exceptions"
import { PyScriptApp } from "../../src/main"
describe("Test withUserErrorHandler", () => {
@@ -24,38 +24,38 @@ describe("Test withUserErrorHandler", () => {
it("userError doesn't stop execution", () => {
function myRealMain() {
throw new UserError("Computer says no");
throw new UserError(ErrorCode.GENERIC, "Computer says no");
}
const app = new MyApp(myRealMain);
app.main();
const banners = document.getElementsByClassName("alert-banner");
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe("Computer says no");
expect(banners[0].innerHTML).toBe("(PY0000): Computer says no");
});
it("userError escapes by default", () => {
function myRealMain() {
throw new UserError("hello <br>");
throw new UserError(ErrorCode.GENERIC, "hello <br>");
}
const app = new MyApp(myRealMain);
app.main();
const banners = document.getElementsByClassName("alert-banner");
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe("hello &lt;br&gt;");
expect(banners[0].innerHTML).toBe("(PY0000): hello &lt;br&gt;");
});
it("userError messageType=html don't escape", () => {
function myRealMain() {
throw new UserError("hello <br>", "html");
throw new UserError(ErrorCode.GENERIC, "hello <br>", "html");
}
const app = new MyApp(myRealMain);
app.main();
const banners = document.getElementsByClassName("alert-banner");
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe("hello <br>");
expect(banners[0].innerHTML).toBe("(PY0000): hello <br>");
});
it("any other exception should stop execution and raise", () => {

View File

@@ -1,4 +1,4 @@
import { jest } from '@jest/globals';
import { jest, describe, it, expect, } from '@jest/globals';
import { loadConfigFromElement, defaultConfig } from '../../src/pyconfig';
import { version } from '../../src/runtime';
import { UserError } from '../../src/exceptions'

View File

@@ -1,6 +1,5 @@
import { expect } from "@jest/globals"
import { ensureUniqueId, joinPaths } from "../../src/utils"
import { beforeEach, expect, describe, it } from "@jest/globals"
import { ensureUniqueId, joinPaths} from "../../src/utils"
describe("Utils", () => {