graphbrainz/src/api.js

207 lines
6 KiB
JavaScript
Raw Normal View History

2016-08-08 07:54:06 +00:00
import request from 'request'
import retry from 'retry'
import qs from 'qs'
import chalk from 'chalk'
import ExtendableError from 'es6-error'
import RateLimit from './rate-limit'
import pkg from '../package.json'
2016-11-26 01:38:32 +00:00
const debug = require('debug')('graphbrainz:api')
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 MusicBrainzError extends ExtendableError {
constructor (message, statusCode) {
super(message)
this.statusCode = statusCode
}
}
export default class MusicBrainz {
2016-11-26 01:38:32 +00:00
constructor ({
baseURL = process.env.MUSICBRAINZ_BASE_URL || 'http://musicbrainz.org/ws/2/',
userAgent = `${pkg.name}/${pkg.version} ` +
`( ${pkg.homepage || pkg.author.url || pkg.author.email} )`,
timeout = 60000,
// MusicBrainz API requests are limited to an *average* of 1 req/sec.
// That means if, for example, we only need to make a few API requests to
// fulfill a query, we might as well make them all at once - as long as
// we then wait a few seconds before making more. In practice this can
// seemingly be set to about 5 requests every 5 seconds before we're
// considered to exceed the rate limit.
limit = 5,
period = 5000,
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
} = {}) {
this.baseURL = baseURL
this.userAgent = userAgent
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`.
*/
2016-08-08 07:54:06 +00:00
shouldRetry (err) {
if (err instanceof MusicBrainzError) {
return err.statusCode >= 500 && err.statusCode < 600
}
return RETRY_CODES[err.code] || false
}
2016-08-20 05:59:32 +00:00
/**
* Send a request without any retrying or rate limiting.
* Use `get` instead.
*/
_get (path, params, info = {}) {
2016-08-08 07:54:06 +00:00
return new Promise((resolve, reject) => {
const options = {
baseUrl: this.baseURL,
url: path,
2016-08-20 05:59:32 +00:00
qs: { ...params, fmt: 'json' },
2016-08-08 07:54:06 +00:00
json: true,
gzip: true,
headers: { 'User-Agent': this.userAgent },
timeout: this.timeout
}
2016-11-26 01:38:32 +00:00
debug(path, info.currentAttempt > 1 ? `(attempt #${info.currentAttempt})` : '')
2016-08-08 07:54:06 +00:00
request(options, (err, response, body) => {
if (err) {
reject(err)
} else if (response.statusCode !== 200) {
const message = (body && body.error) || ''
reject(new MusicBrainzError(message, response.statusCode))
} else {
resolve(body)
}
})
})
}
2016-08-20 05:59:32 +00:00
/**
* Send a request with retrying and rate limiting.
*/
2016-08-08 07:54:06 +00:00
get (path, params) {
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
2016-08-20 05:59:32 +00:00
this.limiter.enqueue(fn, [path, params, { 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)
}
})
})
})
}
2016-08-20 05:59:32 +00:00
stringifyParams (params) {
2016-08-08 07:54:06 +00:00
if (typeof params.inc === 'object') {
params = {
...params,
inc: params.inc.join('+')
}
}
2016-08-20 05:59:32 +00:00
if (typeof params.type === 'object') {
params = {
...params,
type: params.type.join('|')
}
}
if (typeof params.status === 'object') {
params = {
...params,
status: params.status.join('|')
}
}
return qs.stringify(params, {
2016-08-08 07:54:06 +00:00
skipNulls: true,
filter: (key, value) => value === '' ? undefined : value
})
2016-08-20 05:59:32 +00:00
}
getURL (path, params) {
const query = params ? this.stringifyParams(params) : ''
return query ? `${path}?${query}` : path
}
getLookupURL (entity, id, params) {
return this.getURL(`${entity}/${id}`, params)
2016-08-08 07:54:06 +00:00
}
lookup (entity, id, params = {}) {
const url = this.getLookupURL(entity, id, params)
return this.get(url)
}
2016-08-20 05:59:32 +00:00
getBrowseURL (entity, params) {
return this.getURL(entity, params)
}
browse (entity, params = {}) {
const url = this.getBrowseURL(entity, params)
return this.get(url)
}
getSearchURL (entity, query, params) {
return this.getURL(entity, { ...params, query })
}
search (entity, query, params = {}) {
const url = this.getSearchURL(entity, query, params)
return this.get(url)
}
2016-08-08 07:54:06 +00:00
}
if (require.main === module) {
const client = new MusicBrainz()
const fn = (id) => {
return client.lookup('artist', id).then(artist => {
console.log(chalk.green(`Done: ${id}${artist.name}`))
}).catch(err => {
console.log(chalk.red(`Error: ${id}${err}`))
})
}
fn('f1106b17-dcbb-45f6-b938-199ccfab50cc')
fn('a74b1b7f-71a5-4011-9441-d0b5e4122711')
fn('9b5ae4cc-15ae-4f0b-8a4e-8c44e42ba52a')
fn('26f77379-968b-4435-b486-fc9acb4590d3')
fn('8538e728-ca0b-4321-b7e5-cff6565dd4c0')
}