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

Implement a pattern for dealing with upstream APIs which are slow on the first hit; affects [endpoint] #9233

Merged
merged 7 commits into from
Jun 13, 2023
8 changes: 8 additions & 0 deletions core/base-service/base-graphql.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ class BaseGraphqlService extends BaseService {
* and custom error messages e.g: `{ 404: 'package not found' }`.
* This can be used to extend or override the
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
* @param {object} [attrs.customExceptions={}] Key-value map of got network exception codes
* and an object of params to pass when we construct an Inaccessible exception object
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
* for allowed keys
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
* @param {Function} [attrs.transformJson=data => data] Function which takes the raw json and transforms it before
* further procesing. In case of multiple query in a single graphql call and few of them
* throw error, partial data might be used ignoring the error.
Expand All @@ -62,6 +68,7 @@ class BaseGraphqlService extends BaseService {
variables = {},
options = {},
httpErrorMessages = {},
customExceptions = {},
transformJson = data => data,
transformErrors = defaultTransformErrors,
}) {
Expand All @@ -75,6 +82,7 @@ class BaseGraphqlService extends BaseService {
url,
options: mergedOptions,
errorMessages: httpErrorMessages,
customExceptions,
})
const json = transformJson(this._parseJson(buffer))
if (json.errors) {
Expand Down
15 changes: 14 additions & 1 deletion core/base-service/base-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,22 @@ class BaseJsonService extends BaseService {
* and custom error messages e.g: `{ 404: 'package not found' }`.
* This can be used to extend or override the
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
* @param {object} [attrs.customExceptions={}] Key-value map of got network exception codes
* and an object of params to pass when we construct an Inaccessible exception object
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
* for allowed keys
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
* @returns {object} Parsed response
* @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md
*/
async _requestJson({ schema, url, options = {}, errorMessages = {} }) {
async _requestJson({
schema,
url,
options = {},
errorMessages = {},
customExceptions = {},
Copy link
Member Author

Choose a reason for hiding this comment

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

errorMessages and customExceptions do somewhat similar but importantly different things. Naming things is hard.

Copy link
Member

Choose a reason for hiding this comment

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

ack yeah I see that being tricky down the road since both of these are (ultimately) used in the setting of error messages.

I feel like in a perfect world (one where no refactoring would be required), I'd be inclined to keep a single top level object for errors and maybe have it have two properties, one that would point to an object that's our standard http response status code to error message dictionary we have today, and the other would be the new I feel like a single top level object that had two properties, one being new dictionary that maps nodejs error codes to a desired error message.

Thinking on a bit more, I suppose having those two mappings separate as you do here is fine; I think it's just the first having squatted on the highly generic errorMessages name that feels problematic.

What if we come up with a more descriptive name for this one for now, e.g. (overly verbosely) nodeErrorCodeToShieldsExceptions and then we could potentially consider renaming errorMessages to something similarly more descriptive down the road?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah broadly agreed. Just thinking about the ergonomics of consuming this at the service layer...

Mapping HTTP error codes to error messages is really common - we do stuff like

errorMessages: { 404: 'package not found' }

all over the place, so lets keep that easy to do in a convenient way.

I've designed this feature (provisionally lets call it customExceptions) to be relatively generic, but I'd expect us to actually use this quite rarely.

I think given that, I'd prefer to keep a flatter structure and avoid making errorMessages a nested object. In the vast majority of cases we would only care about one of the keys. If we can come up with 2 good names we would need to change the code in loads of places anyway, so lets make those two good names the top-level names rather than introduce another layer of nesting.

i.e: If we need to go and change a load of existing service classes, I'd rather do a big find and replace changing

errorMessages: { 404: 'package not found' }

to

httpErrors: { 404: 'package not found' }

than

errorMessages: { httpErrors: { 404: 'package not found' } }

or whatever.

If we ignore the fact that there is loads of existing code using errorMessages and we were naming these two concepts from scratch today, what do you think would be the ideal names/API for these things? I guess the things we currently call errorMessages are really HTTP Status Codes. The thing I've called customExceptions are really System Error Codes. How about httpErrors and systemErrors ?

Copy link
Member

Choose a reason for hiding this comment

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

How about httpErrors and systemErrors ?

Yup, love it. Definitely clears up the naming overlap and is much more succinct than the ones I threw out there

}) {
const mergedOptions = {
...{ headers: { Accept: 'application/json' } },
...options,
Expand All @@ -46,6 +58,7 @@ class BaseJsonService extends BaseService {
url,
options: mergedOptions,
errorMessages,
customExceptions,
})
const json = this._parseJson(buffer)
return this.constructor._validate(json, schema)
Expand Down
8 changes: 8 additions & 0 deletions core/base-service/base-svg-scraping.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ class BaseSvgScrapingService extends BaseService {
* and custom error messages e.g: `{ 404: 'package not found' }`.
* This can be used to extend or override the
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
* @param {object} [attrs.customExceptions={}] Key-value map of got network exception codes
* and an object of params to pass when we construct an Inaccessible exception object
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
* for allowed keys
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
* @returns {object} Parsed response
* @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md
*/
Expand All @@ -66,6 +72,7 @@ class BaseSvgScrapingService extends BaseService {
url,
options = {},
errorMessages = {},
customExceptions = {},
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
const mergedOptions = {
Expand All @@ -76,6 +83,7 @@ class BaseSvgScrapingService extends BaseService {
url,
options: mergedOptions,
errorMessages,
customExceptions,
})
logTrace(emojic.dart, 'Response SVG', buffer)
const data = {
Expand Down
8 changes: 8 additions & 0 deletions core/base-service/base-xml.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ class BaseXmlService extends BaseService {
* and custom error messages e.g: `{ 404: 'package not found' }`.
* This can be used to extend or override the
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
* @param {object} [attrs.customExceptions={}] Key-value map of got network exception codes
* and an object of params to pass when we construct an Inaccessible exception object
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
* for allowed keys
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
* @param {object} [attrs.parserOptions={}] Options to pass to fast-xml-parser. See
* [documentation](https://github.com/NaturalIntelligence/fast-xml-parser#xml-to-json)
* @returns {object} Parsed response
Expand All @@ -39,6 +45,7 @@ class BaseXmlService extends BaseService {
url,
options = {},
errorMessages = {},
customExceptions = {},
parserOptions = {},
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
Expand All @@ -50,6 +57,7 @@ class BaseXmlService extends BaseService {
url,
options: mergedOptions,
errorMessages,
customExceptions,
})
const validateResult = XMLValidator.validate(buffer)
if (validateResult !== true) {
Expand Down
8 changes: 8 additions & 0 deletions core/base-service/base-yaml.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class BaseYamlService extends BaseService {
* and custom error messages e.g: `{ 404: 'package not found' }`.
* This can be used to extend or override the
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
* @param {object} [attrs.customExceptions={}] Key-value map of got network exception codes
* and an object of params to pass when we construct an Inaccessible exception object
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
* for allowed keys
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
* @param {object} [attrs.encoding='utf8'] Character encoding
* @returns {object} Parsed response
* @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md
Expand All @@ -36,6 +42,7 @@ class BaseYamlService extends BaseService {
url,
options = {},
errorMessages = {},
customExceptions = {},
encoding = 'utf8',
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
Expand All @@ -52,6 +59,7 @@ class BaseYamlService extends BaseService {
url,
options: mergedOptions,
errorMessages,
customExceptions,
})
let parsed
try {
Expand Down
19 changes: 16 additions & 3 deletions core/base-service/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,12 @@ class BaseService {
this._metricHelper = metricHelper
}

async _request({ url, options = {}, errorMessages = {} }) {
async _request({
url,
options = {},
errorMessages = {},
customExceptions = {},
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
let logUrl = url
const logOptions = Object.assign({}, options)
Expand All @@ -246,7 +251,11 @@ class BaseService {
'Request',
`${logUrl}\n${JSON.stringify(logOptions, null, 2)}`
)
const { res, buffer } = await this._requestFetcher(url, options)
const { res, buffer } = await this._requestFetcher(
url,
options,
customExceptions
)
await this._meterResponse(res, buffer)
logTrace(emojic.dart, 'Response status code', res.statusCode)
return checkErrorResponse(errorMessages)({ buffer, res })
Expand Down Expand Up @@ -328,11 +337,15 @@ class BaseService {
error instanceof Deprecated
) {
trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
return {
const serviceData = {
isError: true,
message: error.prettyMessage,
color: 'lightgray',
}
if (error.cacheSeconds !== undefined) {
serviceData.cacheSeconds = error.cacheSeconds
}
return serviceData
} else if (this._handleInternalErrors) {
if (
!trace.logTrace(
Expand Down
2 changes: 1 addition & 1 deletion core/base-service/cache-headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,14 @@ function coalesceCacheLength({
assert(defaultCacheLengthSeconds !== undefined)

const cacheLength = coalesce(
serviceOverrideCacheLengthSeconds,
serviceDefaultCacheLengthSeconds,
defaultCacheLengthSeconds
)

// Overrides can apply _more_ caching, but not less. Query param overriding
// can request more overriding than service override, but not less.
const candidateOverrides = [
serviceOverrideCacheLengthSeconds,
overrideCacheLengthFromQueryParams(queryParams),
Comment on lines 41 to 50
Copy link
Member Author

@chris48s chris48s Jun 2, 2023

Choose a reason for hiding this comment

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

For the history on this, see #2755

Basically, the only thing relying on the logic as it stood here was the endpoint badge (we don't want endpoint badge users to be able to set a lower cacheSeconds value than the service default). I've moved this logic so it gets applied in endpoint.service.js so that we can now lower cacheSeconds with a custom exception property if we want. If we left this as it was, we'd only be able to raise cacheSeconds with a custom exception property (but not lower it).

].filter(x => x !== undefined)

Expand Down
4 changes: 2 additions & 2 deletions core/base-service/cache-headers.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ describe('Cache header functions', function () {
serviceDefaultCacheLengthSeconds: 900,
serviceOverrideCacheLengthSeconds: 400,
queryParams: {},
}).expect(900)
}).expect(400)
given({
cacheHeaderConfig,
serviceOverrideCacheLengthSeconds: 400,
queryParams: {},
}).expect(777)
}).expect(400)
given({
cacheHeaderConfig,
serviceOverrideCacheLengthSeconds: 900,
Expand Down
4 changes: 4 additions & 0 deletions core/base-service/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ShieldsRuntimeError extends Error {
if (props.underlyingError) {
this.stack = props.underlyingError.stack
}
this.cacheSeconds = props.cacheSeconds
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've done this here so any ShieldsRuntimeError can have a custom cacheSeconds but at the moment we're only using it for Inaccessible

}
}

Expand Down Expand Up @@ -206,6 +207,9 @@ class Deprecated extends ShieldsRuntimeError {
* @property {string} prettyMessage User-facing error message to override the
* value of `defaultPrettyMessage()`. This is the text that will appear on the
* badge when we catch and render the exception (Optional)
* @property {number} cacheSeconds Length of time to cache this error response
* for. Defaults to the cacheLength of the service class throwing the error
* (Optional)
*/

export {
Expand Down
13 changes: 12 additions & 1 deletion core/base-service/got.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {

const userAgent = getUserAgent()

async function sendRequest(gotWrapper, url, options) {
async function sendRequest(
gotWrapper,
url,
options = {},
customExceptions = {}
) {
const gotOptions = Object.assign({}, options)
gotOptions.throwHttpErrors = false
gotOptions.retry = { limit: 0 }
Expand All @@ -22,6 +27,12 @@ async function sendRequest(gotWrapper, url, options) {
underlyingError: new Error('Maximum response size exceeded'),
})
}
if (err.code in customExceptions) {
throw new Inaccessible({
...customExceptions[err.code],
underlyingError: err,
})
Copy link
Member Author

Choose a reason for hiding this comment

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

Spreading in this order means you can't manually override underlyingError. I don't think it would ever make sense to do that.

}
throw new Inaccessible({ underlyingError: err })
}
}
Expand Down
30 changes: 30 additions & 0 deletions core/base-service/got.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,36 @@ describe('got wrapper', function () {
)
})

it('should throw a custom error if provided', async function () {
const sendRequest = _fetchFactory(1024)
return (
expect(
sendRequest(
'https://www.google.com/foo/bar',
{ timeout: { request: 1 } },
{
ETIMEDOUT: {
prettyMessage: 'Oh no! A terrible thing has happened',
cacheSeconds: 10,
},
}
)
)
.to.be.rejectedWith(
Inaccessible,
"Inaccessible: Timeout awaiting 'request' for 1ms"
)
// eslint-disable-next-line promise/prefer-await-to-then
.then(error => {
expect(error).to.have.property(
'prettyMessage',
'Oh no! A terrible thing has happened'
)
expect(error).to.have.property('cacheSeconds', 10)
})
)
})

it('should pass a custom user agent header', async function () {
nock('https://www.google.com', {
reqheaders: {
Expand Down
8 changes: 3 additions & 5 deletions core/base-service/legacy-request-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,9 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
// `defaultCacheLengthSeconds` can be overridden by
// `serviceDefaultCacheLengthSeconds` (either by category or on a badge-
// by-badge basis). Then in turn that can be overridden by
// `serviceOverrideCacheLengthSeconds` (which we expect to be used only in
// the dynamic badge) but only if `serviceOverrideCacheLengthSeconds` is
// longer than `serviceDefaultCacheLengthSeconds` and then the `cacheSeconds`
// query param can also override both of those but again only if `cacheSeconds`
// is longer.
// `serviceOverrideCacheLengthSeconds`.
// Then the `cacheSeconds` query param can also override both of those
// but only if `cacheSeconds` is longer.
//
// When the legacy services have been rewritten, all the code in here
// will go away, which should achieve this goal in a simpler way.
Expand Down
6 changes: 3 additions & 3 deletions core/base-service/legacy-request-handler.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ describe('The request handler', function () {
expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900')
})

it('should let live service data override the default cache headers with longer value', async function () {
it('should allow serviceData to override the default cache headers with longer value', async function () {
camp.route(
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
handleRequest(
Expand All @@ -168,7 +168,7 @@ describe('The request handler', function () {
expect(headers['cache-control']).to.equal('max-age=400, s-maxage=400')
})

it('should not let live service data override the default cache headers with shorter value', async function () {
it('should allow serviceData to override the default cache headers with shorter value', async function () {
camp.route(
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
handleRequest(
Expand All @@ -185,7 +185,7 @@ describe('The request handler', function () {
)

const { headers } = await got(`${baseUrl}/testing/123.json`)
expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300')
expect(headers['cache-control']).to.equal('max-age=200, s-maxage=200')
calebcartwright marked this conversation as resolved.
Show resolved Hide resolved
})

it('should set the expires header to current time + cacheSeconds', async function () {
Expand Down
5 changes: 4 additions & 1 deletion services/endpoint/endpoint.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ export default class Endpoint extends BaseJsonService {
logoWidth,
logoPosition,
style,
cacheSeconds,
// don't allow the user to set cacheSeconds any shorter than this._cacheLength
cacheSeconds: Math.max(
...[this._cacheLength, cacheSeconds].filter(x => x !== undefined)
),
Comment on lines +181 to +184
Copy link
Member Author

@chris48s chris48s Jun 2, 2023

Choose a reason for hiding this comment

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

I can't leave a line comment on it because I've not changed it in this PR, but there is an existing endpoint service test covering this.

}
}

Expand Down