Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

mrc-6085 Error handling #3

Merged
merged 23 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .github/workflows/test-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ jobs:
test-and-push:
runs-on: ubuntu-latest
steps:
- name: Set Pull Request Head SHA
if: ${{ github.event_name == 'pull_request' }}
run: |
long_sha=${{ github.event.pull_request.head.sha }}
echo "PR_HEAD_SHA=${long_sha:0:7}" >> $GITHUB_ENV
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
Expand Down
6 changes: 5 additions & 1 deletion docker/common
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
REGISTRY=ghcr.io
PACKAGE_ORG=mrc-ide
PACKAGE_NAME=grout
GIT_SHA=$(git -C "$PACKAGE_ROOT" rev-parse --short=7 HEAD)
if [[ -z "${PR_HEAD_SHA}" ]]; then
GIT_SHA=$(git -C "$PACKAGE_ROOT" rev-parse --short=7 HEAD)
else
GIT_SHA=${PR_HEAD_SHA}
fi
if [[ -v "BRANCH_NAME" ]]; then
GIT_BRANCH=${BRANCH_NAME}
else
Expand Down
1 change: 1 addition & 0 deletions docker/run
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ docker run -d --rm \
--pull=always \
-p 5000:5000 \
-v ./data:/data \
-e GROUT_ERROR_TEST=true \
Copy link
Contributor

Choose a reason for hiding this comment

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

I would have expected a docker/run script to be used for a deployment. Maybe the env var check in src/routes.ts should also check what environment we're in directly.

Copy link
Contributor Author

@EmmaLRussell EmmaLRussell Dec 18, 2024

Choose a reason for hiding this comment

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

No, this is just a dev/CI script, we won't be using it for deployment, that'll be a separate tool. I think it's easier to be explicit when we want the testing route to be added (really just when using this script for integration testing locally/on CI).

--name=$PACKAGE_NAME \
$TAG_SHA
24 changes: 23 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"express": "^4.21.1",
"morgan": "^1.10.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7"
"sqlite3": "^5.1.7",
"uid": "^2.0.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
Expand Down
3 changes: 2 additions & 1 deletion src/controllers/indexController.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Request, Response } from "express";
import { jsonResponseSuccess } from "../jsonResponse";

export class IndexController {
static getIndex = (_req: Request, res: Response) => {
const version = process.env.npm_package_version;
res.status(200).json({ version });
jsonResponseSuccess({ version }, res);
};
}
63 changes: 42 additions & 21 deletions src/controllers/tileController.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,49 @@
import { Request, Response } from "express";
import { NextFunction, Request, Response } from "express";
import { AppLocals } from "../types/app";
import asyncControllerHandler from "../errors/asyncControllerHandler";
import notFound from "../errors/notFound";
import { GroutError } from "../errors/groutError";
import { ErrorType } from "../errors/errorType";

