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

[appveyor] Error handling in BaseService #1590

Merged
merged 16 commits into from
Apr 2, 2018
Merged
26 changes: 26 additions & 0 deletions lib/error-helper.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
'use strict';

const {
NotFound,
InvalidResponse,
} = require('../services/errors');

const checkErrorResponse = function(badgeData, err, res, notFoundMessage = 'not found') {
if (err != null) {
badgeData.text[1] = 'inaccessible';
Expand All @@ -18,6 +23,27 @@ const checkErrorResponse = function(badgeData, err, res, notFoundMessage = 'not
}
};

checkErrorResponse.asPromise = function ({ notFoundMessage } = {}) {
return async function ({ buffer, res }) {
Copy link
Member

Choose a reason for hiding this comment

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

Does this have to be async? It's not doing anything asynchronously.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think if it weren't async, it would not return a promise.

if (res.statusCode === 404) {
throw new NotFound(notFoundMessage);
} else if (res.statusCode !== 200) {
const underlying = Error(`Got status code ${res.statusCode} (expected 200)`);
throw new InvalidResponse(undefined, underlying);
}
return { buffer, res };
};
};

async function asJson({ buffer, res }) {
try {
return JSON.parse(buffer);
} catch (err) {
throw new InvalidResponse();
}
};

module.exports = {
checkErrorResponse,
asJson,
};
59 changes: 58 additions & 1 deletion lib/error-helper.spec.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
'use strict';

const { assert } = require('chai');
const chai = require('chai');
const { assert, expect } = chai;
const { checkErrorResponse } = require('./error-helper');
const { NotFound, InvalidResponse } = require('../services/errors');

chai.use(require('chai-as-promised'));

describe('Standard Error Handler', function() {
it('makes inaccessible badge', function() {
Expand Down Expand Up @@ -36,3 +40,56 @@ describe('Standard Error Handler', function() {
assert.equal(false, checkErrorResponse({'text': []}, null, {statusCode: 200}));
});
});

describe('async error handler', function() {
context('when status is 200', function() {
it('passes through the inputs', async function() {
const args = { buffer: 'buffer', res: { statusCode: 200 } };
expect(await checkErrorResponse.asPromise()(args))
.to.deep.equal(args);
});
});

context('when status is 404', function() {
const res = { statusCode: 404 };

it('throws NotFound', async function() {
try {
await checkErrorResponse.asPromise()({ res });
expect.fail('Expected to throw');
} catch (e) {
expect(e).to.be.an.instanceof(NotFound);
expect(e.message).to.equal('Not Found');
expect(e.prettyMessage).to.equal('not found');
}
});

it('displays the custom not found message', async function() {
const notFoundMessage = 'no goblins found';
const res = { statusCode: 404 };
try {
await checkErrorResponse.asPromise({ notFoundMessage })({ res });
expect.fail('Expected to throw');
} catch (e) {
expect(e).to.be.an.instanceof(NotFound);
expect(e.message).to.equal('Not Found: no goblins found');
expect(e.prettyMessage).to.equal('no goblins found');
}
});
});

context('when status is 500', function() {
const res = { statusCode: 500 };

it('throws InvalidResponse', async function() {
try {
await checkErrorResponse.asPromise()({ res });
expect.fail('Expected to throw');
} catch (e) {
expect(e).to.be.an.instanceof(InvalidResponse);
expect(e.message).to.equal('Invalid Response: Got status code 500 (expected 200)');
expect(e.prettyMessage).to.equal('invalid');
}
});
});
});
28 changes: 18 additions & 10 deletions lib/request-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const LruCache = require('./lru-cache');
const analytics = require('./analytics');
const { makeSend } = require('./result-sender');
const queryString = require('query-string');
const { Inaccessible } = require('../services/errors');

// We avoid calling the vendor's server for computation of the information in a
// number of badges.
Expand Down Expand Up @@ -167,20 +168,22 @@ function handleRequest (makeBadge, handlerOptions) {
});
}

// Wrapper around `cachingRequest` that returns a promise rather than
// needing to pass a callback.
cachingRequest.asPromise = (uri, options) => new Promise((resolve, reject) => {
cachingRequest(uri, options, (err, res, buffer) => {
if (err) {
reject(err);
} else {
resolve({res, buffer});
}
// Wrapper around `cachingRequest` that returns a promise rather than
// needing to pass a callback.
cachingRequest.asPromise = (uri, options) => new Promise((resolve, reject) => {
cachingRequest(uri, options, (err, res, buffer) => {
if (err) {
// Wrap the error in an Inaccessible so it can be identified
// by the BaseService handler.
reject(new Inaccessible(err));
} else {
resolve({ res, buffer });
}
});
});

vendorDomain.run(() => {
handlerOptions.handler(filteredQueryParams, match, function sendBadge(format, badgeData) {
const result = handlerOptions.handler(filteredQueryParams, match, function sendBadge(format, badgeData) {
if (serverUnresponsive) { return; }
clearTimeout(serverResponsive);
// Check for a change in the data.
Expand All @@ -206,6 +209,11 @@ function handleRequest (makeBadge, handlerOptions) {
makeSend(format, ask.res, end)(svg);
}
}, cachingRequest);
if (result && result.catch) {
result.catch(err => {
throw err;
});
}
});
};
}
Expand Down
1 change: 1 addition & 0 deletions lib/server-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const config = {
makeBadge: envFlag(process.env.PROFILE_MAKE_BADGE),
},
rateLimit: envFlag(process.env.RATE_LIMIT, true),
handleInternalErrors: envFlag(process.env.HANDLE_INTERNAL_ERRORS, true),
};

if (config.font.fallbackPath) {
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

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

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@
"lint": "eslint \"**/*.js\"",
"danger": "danger",
"test:js:frontend": "NODE_ENV=mocha mocha --require babel-polyfill --require babel-register \"frontend/**/*.spec.js\"",
"test:js:server": "mocha \"*.spec.js\" \"lib/**/*.spec.js\" \"services/**/*.spec.js\"",
"test:services": "mocha --delay lib/service-test-runner/cli.js",
"test:js:server": "HANDLE_INTERNAL_ERRORS=false mocha \"*.spec.js\" \"lib/**/*.spec.js\" \"services/**/*.spec.js\"",
"test:services": "HANDLE_INTERNAL_ERRORS=false mocha --delay lib/service-test-runner/cli.js",
"test:services:pr:prepare": "node lib/service-test-runner/pull-request-services-cli.js > pull-request-services.log",
"test:services:pr:run": "mocha --delay lib/service-test-runner/cli.js --stdin < pull-request-services.log",
"test:services:pr:run": "HANDLE_INTERNAL_ERRORS=false mocha --delay lib/service-test-runner/cli.js --stdin < pull-request-services.log",
"test:services:pr": "npm run test:services:pr:prepare && npm run test:services:pr:run",
"test": "npm run lint && npm run test:js:frontend && npm run test:js:server",
"circle-images:build": "docker build -t shieldsio/shields-ci-node-8:${IMAGE_TAG} -f .circleci/images/node-8/Dockerfile . && docker build -t shieldsio/shields-ci-node-9:${IMAGE_TAG} -f .circleci/images/node-9/Dockerfile .",
Expand All @@ -73,7 +73,7 @@
"build": "npm run examples && next build && next export -o build/",
"heroku-postbuild": "npm run build",
"analyze": "ANALYZE=true LONG_CACHE=false BASE_URL=https://img.shields.io npm run build",
"start:server": "RATE_LIMIT=false node server 8080 ::",
"start:server": "HANDLE_INTERNAL_ERRORS=false RATE_LIMIT=false node server 8080 ::",
"now-start": "node server",
"prestart": "npm run depcheck && npm run examples",
"start": "concurrently --names server,frontend \"ALLOWED_ORIGIN=http://localhost:3000 npm run start:server\" \"BASE_URL=http://[::]:8080 next dev\""
Expand All @@ -99,6 +99,7 @@
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.6.1",
"chai": "^4.1.2",
"chai-as-promised": "^7.1.1",
"chai-string": "^1.4.0",
"chainsmoker": "^0.1.0",
"check-node-version": "^3.1.0",
Expand Down
5 changes: 4 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ camp.notfound(/.*/, function(query, match, end, request) {
// Vendors.

loadServiceClasses().forEach(
serviceClass => serviceClass.register(camp, cache));
serviceClass => serviceClass.register(
camp,
cache,
{ handleInternalErrors: config.handleInternalErrors }));

// JIRA issue integration
camp.route(/^\/jira\/issue\/(http(?:s)?)\/(.+)\/([^/]+)\.(svg|png|gif|jpg|json)$/,
Expand Down
19 changes: 8 additions & 11 deletions services/appveyor/appveyor.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
'use strict';

const BaseService = require('../base');
const {
checkErrorResponse,
asJson,
} = require('../../lib/error-helper');

/**
* AppVeyor CI integration.
*/
module.exports = class AppVeyor extends BaseService {
async handle({repo, branch}) {
let apiUrl = 'https://ci.appveyor.com/api/projects/' + repo;
if (branch != null) {
apiUrl += '/branch/' + branch;
}
const {buffer, res} = await this._sendAndCacheRequest(apiUrl, {
const json = await this._sendAndCacheRequest(apiUrl, {
headers: { 'Accept': 'application/json' }
});
}).then(checkErrorResponse.asPromise({ notFoundMessage: 'project not found or access denied' }))
Copy link
Member

Choose a reason for hiding this comment

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

I know this is already a good reduction in boilerplate, but I feel like we're very often going to chain .then(checkErrorResponse.asPromise({})) here. Do you think it would be worth maybe wrapping this in the base class so that the error handling function is passed as a param with a default value?

.. something like:

  async _wrappedRequest(url, options, handler=checkErrorResponse.asPromise({})) {
    return this._sendAndCacheRequest(url, options).then(handler);
  }

in base.js and then we can call const json = await this._wrappedRequest(url, options).then(asJson); when we want to use the default handler (which is probably ~90% of cases) and const json = await this._wrappedRequest(url, options, myCustomHander).then(asJson); in the case where we want to customise the message or deal with a non-standard error condition or whatever.

If there is a situation where something really non-standard happens, we could always override _wrappedRequest (or whatever we call it - better names welcome) or just call _sendAndCacheRequest directly. Does that seem sensible?

Copy link
Member Author

Choose a reason for hiding this comment

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

Agreed, the vast majority of the time we're wanting to do the same thing here. Adding a method is a nice idea. Perhaps it could be _performRequest or _makeRequest.

For a custom not-found message, which is or should be relatively common, perhaps we could use a separate static get notFoundMessage() getter.

As you say, this could be overridden when something more custom is needed.

I'd even go so far as to say that, perhaps, the method should be _requestJson, which parses JSON by default, and the the handful of services which need something else could define or use a different method, or call external helpers (like the SVG badge fetcher). Then the 90% case looks like const json = await this._requestJson(url, options);, including if we're overriding the not-found message, and doesn't need to require any helpers.

I'd prefer to have concerns a little more separated – having JSON-defaulting stuff in BaseService begins to cross the line for me – but we can revisit that later. With a few more examples, probably separating concerns will be a bit easier.

Copy link
Member

Choose a reason for hiding this comment

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

I'd even go so far as to say that, perhaps, the method should be _requestJson
...
I'd prefer to have concerns a little more separated – having JSON-defaulting stuff in BaseService begins to cross the line for me

I had a very similar thought to this.

Maybe this is another good way to look at this:
Now BaseService is a class we can extend it, so you can declare

class BaseJsonService extends BaseService

and chain .then(asJson) in the BaseJsonService version of whatever we call that method, but not in BaseService. If we spot other common patterns that we are using in lots of places, we could also define other base classes. We don't have to do it all in one object.

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure! I'm good with that as a starting point. Though, down the line, I'd also like to consider breaking the vendor-calling code out of the service class.

.then(asJson);
Copy link
Member

Choose a reason for hiding this comment

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

Question: If we want to throw an exception here in the handle() function because we detect an error condition based on the response content rather than the status code, can you give an example of how to do that? Based on the calling code

     try {
       return await this.handle(namedParams);
     } catch (error) {
       ...
     }

it seems like I should be able to throw new Error('foo'); or return Promise.reject(new Error('foo')); in here, but both of those throw UnhandledPromiseRejectionWarning if I try that. Obviously once we've got a more final implementation we can document stuff like this.

Copy link
Member Author

Choose a reason for hiding this comment

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

Right! So, the way to signal an exception based on the response content is to throw new InvalidResponse(message), or just throw new InvalidResponse() to display invalid.

That said, the UnhandledPromiseRejectionWarning is a bug (see #1590 (comment)). The behavior I would expect in that situation is that the thrown error is caught and emitted directly. (I think that's possible.)

Copy link
Member

Choose a reason for hiding this comment

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

Right yes, so if I add the line throw new InvalidResponse('foo'); here, that does what we expect. If I add throw new Error('foo'); here that throws UnhandledPromiseRejectionWarning. It seems like that should also be caught in invokeHandler()

Copy link
Member

Choose a reason for hiding this comment

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

Bit more info: that behaviour is only true with HANDLE_INTERNAL_ERRORS=false.

If I set HANDLE_INTERNAL_ERRORS=true there, we get a standard 'internal error', but I guess the error it is catching/re-throwing is UnhandledPromiseRejectionWarning rather than the new Error() we threw.

Copy link
Member Author

Choose a reason for hiding this comment

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

It took me a while to get clarity about what needed to happen here, though I think I've gotten it working well now. Would you like to take a look?


if (res.statusCode === 404) {
return {message: 'project not found or access denied'};
}

const data = JSON.parse(buffer);
const status = data.build.status;
const { build: { status } } = json;
if (status === 'success') {
return {message: 'passing', color: 'brightgreen'};
} else if (status !== 'running' && status !== 'queued') {
Expand Down
5 changes: 5 additions & 0 deletions services/appveyor/appveyor.tester.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ t.create('CI 404')
.get('/ci/somerandomproject/thatdoesntexits.json')
.expectJSON({ name: 'build', value: 'project not found or access denied' });

t.create('CI (connection error)')
.get('/ci/this-one/is-not-real-either.json')
.networkOff()
.expectJSON({ name: 'build', value: 'inaccessible' });

// Test AppVeyor tests status badge
t.create('tests status')
.get('/tests/NZSmartie/coap-net-iu0to.json')
Expand Down
40 changes: 33 additions & 7 deletions services/base.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
'use strict';

const {
NotFound,
InvalidResponse,
Inaccessible,
} = require('./errors');
const {
makeLogo,
toArray,
Expand All @@ -8,8 +13,9 @@ const {
} = require('../lib/badge-data');

module.exports = class BaseService {
constructor({sendAndCacheRequest}) {
constructor({ sendAndCacheRequest }, { handleInternalErrors }) {
this._sendAndCacheRequest = sendAndCacheRequest;
this._handleInternalErrors = handleInternalErrors;
}

/**
Expand All @@ -32,6 +38,7 @@ module.exports = class BaseService {
static get category() {
return 'unknown';
}

/**
* Returns an object:
* - base: (Optional) The base path of the URLs for this service. This is
Expand Down Expand Up @@ -95,8 +102,27 @@ module.exports = class BaseService {
try {
return await this.handle(namedParams);
} catch (error) {
console.log(error);
return { message: 'error' };
if (error instanceof NotFound) {
return {
message: error.prettyMessage,
color: 'red',
};
} else if (error instanceof InvalidResponse ||
error instanceof Inaccessible) {
return {
message: error.prettyMessage,
color: 'lightgray',
};
} else if (this._handleInternalErrors) {
console.log(error);
return {
label: 'shields',
message: 'internal error',
color: 'lightgray',
};
} else {
throw error;
}
}
}

Expand All @@ -118,15 +144,15 @@ module.exports = class BaseService {
link: serviceLink,
} = serviceData;

const defaultLabel = this.category;
const {
color: defaultColor,
logo: defaultLogo,
label: defaultLabel,
} = this.defaultBadgeData;

const badgeData = {
text: [
overrideLabel || serviceLabel || defaultLabel,
overrideLabel || serviceLabel || defaultLabel || this.category,
serviceMessage || 'n/a',
],
template: style,
Expand All @@ -141,15 +167,15 @@ module.exports = class BaseService {
return badgeData;
}

static register(camp, handleRequest) {
static register(camp, handleRequest, { handleInternalErrors }) {
const ServiceClass = this; // In a static context, "this" is the class.

camp.route(this._regex,
handleRequest(async (queryParams, match, sendBadge, request) => {
const namedParams = this._namedParamsForMatch(match);
const serviceInstance = new ServiceClass({
sendAndCacheRequest: request.asPromise,
});
}, { handleInternalErrors });
const serviceData = await serviceInstance.invokeHandler(namedParams);
const badgeData = this._makeBadgeData(queryParams, serviceData);

Expand Down
Loading