mirror of
https://github.com/BradNut/graphbrainz
synced 2025-09-08 17:40:32 +00:00
194 lines
5.5 KiB
JavaScript
194 lines
5.5 KiB
JavaScript
import request from 'request'
|
|
import retry from 'retry'
|
|
import qs from 'qs'
|
|
import ExtendableError from 'es6-error'
|
|
import RateLimit from './rate-limit'
|
|
import pkg from '../package.json'
|
|
|
|
const debug = require('debug')('graphbrainz:api')
|
|
|
|
// 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.
|
|
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 {
|
|
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} )`,
|
|
extraHeaders = {},
|
|
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 = 5500,
|
|
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.extraHeaders = extraHeaders
|
|
this.timeout = timeout
|
|
this.limiter = new RateLimit({ limit, period, concurrency })
|
|
this.retryOptions = {
|
|
retries,
|
|
minTimeout: retryDelayMin,
|
|
maxTimeout: retryDelayMax,
|
|
randomize: randomizeRetry
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 MusicBrainzError) {
|
|
return err.statusCode >= 500 && err.statusCode < 600
|
|
}
|
|
return RETRY_CODES[err.code] || false
|
|
}
|
|
|
|
/**
|
|
* Send a request without any retrying or rate limiting.
|
|
* Use `get` instead.
|
|
*/
|
|
_get (path, params, info = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const options = {
|
|
baseUrl: this.baseURL,
|
|
url: path,
|
|
qs: { ...params, fmt: 'json' },
|
|
json: true,
|
|
gzip: true,
|
|
headers: { 'User-Agent': this.userAgent, ...this.extraHeaders },
|
|
timeout: this.timeout
|
|
}
|
|
|
|
debug(`Sending request. url=${path} attempt=${info.currentAttempt}`)
|
|
|
|
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)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Send a request with retrying and rate limiting.
|
|
*/
|
|
get (path, params) {
|
|
return new Promise((resolve, reject) => {
|
|
const fn = this._get.bind(this)
|
|
const operation = retry.operation(this.retryOptions)
|
|
operation.attempt(currentAttempt => {
|
|
// This will increase the priority in our `RateLimit` queue for each
|
|
// retry, so that newer requests don't delay this one further.
|
|
const priority = currentAttempt
|
|
this.limiter.enqueue(fn, [path, params, { currentAttempt }], priority)
|
|
.then(resolve)
|
|
.catch(err => {
|
|
if (!this.shouldRetry(err) || !operation.retry(err)) {
|
|
reject(operation.mainError() || err)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
stringifyParams (params) {
|
|
if (Array.isArray(params.inc)) {
|
|
params = {
|
|
...params,
|
|
inc: params.inc.join('+')
|
|
}
|
|
}
|
|
if (Array.isArray(params.type)) {
|
|
params = {
|
|
...params,
|
|
type: params.type.join('|')
|
|
}
|
|
}
|
|
if (Array.isArray(params.status)) {
|
|
params = {
|
|
...params,
|
|
status: params.status.join('|')
|
|
}
|
|
}
|
|
return qs.stringify(params, {
|
|
skipNulls: true,
|
|
filter: (key, value) => value === '' ? undefined : value
|
|
})
|
|
}
|
|
|
|
getURL (path, params) {
|
|
const query = params ? this.stringifyParams(params) : ''
|
|
return query ? `${path}?${query}` : path
|
|
}
|
|
|
|
getLookupURL (entity, id, params) {
|
|
if (id == null) {
|
|
return this.getBrowseURL(entity, params)
|
|
}
|
|
return this.getURL(`${entity}/${id}`, params)
|
|
}
|
|
|
|
lookup (entity, id, params = {}) {
|
|
const url = this.getLookupURL(entity, id, params)
|
|
return this.get(url)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|