const parseIntParam = (param: string): number => {
// Native parseInt is not strict (ignores whitespace and trailing chars) so test with a regex
if (!/^\d+$/.test(param)) {
throw new GroutError(
`"${param}" is not an integer`,
ErrorType.BAD_REQUEST
);
}
return parseInt(param, 10);
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Why not use quinary? Decimal is so 2024.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Banking on retro vibes being in next season.

Comment on lines +8 to +16
Copy link
Contributor

Choose a reason for hiding this comment

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

dont have to change this but you could use Number(string) which does everything you want without you having to write regex, Number evals the whole string vs parseInt stopping

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think Number(string) will help me exclude non-integer numbers though, will it?

Copy link
Contributor

Choose a reason for hiding this comment

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

What would parseInt(Number(string)) do?

Copy link
Contributor

Choose a reason for hiding this comment

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

you can do Number.isInteger(Number(string)) for that, so something like

const num = Number(string);
if (!Number.IsInteger) {
  throw...
}
return num

guess its about the same XD up to you

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, I might just leave it as it is. Nice idea though, thanks!


export class TileController {
static getTile = async (req: Request, res: Response) => {
const { dataset, level, z, x, y } = req.params;
const { tileDatasets } = req.app.locals as AppLocals;
static getTile = async (
req: Request,
res: Response,
next: NextFunction
) => {
await asyncControllerHandler(next, async () => {
const { dataset, level, z, x, y } = req.params;
const { tileDatasets } = req.app.locals as AppLocals;

let tileData = null;
if (tileDatasets[dataset] && tileDatasets[dataset][level]) {
const db = tileDatasets[dataset][level];
tileData = await db.getTileData(
parseInt(z),
parseInt(x),
parseInt(y)
);
}
let tileData = null;
if (tileDatasets[dataset] && tileDatasets[dataset][level]) {
const db = tileDatasets[dataset][level];
tileData = await db.getTileData(
parseIntParam(z),
parseIntParam(x),
parseIntParam(y)
);
}

if (tileData) {
res.writeHead(200, {
"Content-Type": "application/octet-stream",
"Content-Encoding": "gzip"
}).end(tileData);
} else {
res.writeHead(404).end();
}
if (tileData) {
res.writeHead(200, {
"Content-Type": "application/octet-stream",
"Content-Encoding": "gzip"
}).end(tileData);
} else {
notFound(req);
}
});
};
}
10 changes: 10 additions & 0 deletions src/errors/asyncControllerHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextFunction } from "express";

// This method should be used to wrap any async controller methods to ensure error handling is applied
export default async (next: NextFunction, method: () => void) => {
try {
await method();
} catch (error) {
next(error);
Copy link
Contributor

Choose a reason for hiding this comment

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

What do we use next for in this app, if anything? As far as I can tell from reading about it, it's for falling back to other routes when the route has an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It hands on to the next middleware which can handle the error. We need it for handleError to be triggered.

}
};
11 changes: 11 additions & 0 deletions src/errors/errorType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const enum ErrorType {
BAD_REQUEST = "BAD_REQUEST",
NOT_FOUND = "NOT_FOUND",
UNEXPECTED_ERROR = "UNEXPECTED_ERROR"
}

export const ErrorTypeStatuses: { [key in ErrorType]: number } = {
[ErrorType.BAD_REQUEST]: 400,
[ErrorType.NOT_FOUND]: 404,
[ErrorType.UNEXPECTED_ERROR]: 500
};
16 changes: 16 additions & 0 deletions src/errors/groutError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ErrorType, ErrorTypeStatuses } from "./errorType";

export class GroutError extends Error {
errorType: ErrorType;

constructor(message: string, errorType: ErrorType) {
super(message);

this.name = "GroutError";
this.errorType = errorType;
}

get status() {
return ErrorTypeStatuses[this.errorType];
}
}
31 changes: 31 additions & 0 deletions src/errors/handleError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextFunction, Response } from "express";
import { uid } from "uid";
import { GroutError } from "./groutError";
import { ErrorType } from "./errorType";
import { RequestWithError } from "../logging";
import { jsonResponseError } from "../jsonResponse";

// We need to include the unused next var for this to be used correctly as an error handler
export const handleError = (
err: Error,
req: RequestWithError,
res: Response,
_: NextFunction // eslint-disable-line @typescript-eslint/no-unused-vars
) => {
const groutError = err instanceof GroutError;

const status = groutError ? err.status : 500;
const type = groutError ? err.errorType : ErrorType.UNEXPECTED_ERROR;

// Do not return raw messages from unexpected errors to the front end
const detail = groutError
? err.message
: `An unexpected error occurred. Please contact support and quote error code ${uid()}`;

// Set error type, detail and stack on req so morgan logs them
req.errorType = type;
req.errorDetail = detail;
req.errorStack = err.stack;

jsonResponseError(status, type, detail, res);
};
8 changes: 8 additions & 0 deletions src/errors/notFound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Request } from "express";
import { ErrorType } from "./errorType";
import { GroutError } from "./groutError";

export default (req: Request) => {
const { url } = req;
throw new GroutError(`Route not found: ${url}`, ErrorType.NOT_FOUND);
};
31 changes: 31 additions & 0 deletions src/jsonResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Response } from "express";

