Unify API errors processing

With this change, we define a custom error handler as express middleware
which renders 503 error with JSON response that includes status and message
fields.

The handler also logs the error, so the stack can be inspected in supervisor
logs. It's also a point where we can report the error to analytics services.

This removes a bunch of error handlers written in every request handler
function. Behaviour should remain unchanged except the fact that
/healthy endpoint now returns 503 in case of failure instead of 500.

Change-type: patch
Signed-off-by: Roman Mazur <roman@balena.io>
This commit is contained in:
Roman Mazur 2019-09-23 16:23:16 +03:00
parent 89d9e9d117
commit 8b4c9837fa
No known key found for this signature in database
GPG Key ID: 9459886EFE6EE2F6
4 changed files with 275 additions and 357 deletions

View File

@ -935,7 +935,7 @@ export class APIBinder {
router.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
router.use(bodyParser.json({ limit: '10mb' }));
router.post('/v1/update', (req, res) => {
router.post('/v1/update', (req, res, next) => {
apiBinder.eventTracker.track('Update notification');
if (apiBinder.readyForUpdates) {
this.config
@ -953,15 +953,7 @@ export class APIBinder {
res.sendStatus(202);
}
})
.catch(err => {
const msg =
err.message != null
? err.message
: err != null
? err
: 'Unknown error';
res.status(503).send(msg);
});
.catch(next);
} else {
res.sendStatus(202);
}

View File

@ -9,7 +9,7 @@ exports.createV1Api = (router, applications) ->
{ eventTracker } = applications
router.post '/v1/restart', (req, res) ->
router.post '/v1/restart', (req, res, next) ->
appId = checkInt(req.body.appId)
force = checkTruthy(req.body.force)
eventTracker.track('Restart container (v1)', { appId })
@ -18,10 +18,9 @@ exports.createV1Api = (router, applications) ->
doRestart(applications, appId, force)
.then ->
res.status(200).send('OK')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
.catch(next)
v1StopOrStart = (req, res, action) ->
v1StopOrStart = (req, res, next, action) ->
appId = checkInt(req.params.appId)
force = checkTruthy(req.body.force)
if !appId?
@ -47,16 +46,14 @@ exports.createV1Api = (router, applications) ->
return service
.then (service) ->
res.status(200).json({ containerId: service.containerId })
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
.catch(next)
router.post '/v1/apps/:appId/stop', (req, res) ->
v1StopOrStart(req, res, 'stop')
createV1StopOrStartHandler = (action) -> _.partial(v1StopOrStart, _, _, _, action)
router.post '/v1/apps/:appId/start', (req, res) ->
v1StopOrStart(req, res, 'start')
router.post('/v1/apps/:appId/stop', createV1StopOrStartHandler('stop'))
router.post('/v1/apps/:appId/start', createV1StopOrStartHandler('start'))
router.get '/v1/apps/:appId', (req, res) ->
router.get '/v1/apps/:appId', (req, res, next) ->
appId = checkInt(req.params.appId)
eventTracker.track('GET app (v1)', { appId })
if !appId?
@ -82,10 +79,9 @@ exports.createV1Api = (router, applications) ->
appToSend.commit = status.commit
res.json(appToSend)
)
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
.catch(next)
router.post '/v1/purge', (req, res) ->
router.post '/v1/purge', (req, res, next) ->
appId = checkInt(req.body.appId)
force = checkTruthy(req.body.force)
if !appId?
@ -94,6 +90,4 @@ exports.createV1Api = (router, applications) ->
doPurge(applications, appId, force)
.then ->
res.status(200).json(Data: 'OK', Error: '')
.catch (err) ->
res.status(503).send(err?.message or err or 'Unknown error')
.catch(next)

View File

@ -1,5 +1,5 @@
import * as Bluebird from 'bluebird';
import { Request, Response, Router } from 'express';
import { NextFunction, Request, Response, Router } from 'express';
import * as _ from 'lodash';
import { fs } from 'mz';
@ -22,21 +22,10 @@ import { checkInt, checkTruthy } from '../lib/validation';
export function createV2Api(router: Router, applications: ApplicationManager) {
const { _lockingIfNecessary, deviceState } = applications;
const messageFromError = (err?: Error | string | null): string => {
let message = 'Unknown error';
if (err != null) {
if (_.isError(err) && err.message != null) {
message = err.message;
} else {
message = err as string;
}
}
return message;
};
const handleServiceAction = (
req: Request,
res: Response,
next: NextFunction,
action: any,
): Bluebird<void> => {
const { imageId, serviceName, force } = req.body;
@ -85,15 +74,16 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
res.status(200).send('OK');
});
})
.catch(err => {
res.status(503).send(messageFromError(err));
});
.catch(next);
});
};
const createServiceActionHandler = (action: string) =>
_.partial(handleServiceAction, _, _, _, action);
router.post(
'/v2/applications/:appId/purge',
(req: Request, res: Response) => {
(req: Request, res: Response, next: NextFunction) => {
const { force } = req.body;
const { appId } = req.params;
@ -101,45 +91,28 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
.then(() => {
res.status(200).send('OK');
})
.catch(err => {
let message;
if (err != null) {
message = err.message;
if (message == null) {
message = err;
}
} else {
message = 'Unknown error';
}
res.status(503).send(message);
});
.catch(next);
},
);
router.post(
'/v2/applications/:appId/restart-service',
(req: Request, res: Response) => {
return handleServiceAction(req, res, 'restart');
},
createServiceActionHandler('restart'),
);
router.post(
'/v2/applications/:appId/stop-service',
(req: Request, res: Response) => {
return handleServiceAction(req, res, 'stop');
},
createServiceActionHandler('stop'),
);
router.post(
'/v2/applications/:appId/start-service',
(req: Request, res: Response) => {
return handleServiceAction(req, res, 'start');
},
createServiceActionHandler('start'),
);
router.post(
'/v2/applications/:appId/restart',
(req: Request, res: Response) => {
(req: Request, res: Response, next: NextFunction) => {
const { force } = req.body;
const { appId } = req.params;
@ -147,192 +120,181 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
.then(() => {
res.status(200).send('OK');
})
.catch(err => {
res.status(503).send(messageFromError(err));
});
.catch(next);
},
);
// TODO: Support dependent applications when this feature is complete
router.get('/v2/applications/state', async (_req: Request, res: Response) => {
// It's kinda hacky to access the services and db via the application manager
// maybe refactor this code
Bluebird.join(
applications.services.getStatus(),
applications.images.getStatus(),
applications.db.models('app').select(['appId', 'commit', 'name']),
(
services,
images,
apps: Array<{ appId: string; commit: string; name: string }>,
) => {
// Create an object which is keyed my application name
const response: {
[appName: string]: {
appId: number;
commit: string;
services: {
[serviceName: string]: {
status: string;
releaseId: number;
downloadProgress: number | null;
router.get(
'/v2/applications/state',
async (_req: Request, res: Response, next: NextFunction) => {
// It's kinda hacky to access the services and db via the application manager
// maybe refactor this code
Bluebird.join(
applications.services.getStatus(),
applications.images.getStatus(),
applications.db.models('app').select(['appId', 'commit', 'name']),
(
services,
images,
apps: Array<{ appId: string; commit: string; name: string }>,
) => {
// Create an object which is keyed my application name
const response: {
[appName: string]: {
appId: number;
commit: string;
services: {
[serviceName: string]: {
status: string;
releaseId: number;
downloadProgress: number | null;
};
};
};
};
} = {};
} = {};
const appNameById: { [id: number]: string } = {};
const appNameById: { [id: number]: string } = {};
apps.forEach(app => {
const appId = parseInt(app.appId, 10);
response[app.name] = {
appId,
commit: app.commit,
services: {},
};
apps.forEach(app => {
const appId = parseInt(app.appId, 10);
response[app.name] = {
appId,
commit: app.commit,
services: {},
};
appNameById[appId] = app.name;
});
images.forEach(img => {
const appName = appNameById[img.appId];
if (appName == null) {
log.warn(
`Image found for unknown application!\nImage: ${JSON.stringify(
img,
)}`,
);
return;
}
const svc = _.find(services, (service: Service) => {
return service.imageId === img.imageId;
appNameById[appId] = app.name;
});
let status: string;
if (svc == null) {
status = img.status;
} else {
status = svc.status || img.status;
}
response[appName].services[img.serviceName] = {
status,
releaseId: img.releaseId,
downloadProgress: img.downloadProgress || null,
};
});
images.forEach(img => {
const appName = appNameById[img.appId];
if (appName == null) {
log.warn(
`Image found for unknown application!\nImage: ${JSON.stringify(
img,
)}`,
);
return;
}
res.status(200).json(response);
},
);
});
const svc = _.find(services, (service: Service) => {
return service.imageId === img.imageId;
});
let status: string;
if (svc == null) {
status = img.status;
} else {
status = svc.status || img.status;
}
response[appName].services[img.serviceName] = {
status,
releaseId: img.releaseId,
downloadProgress: img.downloadProgress || null,
};
});
res.status(200).json(response);
},
).catch(next);
},
);
router.get(
'/v2/applications/:appId/state',
(_req: Request, res: Response) => {
(_req: Request, res: Response, next: NextFunction) => {
// Get all services and their statuses, and return it
applications.getStatus().then(apps => {
res.status(200).json(apps);
});
applications
.getStatus()
.then(apps => {
res.status(200).json(apps);
})
.catch(next);
},
);
router.get('/v2/local/target-state', async (_req, res) => {
try {
const localMode = await deviceState.config.get('localMode');
if (!localMode) {
return res.status(400).json({
status: 'failed',
message: 'Target state can only be retrieved when in local mode',
});
}
const targetState = await deviceState.getTarget();
// We avoid using cloneDeep here, as the class
// instances can cause a maximum call stack exceeded
// error
// TODO: This should really return the config as it
// is returned from the api, but currently that's not
// the easiest thing due to the way they are stored and
// retrieved from the db - when all of the application
// manager is strongly typed, revisit this. The best
// thing to do would be to represent the input with
// io-ts and make sure the below conforms to it
const target: any = {
local: {
config: {},
},
dependent: {
config: {},
},
};
if (targetState.local != null) {
target.local = {
name: targetState.local.name,
config: _.cloneDeep(targetState.local.config),
apps: _.mapValues(targetState.local.apps, app => ({
appId: app.appId,
name: app.name,
commit: app.commit,
releaseId: app.releaseId,
services: _.map(app.services, s => s.toComposeObject()),
volumes: _.mapValues(app.volumes, v => v.toComposeObject()),
networks: _.mapValues(app.networks, n => n.toComposeObject()),
})),
};
}
if (targetState.dependent != null) {
target.dependent = _.cloneDeep(target.dependent);
}
res.status(200).json({
status: 'success',
state: target,
});
} catch (err) {
res.status(503).send({
const localMode = await deviceState.config.get('localMode');
if (!localMode) {
return res.status(400).json({
status: 'failed',
message: messageFromError(err),
message: 'Target state can only be retrieved when in local mode',
});
}
const targetState = await deviceState.getTarget();
// We avoid using cloneDeep here, as the class
// instances can cause a maximum call stack exceeded
// error
// TODO: This should really return the config as it
// is returned from the api, but currently that's not
// the easiest thing due to the way they are stored and
// retrieved from the db - when all of the application
// manager is strongly typed, revisit this. The best
// thing to do would be to represent the input with
// io-ts and make sure the below conforms to it
const target: any = {
local: {
config: {},
},
dependent: {
config: {},
},
};
if (targetState.local != null) {
target.local = {
name: targetState.local.name,
config: _.cloneDeep(targetState.local.config),
apps: _.mapValues(targetState.local.apps, app => ({
appId: app.appId,
name: app.name,
commit: app.commit,
releaseId: app.releaseId,
services: _.map(app.services, s => s.toComposeObject()),
volumes: _.mapValues(app.volumes, v => v.toComposeObject()),
networks: _.mapValues(app.networks, n => n.toComposeObject()),
})),
};
}
if (targetState.dependent != null) {
target.dependent = _.cloneDeep(target.dependent);
}
res.status(200).json({
status: 'success',
state: target,
});
});
router.post('/v2/local/target-state', async (req, res) => {
// let's first ensure that we're in local mode, otherwise
// this function should not do anything
try {
const localMode = await deviceState.config.get('localMode');
if (!localMode) {
return res.status(400).json({
status: 'failed',
message: 'Target state can only set when device is in local mode',
});
}
// Now attempt to set the state
const force = req.body.force;
const targetState = req.body;
try {
await deviceState.setTarget(targetState, true);
await deviceState.triggerApplyTarget({ force });
res.status(200).json({
status: 'success',
message: 'OK',
});
} catch (e) {
res.status(400).json({
status: 'failed',
message: e.message,
});
}
} catch (e) {
const message = 'Could not apply target state: ';
res.status(503).json({
const localMode = await deviceState.config.get('localMode');
if (!localMode) {
return res.status(400).json({
status: 'failed',
message: message + messageFromError(e),
message: 'Target state can only set when device is in local mode',
});
}
// Now attempt to set the state
const force = req.body.force;
const targetState = req.body;
try {
await deviceState.setTarget(targetState, true);
await deviceState.triggerApplyTarget({ force });
res.status(200).json({
status: 'success',
message: 'OK',
});
} catch (e) {
res.status(400).json({
status: 'failed',
message: e.message,
});
}
});
@ -340,29 +302,21 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
router.get('/v2/local/device-info', async (_req, res) => {
// Return the device type and slug so that local mode builds can use this to
// resolve builds
try {
// FIXME: We should be mounting the following file into the supervisor from the
// start-resin-supervisor script, changed in meta-resin - but until then, hardcode it
const data = await fs.readFile(
'/mnt/root/resin-boot/device-type.json',
'utf8',
);
const deviceInfo = JSON.parse(data);
// FIXME: We should be mounting the following file into the supervisor from the
// start-resin-supervisor script, changed in meta-resin - but until then, hardcode it
const data = await fs.readFile(
'/mnt/root/resin-boot/device-type.json',
'utf8',
);
const deviceInfo = JSON.parse(data);
return res.status(200).json({
status: 'success',
info: {
arch: deviceInfo.arch,
deviceType: deviceInfo.slug,
},
});
} catch (e) {
const message = 'Could not fetch device information: ';
res.status(503).json({
status: 'failed',
message: message + messageFromError(e),
});
}
return res.status(200).json({
status: 'success',
info: {
arch: deviceInfo.arch,
deviceType: deviceInfo.slug,
},
});
});
router.get('/v2/local/logs', async (_req, res) => {
@ -392,39 +346,29 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
});
router.get('/v2/containerId', async (req, res) => {
try {
const services = await applications.services.getAll();
const services = await applications.services.getAll();
if (req.query.serviceName != null || req.query.service != null) {
const serviceName = req.query.serviceName || req.query.service;
const service = _.find(
services,
svc => svc.serviceName === serviceName,
);
if (service != null) {
res.status(200).json({
status: 'success',
containerId: service.containerId,
});
} else {
res.status(503).json({
status: 'failed',
message: 'Could not find service with that name',
});
}
} else {
if (req.query.serviceName != null || req.query.service != null) {
const serviceName = req.query.serviceName || req.query.service;
const service = _.find(services, svc => svc.serviceName === serviceName);
if (service != null) {
res.status(200).json({
status: 'success',
services: _(services)
.keyBy('serviceName')
.mapValues('containerId')
.value(),
containerId: service.containerId,
});
} else {
res.status(503).json({
status: 'failed',
message: 'Could not find service with that name',
});
}
} catch (e) {
res.status(503).json({
status: 'failed',
message: messageFromError(e),
} else {
res.status(200).json({
status: 'success',
services: _(services)
.keyBy('serviceName')
.mapValues('containerId')
.value(),
});
}
});
@ -481,85 +425,51 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
});
router.get('/v2/device/name', async (_req, res) => {
try {
const deviceName = await applications.config.get('name');
res.json({
status: 'success',
deviceName,
});
} catch (e) {
res.status(503).json({
status: 'failed',
message: messageFromError(e),
});
}
const deviceName = await applications.config.get('name');
res.json({
status: 'success',
deviceName,
});
});
router.get('/v2/device/tags', async (_req, res) => {
try {
const tags = await applications.apiBinder.fetchDeviceTags();
return res.json({
status: 'success',
tags,
});
} catch (e) {
res.status(503).json({
status: 'failed',
message: messageFromError(e),
});
}
const tags = await applications.apiBinder.fetchDeviceTags();
return res.json({
status: 'success',
tags,
});
});
router.get('/v2/cleanup-volumes', async (_req, res) => {
try {
const targetState = await applications.getTargetApps();
const referencedVolumes: string[] = [];
_.each(targetState, app => {
_.each(app.volumes, vol => {
referencedVolumes.push(
Volume.generateDockerName(vol.appId, vol.name),
);
});
const targetState = await applications.getTargetApps();
const referencedVolumes: string[] = [];
_.each(targetState, app => {
_.each(app.volumes, vol => {
referencedVolumes.push(Volume.generateDockerName(vol.appId, vol.name));
});
await applications.volumes.removeOrphanedVolumes(referencedVolumes);
res.json({
status: 'success',
});
} catch (e) {
res.status(503).json({
status: 'failed',
message: messageFromError(e),
});
}
});
await applications.volumes.removeOrphanedVolumes(referencedVolumes);
res.json({
status: 'success',
});
});
router.post('/v2/journal-logs', (req, res) => {
try {
const all = checkTruthy(req.body.all) || false;
const follow = checkTruthy(req.body.follow) || false;
const count = checkInt(req.body.count, { positive: true }) || undefined;
const unit = req.body.unit;
const format = req.body.format || 'short';
const all = checkTruthy(req.body.all) || false;
const follow = checkTruthy(req.body.follow) || false;
const count = checkInt(req.body.count, { positive: true }) || undefined;
const unit = req.body.unit;
const format = req.body.format || 'short';
const journald = spawnJournalctl({ all, follow, count, unit, format });
res.status(200);
journald.stdout.pipe(res);
res.on('close', () => {
journald.kill('SIGKILL');
});
journald.on('exit', () => {
journald.stdout.unpipe();
res.end();
});
} catch (e) {
log.error('There was an error reading the journalctl process', e);
if (res.headersSent) {
return;
}
res.json({
status: 'failed',
message: messageFromError(e),
});
}
const journald = spawnJournalctl({ all, follow, count, unit, format });
res.status(200);
journald.stdout.pipe(res);
res.on('close', () => {
journald.kill('SIGKILL');
});
journald.on('exit', () => {
journald.stdout.unpipe();
res.end();
});
});
}

View File

@ -1,3 +1,4 @@
import { NextFunction, Request, Response } from 'express';
import * as express from 'express';
import { Server } from 'http';
import * as _ from 'lodash';
@ -102,15 +103,11 @@ export class SupervisorAPI {
this.api.use(expressLogger);
this.api.get('/v1/healthy', async (_req, res) => {
try {
const healths = await Promise.all(this.healthchecks.map(fn => fn()));
if (!_.every(healths)) {
throw new Error('Unhealthy');
}
return res.sendStatus(200);
} catch (e) {
res.sendStatus(500);
const healths = await Promise.all(this.healthchecks.map(fn => fn()));
if (!_.every(healths)) {
throw new Error('Unhealthy');
}
return res.sendStatus(200);
});
this.api.get('/ping', (_req, res) => res.send('OK'));
@ -127,19 +124,44 @@ export class SupervisorAPI {
// Expires the supervisor's API key and generates a new one.
// It also communicates the new key to the balena API.
this.api.post('/v1/regenerate-api-key', async (_req, res) => {
try {
const secret = await this.config.newUniqueKey();
await this.config.set({ apiSecret: secret });
res.status(200).send(secret);
} catch (e) {
res.status(503).send(e != null ? e.message : e || 'Unknown error');
}
const secret = await this.config.newUniqueKey();
await this.config.set({ apiSecret: secret });
res.status(200).send(secret);
});
// And assign all external routers
for (const router of this.routers) {
this.api.use(router);
}
// Error handling.
const messageFromError = (err?: Error | string | null): string => {
let message = 'Unknown error';
if (err != null) {
if (_.isError(err) && err.message != null) {
message = err.message;
} else {
message = err as string;
}
}
return message;
};
this.api.use(
(err: Error, req: Request, res: Response, next: NextFunction) => {
if (res.headersSent) {
// Error happens while we are writing the response - default handler closes the connection.
next(err);
return;
}
log.error(`Error on ${req.method} ${req.path}: `, err);
res.status(503).send({
status: 'failed',
message: messageFromError(err),
});
},
);
}
public async listen(