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
10 changes: 9 additions & 1 deletion 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.systemErrors={}] 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 = {},
systemErrors = {},
transformJson = data => data,
transformErrors = defaultTransformErrors,
}) {
Expand All @@ -74,7 +81,8 @@ class BaseGraphqlService extends BaseService {
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages: httpErrorMessages,
httpErrors: httpErrorMessages,
systemErrors,
})
const json = transformJson(this._parseJson(buffer))
if (json.errors) {
Expand Down
19 changes: 16 additions & 3 deletions core/base-service/base-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,35 @@ class BaseJsonService extends BaseService {
* @param {string} attrs.url URL to request
* @param {object} [attrs.options={}] Options to pass to got. See
* [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md)
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
* @param {object} [attrs.httpErrors={}] Key-value map of status codes
* 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.systemErrors={}] 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 = {},
httpErrors = {},
systemErrors = {},
}) {
const mergedOptions = {
...{ headers: { Accept: 'application/json' } },
...options,
}
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages,
httpErrors,
systemErrors,
})
const json = this._parseJson(buffer)
return this.constructor._validate(json, schema)
Expand Down
14 changes: 11 additions & 3 deletions core/base-service/base-svg-scraping.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ class BaseSvgScrapingService extends BaseService {
* @param {string} attrs.url URL to request
* @param {object} [attrs.options={}] Options to pass to got. See
* [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md)
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
* @param {object} [attrs.httpErrors={}] Key-value map of status codes
* 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.systemErrors={}] 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 @@ -65,7 +71,8 @@ class BaseSvgScrapingService extends BaseService {
valueMatcher,
url,
options = {},
errorMessages = {},
httpErrors = {},
systemErrors = {},
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
const mergedOptions = {
Expand All @@ -75,7 +82,8 @@ class BaseSvgScrapingService extends BaseService {
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages,
httpErrors,
systemErrors,
})
logTrace(emojic.dart, 'Response SVG', buffer)
const data = {
Expand Down
14 changes: 11 additions & 3 deletions core/base-service/base-xml.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ class BaseXmlService extends BaseService {
* @param {string} attrs.url URL to request
* @param {object} [attrs.options={}] Options to pass to got. See
* [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md)
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
* @param {object} [attrs.httpErrors={}] Key-value map of status codes
* 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.systemErrors={}] 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 @@ -38,7 +44,8 @@ class BaseXmlService extends BaseService {
schema,
url,
options = {},
errorMessages = {},
httpErrors = {},
systemErrors = {},
parserOptions = {},
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
Expand All @@ -49,7 +56,8 @@ class BaseXmlService extends BaseService {
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages,
httpErrors,
systemErrors,
})
const validateResult = XMLValidator.validate(buffer)
if (validateResult !== true) {
Expand Down
14 changes: 11 additions & 3 deletions core/base-service/base-yaml.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@ class BaseYamlService extends BaseService {
* @param {string} attrs.url URL to request
* @param {object} [attrs.options={}] Options to pass to got. See
* [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md)
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
* @param {object} [attrs.httpErrors={}] Key-value map of status codes
* 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.systemErrors={}] 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 @@ -35,7 +41,8 @@ class BaseYamlService extends BaseService {
schema,
url,
options = {},
errorMessages = {},
httpErrors = {},
systemErrors = {},
encoding = 'utf8',
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
Expand All @@ -51,7 +58,8 @@ class BaseYamlService extends BaseService {
const { buffer } = await this._request({
url,
options: mergedOptions,
errorMessages,
httpErrors,
systemErrors,
})
let parsed
try {
Expand Down
16 changes: 12 additions & 4 deletions core/base-service/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ class BaseService {
this._metricHelper = metricHelper
}

async _request({ url, options = {}, errorMessages = {} }) {
async _request({ url, options = {}, httpErrors = {}, systemErrors = {} }) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
let logUrl = url
const logOptions = Object.assign({}, options)
Expand All @@ -246,10 +246,14 @@ 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,
systemErrors
)
await this._meterResponse(res, buffer)
logTrace(emojic.dart, 'Response status code', res.statusCode)
return checkErrorResponse(errorMessages)({ buffer, res })
return checkErrorResponse(httpErrors)({ buffer, res })
}

static enabledMetrics = []
Expand Down Expand Up @@ -328,11 +332,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
10 changes: 5 additions & 5 deletions core/base-service/check-error-response.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@ const defaultErrorMessages = {
429: 'rate limited by upstream service',
}

export default function checkErrorResponse(errorMessages = {}) {
export default function checkErrorResponse(httpErrors = {}) {
return async function ({ buffer, res }) {
let error
errorMessages = { ...defaultErrorMessages, ...errorMessages }
httpErrors = { ...defaultErrorMessages, ...httpErrors }
if (res.statusCode === 404) {
error = new NotFound({ prettyMessage: errorMessages[404] })
error = new NotFound({ prettyMessage: httpErrors[404] })
} else if (res.statusCode !== 200) {
const underlying = Error(
`Got status code ${res.statusCode} (expected 200)`
)
const props = { underlyingError: underlying }
if (errorMessages[res.statusCode] !== undefined) {
props.prettyMessage = errorMessages[res.statusCode]
if (httpErrors[res.statusCode] !== undefined) {
props.prettyMessage = httpErrors[res.statusCode]
}
if (res.statusCode >= 500) {
error = new Inaccessible(props)
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
8 changes: 7 additions & 1 deletion core/base-service/got.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {

const userAgent = getUserAgent()

async function sendRequest(gotWrapper, url, options) {
async function sendRequest(gotWrapper, url, options = {}, systemErrors = {}) {
const gotOptions = Object.assign({}, options)
gotOptions.throwHttpErrors = false
gotOptions.retry = { limit: 0 }
Expand All @@ -22,6 +22,12 @@ async function sendRequest(gotWrapper, url, options) {
underlyingError: new Error('Maximum response size exceeded'),
})
}
if (err.code in systemErrors) {
throw new Inaccessible({
...systemErrors[err.code],
underlyingError: err,
})
}
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