graphbrainz/src/api/client.js

145 lines
4.2 KiB
JavaScript
Raw Permalink Normal View History

2016-08-08 07:54:06 +00:00
import request from 'request'
import retry from 'retry'
import ExtendableError from 'es6-error'
import RateLimit from '../rate-limit'
import pkg from '../../package.json'
2016-08-08 07:54:06 +00:00
const debug = require('debug')('graphbrainz:api/client')
2016-11-26 01:38:32 +00:00
2016-08-20 05:59:32 +00:00
// If the `request` callback returns an error, it indicates a failure at a lower
// level than the HTTP response itself. If it's any of the following error
// codes, we should retry.
2016-08-08 07:54:06 +00:00
const RETRY_CODES = {
ECONNRESET: true,
ENOTFOUND: true,
ESOCKETTIMEDOUT: true,
ETIMEDOUT: true,
ECONNREFUSED: true,
EHOSTUNREACH: true,
EPIPE: true,
EAI_AGAIN: true
}
export class ClientError extends ExtendableError {
constructor(message, statusCode) {
2016-08-08 07:54:06 +00:00
super(message)
this.statusCode = statusCode
}
}
export default class Client {
constructor({
baseURL,
userAgent = `${pkg.name}/${pkg.version} ` +
`( ${pkg.homepage || pkg.author.url || pkg.author.email} )`,
extraHeaders = {},
errorClass = ClientError,
timeout = 60000,
limit = 1,
period = 1000,
concurrency = 10,
retries = 10,
// It's OK for `retryDelayMin` to be less than one second, even 0, because
// `RateLimit` will already make sure we don't exceed the API rate limit.
// We're not doing exponential backoff because it will help with being
// rate limited, but rather to be chill in case MusicBrainz is returning
// some other error or our network is failing.
retryDelayMin = 100,
retryDelayMax = 60000,
randomizeRetry = true
} = {}) {
2016-11-26 01:38:32 +00:00
this.baseURL = baseURL
this.userAgent = userAgent
2016-12-07 08:23:02 +00:00
this.extraHeaders = extraHeaders
this.errorClass = errorClass
2016-11-26 01:38:32 +00:00
this.timeout = timeout
this.limiter = new RateLimit({ limit, period, concurrency })
2016-08-08 07:54:06 +00:00
this.retryOptions = {
2016-11-26 01:38:32 +00:00
retries,
minTimeout: retryDelayMin,
maxTimeout: retryDelayMax,
randomize: randomizeRetry
2016-08-08 07:54:06 +00:00
}
}
2016-08-20 05:59:32 +00:00
/**
* Determine if we should retry the request based on the given error.
* Retry any 5XX response from MusicBrainz, as well as any error in
* `RETRY_CODES`.
*/
shouldRetry(err) {
if (err instanceof this.errorClass) {
2016-08-08 07:54:06 +00:00
return err.statusCode >= 500 && err.statusCode < 600
}
return RETRY_CODES[err.code] || false
}
parseErrorMessage(response, body) {
return typeof body === 'string' && body ? body : `${response.statusCode}`
}
2016-08-20 05:59:32 +00:00
/**
* Send a request without any retrying or rate limiting.
* Use `get` instead.
*/
_get(path, options = {}, info = {}) {
2016-08-08 07:54:06 +00:00
return new Promise((resolve, reject) => {
options = {
2016-08-08 07:54:06 +00:00
baseUrl: this.baseURL,
url: path,
gzip: true,
timeout: this.timeout,
...options,
headers: {
'User-Agent': this.userAgent,
...this.extraHeaders,
...options.headers
}
2016-08-08 07:54:06 +00:00
}
const url = `${options.baseUrl}${options.url}`
debug(`Sending request. url=${url} attempt=${info.currentAttempt}`)
2016-08-08 07:54:06 +00:00
request(options, (err, response, body) => {
if (err) {
debug(`Error: “${err}” url=${url}`)
2016-08-08 07:54:06 +00:00
reject(err)
} else if (response.statusCode >= 400) {
const message = this.parseErrorMessage(response, body)
debug(`Error: “${message}” url=${url}`)
const ClientError = this.errorClass
reject(new ClientError(message, response.statusCode))
} else if (options.method === 'HEAD') {
resolve(response.headers)
2016-08-08 07:54:06 +00:00
} else {
resolve(body)
}
})
})
}
2016-08-20 05:59:32 +00:00
/**
* Send a request with retrying and rate limiting.
*/
get(path, options = {}) {
2016-08-08 07:54:06 +00:00
return new Promise((resolve, reject) => {
const fn = this._get.bind(this)
const operation = retry.operation(this.retryOptions)
operation.attempt(currentAttempt => {
2016-08-20 05:59:32 +00:00
// This will increase the priority in our `RateLimit` queue for each
// retry, so that newer requests don't delay this one further.
2016-08-08 07:54:06 +00:00
const priority = currentAttempt
this.limiter
.enqueue(fn, [path, options, { currentAttempt }], priority)
2016-08-08 07:54:06 +00:00
.then(resolve)
.catch(err => {
if (!this.shouldRetry(err) || !operation.retry(err)) {
reject(operation.mainError() || err)
}
})
})
})
}
}