const addContentType = (res: Response) => {
res.header("Content-Type", "application/json");
};

export const jsonResponseSuccess = (data: object | string, res: Response) => {
addContentType(res);
const responseObject = {
status: "success",
errors: null,
data
};
res.end(JSON.stringify(responseObject));
};

export const jsonResponseError = (
httpStatus: number,
error: string,
detail: string,
res: Response
) => {
addContentType(res);
const responseObject = {
status: "failure",
errors: [{ error, detail }],
data: null
};
res.status(httpStatus);
res.end(JSON.stringify(responseObject));
};
18 changes: 16 additions & 2 deletions src/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,20 @@ import { Application, Request, Response } from "express";
import morgan from "morgan";
import { Dict } from "./types/utils";

export type RequestWithError = Request & {
errorType?: string;
errorDetail?: string;
errorStack?: string;
};

export const initialiseLogging = (app: Application) => {
// Log error details appended to request by handleError
morgan.token("error-type", (req: RequestWithError) => req.errorType);
morgan.token("error-detail", (req: RequestWithError) => req.errorDetail);
morgan.token("error-stack", (req: RequestWithError) => req.errorStack);

const customFormat = (
tokens: Dict<(req: Request, res: Response, header?: string) => string>,
tokens: Dict<(req: Request, res?: Response, header?: string) => string>,
req: Request,
res: Response
) => {
Expand All @@ -17,7 +28,10 @@ export const initialiseLogging = (app: Application) => {
tokens.res(req, res, "content-length"),
"-",
tokens["response-time"](req, res),
"ms"
"ms",
tokens["error-type"](req),
tokens["error-detail"](req),
tokens["error-stack"](req)
].join(" ");
};

Expand Down
13 changes: 13 additions & 0 deletions src/routes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
import { Router } from "express";
import { IndexController } from "./controllers/indexController";
import { TileController } from "./controllers/tileController";
import notFound from "./errors/notFound";

export const registerRoutes = () => {
const router = Router();
router.get("/", IndexController.getIndex);
router.get("/tile/:dataset/:level/:z/:x/:y", TileController.getTile);

// provide an endpoint we can use to test 500 response behaviour by throwing an "unexpected error" - but only if we
// are running in a non-production mode indicated by an env var
if (process.env.GROUT_ERROR_TEST) {
router.get("/error-test", () => {
throw Error("Testing error behaviour");

Check warning on line 15 in src/routes.ts

View check run for this annotation

Codecov / codecov/patch

src/routes.ts#L14-L15

Added lines #L14 - L15 were not covered by tests
});
}
Comment on lines +11 to +17
Copy link
Contributor

Choose a reason for hiding this comment

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

nice this is a neat idea, we should definitely use it more!


// Throw 404 error for any unmatched routes
router.use(notFound);

return router;
};
3 changes: 2 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { GroutConfig } from "./types/app";
import { registerRoutes } from "./routes";
import { initialiseLogging } from "./logging";
import { discoverTileDatasets } from "./discover";
import { handleError } from "./errors/handleError";

// Wrap the main server set-up functionality in a non-top-level method so we can use async - we can revert this in
// https://mrc-ide.myjetbrains.com/youtrack/issue/mrc-6134/Add-Vite-build-and-related-tidy-up
Expand All @@ -32,7 +33,7 @@ const main = async () => {
Object.freeze(app.locals); // We don't expect anything else to modify app.locals

app.use("/", registerRoutes());

app.use(handleError);
app.listen(port, () => {
console.log(`Grout is running on port ${port}`);
});
Expand Down
4 changes: 2 additions & 2 deletions test.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
</style>
</head>
<body>
<h1>GROUT</h1>
<h1>GROUT Test Page</h1>
<div id="map"></div>
<script>
const map = L.map("map").setView({lon: 0, lat: 0}, 3);
Expand Down Expand Up @@ -79,7 +79,7 @@ <h1>GROUT</h1>
style: {
weight: 1,
fill: false,
color: "#000000"
color: "#777"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tone down garish country outlines on test page!

},
...options
};
Expand Down
Loading
Loading